Compare commits

...

5 Commits

Author SHA1 Message Date
Sebastian Dine d3b4762b76 Minor: Additional functions (#25)
* several functions. fixes #1 and #2

* multi selection functionality
2026-08-03 08:35:38 +02:00
Sebastian Dine 2eb7c59f78 Minor: New Game Yu-Gi-Oh! Bandai (#22) 2026-07-30 14:51:53 +02:00
Sebastian Dine 7cf25d671f convenience features (#21) 2026-07-24 08:56:53 +02:00
Sebastian Dine ab0e3c5ae2 Fix: replace api.pokemontcg.io with api.tcgdex.net (#20) 2026-07-23 17:58:00 +02:00
Sebastian Dine 9917e364c1 Minor: Set completion tracking (#19) 2026-07-23 10:29:56 +02:00
157 changed files with 12724 additions and 812 deletions
+2 -2
View File
@@ -84,7 +84,7 @@ Run from the **workspace root**.
- Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically.
- `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically.
- `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `<exeDir>/.cache/preview-cache/`**next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path.
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
## Windows UI theming guardrails
@@ -92,7 +92,7 @@ Run from the **workspace root**.
- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows.
- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light.
- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize).
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed.
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme` in `Theme.cpp` (opt out of immersive dark mode + parent `WM_CTLCOLOREDIT` subclass — that message goes to the EDIT's parent, not `MainFrame`).
- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows.
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
+8
View File
@@ -9,6 +9,7 @@ Currently, the application supports the following TCGs:
- Magic the Gathering
- Pokemon TCG
- Yu-Gi-Oh!
- Yu-Gi-Oh! (Bandai)
- Digimon (Digi-Battle)
## Screenshots
@@ -34,6 +35,13 @@ Currently, the application supports the following TCGs:
</details>
<details>
<summary>Yu-Gi-Oh! (Bandai)</summary>
![CCM3 Demo - Yu-Gi-Oh! (Bandai)](docs/assets/images/demo-ygo-bandai.png)
</details>
<details>
<summary>Digimon (Digi-Battle)</summary>
+1 -1
View File
@@ -9,7 +9,7 @@ The `ccm` executable — composition root only. The single place where concrete
## Conventions
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonCollectionRepository<YuGiOhBandaiCard>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `YuGiOhBandaiSetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `YuGiOhBandaiGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, `YuGiOhBandaiGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`).
+45 -4
View File
@@ -5,6 +5,7 @@
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
@@ -12,6 +13,7 @@
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/infra/JsonCollectionRepository.hpp"
#include "ccm/infra/JsonSetRepository.hpp"
@@ -21,6 +23,10 @@
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/PokemonSetCatalogService.hpp"
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/AppContext.hpp"
@@ -28,6 +34,7 @@
#include "ccm/ui/MagicGameView.hpp"
#include "ccm/ui/MainFrame.hpp"
#include "ccm/ui/PokemonGameView.hpp"
#include "ccm/ui/YuGiOhBandaiGameView.hpp"
#include "ccm/ui/YuGiOhGameView.hpp"
#include <wx/app.h>
@@ -51,6 +58,7 @@ std::string dirNameForGame(ccm::Game g) {
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
case ccm::Game::DigiBattle99: return "digibattle99";
case ccm::Game::YuGiOhBandai: return "yugiohbandai";
case ccm::Game::JapanesePokemon: return "pokemon";
}
return "magic";
@@ -88,6 +96,7 @@ public:
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
ygoBandaiMod_ = std::make_unique<ccm::YuGiOhBandaiGameModule>(*http_);
ccm::JapanesePokemonEnCatalog jpCatalog;
{
@@ -109,7 +118,19 @@ public:
digiBattle99Repo_ =
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
*fs_, *config_, &dirNameForGame);
ygoBandaiRepo_ =
std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>>(
*fs_, *config_, &dirNameForGame);
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
digiBattle99CatalogStore_ =
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoCatalogStore_ =
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoMod_->setCatalogService(ygoCatalogStore_.get());
ygoBandaiCatalogStore_ =
std::make_unique<ccm::YuGiOhBandaiSetCatalogService>(*fs_, *config_, &dirNameForGame);
pokeCatalogStore_ =
std::make_unique<ccm::PokemonSetCatalogService>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
@@ -122,11 +143,15 @@ public:
digiBattle99CollSvc_ =
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
*digiBattle99Repo_, *imgStore_);
ygoBandaiCollSvc_ =
std::make_unique<ccm::CollectionService<ccm::YuGiOhBandaiCard>>(
*ygoBandaiRepo_, *imgStore_);
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
setSvc_->registerModule(magicMod_.get());
setSvc_->registerModule(pokeMod_.get());
setSvc_->registerModule(ygoMod_.get());
setSvc_->registerModule(digiBattle99Mod_.get());
setSvc_->registerModule(ygoBandaiMod_.get());
setSvc_->registerModule(jpPokeMod_.get());
// Disk-backed preview cache lives next to the executable, in the same
@@ -152,18 +177,24 @@ public:
previewSvc_->registerModule(*pokeMod_);
previewSvc_->registerModule(*ygoMod_);
previewSvc_->registerModule(*digiBattle99Mod_);
previewSvc_->registerModule(*ygoBandaiMod_);
previewSvc_->registerModule(*jpPokeMod_);
// Per-game UI bundles. Order here is the order shown in the Game menu.
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_, *jpPokeMod_,
*pokeCatalogStore_);
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
*ygoCatalogStore_);
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*digiBattle99Mod_);
*digiBattle99Mod_, *digiBattle99CatalogStore_);
ygoBandaiView_ = std::make_unique<ccm::ui::YuGiOhBandaiGameView>(
*config_, *ygoBandaiCollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*ygoBandaiMod_, *ygoBandaiCatalogStore_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
@@ -174,8 +205,10 @@ public:
*pokeMod_,
*ygoMod_,
*digiBattle99Mod_,
*ygoBandaiMod_,
*jpPokeMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
{ magicView_.get(), pokeView_.get(), ygoView_.get(), ygoBandaiView_.get(),
digiBattle99View_.get() },
});
auto* frame = new ccm::ui::MainFrame(*ctx_);
@@ -197,18 +230,25 @@ private:
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
std::unique_ptr<ccm::YuGiOhBandaiGameModule> ygoBandaiMod_;
std::unique_ptr<ccm::JapanesePokemonGameModule> jpPokeMod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>> ygoBandaiRepo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
std::unique_ptr<ccm::YuGiOhBandaiSetCatalogService> ygoBandaiCatalogStore_;
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhBandaiCard>> ygoBandaiCollSvc_;
std::unique_ptr<ccm::SetService> setSvc_;
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
@@ -216,6 +256,7 @@ private:
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
std::unique_ptr<ccm::ui::DigiBattle99GameView> digiBattle99View_;
std::unique_ptr<ccm::ui::YuGiOhBandaiGameView> ygoBandaiView_;
std::unique_ptr<ccm::ui::AppContext> ctx_;
};
+1 -1
View File
@@ -31,7 +31,7 @@ FetchContent_MakeAvailable(nlohmann_json)
# ---------------------------------------------------------------------------
# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need
# a system libcurl. Used for Scryfall + pokemontcg.io REST calls.
# a system libcurl. Used for Scryfall + TCGdex REST calls.
#
# Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)`
# rule that references `libcurl_shared`, which isn't in any export set when
+4 -4
View File
@@ -4,11 +4,11 @@
## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `YuGiOhBandaiCard`, `YuGiOhBandaiSetCatalog` (Bandai pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect.
- `include/ccm/services/`high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `YuGiOhBandaiSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `yugiohbandai/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all expose a fully working set source + card preview source. `YuGiOhSetSource`, `YuGiOhBandaiSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
- `src/` mirrors `include/ccm/` for non-template implementations.
@@ -27,7 +27,7 @@
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `<setNo>||<rarity>||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface.
10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `<exeDir>/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `<hash>.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path.
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
## Adding a new game
+19
View File
@@ -7,7 +7,12 @@ add_library(ccm_core STATIC
src/domain/MagicCard.cpp
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/YuGiOhBandaiCard.cpp
src/domain/YuGiOhBandaiSetCatalog.cpp
src/domain/DigiBattle99Card.cpp
src/domain/DigiBattle99SetCatalog.cpp
src/domain/YuGiOhSetCatalog.cpp
src/domain/PokemonSetCatalog.cpp
src/domain/JapanesePokemonCard.cpp
src/domain/Configuration.cpp
@@ -17,6 +22,14 @@ add_library(ccm_core STATIC
src/services/CardPreviewService.cpp
src/services/CardSorter.cpp
src/services/CardFilter.cpp
src/services/DigiBattle99SetCompletion.cpp
src/services/DigiBattle99SetCatalogService.cpp
src/services/YuGiOhSetCompletion.cpp
src/services/YuGiOhSetCatalogService.cpp
src/services/YuGiOhBandaiSetCompletion.cpp
src/services/YuGiOhBandaiSetCatalogService.cpp
src/services/PokemonSetCompletion.cpp
src/services/PokemonSetCatalogService.cpp
src/infra/CprHttpClient.cpp
src/infra/StdFileSystem.cpp
@@ -27,6 +40,8 @@ add_library(ccm_core STATIC
src/games/magic/MagicSetSource.cpp
src/games/magic/MagicCardPreviewSource.cpp
src/games/magic/MagicGameModule.cpp
src/games/pokemon/PokemonWestSetId.cpp
src/games/pokemon/PokemonCollectionSetSync.cpp
src/games/pokemon/PokemonSetSource.cpp
src/games/pokemon/PokemonCardPreviewSource.cpp
src/games/pokemon/PokemonGameModule.cpp
@@ -36,12 +51,16 @@ add_library(ccm_core STATIC
src/games/digibattle99/DigiBattle99SetSource.cpp
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
src/games/digibattle99/DigiBattle99GameModule.cpp
src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp
src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp
src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
src/games/pokemonjp/JapanesePokemonSetSource.cpp
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
src/games/pokemonjp/JapanesePokemonGameModule.cpp
src/util/FsNames.cpp
src/util/SetNoNatural.cpp
)
target_include_directories(ccm_core
@@ -0,0 +1,52 @@
#pragma once
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
// completion. Filled from digimoncard.io bulk search.php (same payload as the
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99CatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const DigiBattle99CatalogCard&,
const DigiBattle99CatalogCard&) = default;
};
struct DigiBattle99SetCatalogPack {
std::string setId;
std::string setName;
std::vector<DigiBattle99CatalogCard> cards;
friend bool operator==(const DigiBattle99SetCatalogPack&,
const DigiBattle99SetCatalogPack&) = default;
};
struct DigiBattle99SetCatalog {
std::vector<DigiBattle99SetCatalogPack> packs;
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const DigiBattle99SetCatalog&,
const DigiBattle99SetCatalog&) = default;
};
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
} // namespace ccm
+2 -1
View File
@@ -21,6 +21,7 @@ enum class Game {
Pokemon,
YuGiOh,
DigiBattle99,
YuGiOhBandai,
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
};
@@ -70,7 +71,7 @@ std::optional<Condition> conditionFromString(std::string_view s) noexcept;
std::optional<Theme> themeFromString(std::string_view s) noexcept;
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
const std::array<Game, 4>& allGames() noexcept;
const std::array<Game, 5>& allGames() noexcept;
const std::array<Language, 10>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
@@ -0,0 +1,52 @@
#pragma once
// PokemonSetCatalog: offline pack → card checklist for Pokemon set
// completion. West and Asia each persist their own file under
// `<dataStorage>/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`).
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const PokemonCatalogCard&,
const PokemonCatalogCard&) = default;
};
struct PokemonSetCatalogPack {
std::string setId;
std::string setName;
std::vector<PokemonCatalogCard> cards;
friend bool operator==(const PokemonSetCatalogPack&,
const PokemonSetCatalogPack&) = default;
};
struct PokemonSetCatalog {
std::vector<PokemonSetCatalogPack> packs;
[[nodiscard]] const PokemonSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const PokemonSetCatalog&,
const PokemonSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const PokemonCatalogCard& c);
void from_json(const nlohmann::json& j, PokemonCatalogCard& c);
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p);
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p);
void to_json(nlohmann::json& j, const PokemonSetCatalog& c);
void from_json(const nlohmann::json& j, PokemonSetCatalog& c);
} // namespace ccm
@@ -0,0 +1,37 @@
#pragma once
// YuGiOhBandaiCard - Bandai Carddass (pre-Konami) card model.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct YuGiOhBandaiCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string rarity;
std::string note;
std::vector<std::string> images;
Language language{Language::Japanese};
Condition condition{Condition::NearMint};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const YuGiOhBandaiCard&, const YuGiOhBandaiCard&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c);
} // namespace ccm
@@ -0,0 +1,52 @@
#pragma once
// YuGiOhBandaiSetCatalog: offline pack → card checklist for Bandai set
// completion. Filled from Yugipedia set-gallery wikitext and persisted at
// `<dataStorage>/yugiohbandai/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhBandaiCatalogCard {
std::string setNo;
std::string name;
std::string rarity;
friend bool operator==(const YuGiOhBandaiCatalogCard&,
const YuGiOhBandaiCatalogCard&) = default;
};
struct YuGiOhBandaiSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhBandaiCatalogCard> cards;
friend bool operator==(const YuGiOhBandaiSetCatalogPack&,
const YuGiOhBandaiSetCatalogPack&) = default;
};
struct YuGiOhBandaiSetCatalog {
std::vector<YuGiOhBandaiSetCatalogPack> packs;
[[nodiscard]] const YuGiOhBandaiSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhBandaiSetCatalog&,
const YuGiOhBandaiSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c);
} // namespace ccm
@@ -0,0 +1,55 @@
#pragma once
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhCatalogCard {
std::string setNo;
std::string name;
/// YGOPRODeck `set_rarity` for this printing when known (optional;
/// older `set-catalog.json` files omit it).
std::string rarity{};
friend bool operator==(const YuGiOhCatalogCard&,
const YuGiOhCatalogCard&) = default;
};
struct YuGiOhSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhCatalogCard> cards;
friend bool operator==(const YuGiOhSetCatalogPack&,
const YuGiOhSetCatalogPack&) = default;
};
struct YuGiOhSetCatalog {
std::vector<YuGiOhSetCatalogPack> packs;
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhSetCatalog&,
const YuGiOhSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
} // namespace ccm
@@ -35,6 +35,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setName) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setName,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setName,
std::string_view setNo) override;
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
// Does not invent zero-padding — CDN keys match API ids literally.
static std::string normalizeCardNumber(std::string_view setNo);
@@ -58,7 +64,8 @@ public:
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setName,
std::string_view wantedCardName);
std::string_view wantedCardName,
std::string_view wantedSetNo = {});
private:
IHttpClient& http_;
@@ -3,12 +3,15 @@
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
// The same payload also builds the set-completion catalog (parseCatalog).
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -20,12 +23,21 @@ public:
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
struct FetchWithCatalog {
std::vector<Set> sets;
DigiBattle99SetCatalog catalog;
};
explicit DigiBattle99SetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
// One HTTP round-trip producing both the set list and the pack catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
static std::string slugifyPackName(std::string_view packName);
@@ -1,11 +1,9 @@
#pragma once
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
// TCG. Calls the Pokemon TCG search endpoint at
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
// and returns `data[0].images.large` (with `images.small` as a graceful
// fallback). Mirrors the established `getImage` flow in
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
// PokemonCardPreviewSource: West Pokemon previews via TCGdex EN.
// Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then
// set-detail name match for auto-detect. Image URLs append /high.png (wxImage
// decodes PNG, not webp).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
@@ -31,32 +29,47 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// Strip everything after the first '/' (e.g. "4/102" -> "4").
static std::string normalizeCollectorNumber(std::string_view setNo);
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
static std::string buildSetDetailUrl(std::string_view setId);
static std::string buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo);
static std::string imageUrlFromBase(std::string_view imageBase);
// Slimmer search URL for auto-detect: omits the number clause and asks the
// API for only the fields the print-variant parser needs.
static std::string buildDetectSearchUrl(std::string_view name,
std::string_view setId);
struct SetCardRow {
std::string localId;
std::string name;
std::string imageBase;
std::string rarity;
};
static Result<std::vector<SetCardRow>, PreviewLookupError>
parseSetCards(const std::string& body);
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`. Errors are classified:
// - JSON parse failure or missing/non-array `data` => Transient.
// - Empty `data` array or missing image variants => NotFound.
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
parseCardByIdResponse(const std::string& body);
// Parse a slim TCGdex cards-array search response; prefer first hit with image.
static Result<std::string, PreviewLookupError>
parseSearchResponse(const std::string& body);
// Enumerate distinct collector numbers (and rarities) for an exact card
// name inside the chosen set. Exposed for unit testing without HTTP.
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
std::string_view wantedCardName);
// Parse TCGdex card-by-id JSON into print metadata (name + localId + rarity).
static Result<AutoDetectedPrint> parsePrintFromCardById(const std::string& body);
private:
IHttpClient& http_;
};
@@ -0,0 +1,21 @@
#pragma once
// Sync Pokemon collection cards against freshly fetched set lists:
// - West: canonicalize legacy pokemontcg set ids, then refresh name/date
// - Asia: refresh name/date when the set id is present in the Asia list
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/Set.hpp"
#include <cstddef>
#include <vector>
namespace ccm {
// Mutates cards in place. Returns how many cards changed at least one set field.
[[nodiscard]] std::size_t syncPokemonCollectionSets(
std::vector<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& asiaSets);
} // namespace ccm
@@ -1,7 +1,7 @@
#pragma once
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
// and card preview source, both backed by api.pokemontcg.io/v2.
// and card preview source, both backed by TCGdex EN (api.tcgdex.net/v2/en).
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
@@ -1,27 +1,45 @@
#pragma once
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
// response into our `Set` domain type, and sorts by release date ascending.
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
// so no rewriting is needed (unlike Scryfall's `released_at`).
// Behavior matches `pokemon/set_services.rs::update_sets`.
// PokemonSetSource: ISetSource for West Pokemon via TCGdex EN
// (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release
// dates and set-completion checklists come from per-set detail GETs.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class PokemonSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
explicit PokemonSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
// List + per-set detail (cards + release date) for the offline checklist.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseListResponse(const std::string& body);
static Result<std::string> parseReleaseDate(const std::string& detailBody);
static std::string rewriteReleaseDate(std::string_view isoDate);
static std::string buildSetDetailUrl(std::string_view setId);
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set);
private:
IHttpClient& http_;
@@ -0,0 +1,16 @@
#pragma once
// Canonicalize legacy pokemontcg.io West set ids to TCGdex EN ids.
// Identity when the id is already TCGdex (or unknown). Asia set ids must not
// be passed through this helper.
#include <string>
#include <string_view>
namespace ccm {
// Returns the TCGdex EN set id for a West Pokemon card.set.id. Unknown ids
// and ids that already match TCGdex are returned unchanged.
[[nodiscard]] std::string canonicalizeWestSetId(std::string_view setId);
} // namespace ccm
@@ -29,6 +29,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
static std::string normalizeLocalId(std::string_view setNo);
static std::string buildSetDetailUrl(std::string_view setId);
static std::string buildCardUrl(std::string_view setId, std::string_view localId);
@@ -60,6 +66,15 @@ public:
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog);
// Reverse lookup: set + localId → name via catalog (no HTTP).
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCatalog(std::string_view setId,
std::string_view localId,
const JapanesePokemonEnCatalog& catalog);
// Parse TCGdex JA card-by-id JSON into print metadata.
static Result<AutoDetectedPrint> parsePrintFromCardResponse(const std::string& body);
private:
IHttpClient& http_;
const JapanesePokemonEnCatalog& catalog_;
@@ -54,6 +54,10 @@ public:
[[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept;
// All prints for a set (catalog gap-fill / set-completion checklists).
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
printsForSet(std::string_view setId) const;
// TCGPlayer product-image CDN URL for classic JA gap-fill.
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
@@ -1,22 +1,37 @@
#pragma once
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
// dates. English display names come from JapanesePokemonEnCatalog when present.
// dates and set-completion checklists. English display names come from
// JapanesePokemonEnCatalog when present.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class JapanesePokemonSetSource final : public ISetSource {
public:
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
Result<std::vector<Set>> fetchAll() override;
// List + per-set detail (cards + release date) + EN catalog gap-fill.
Result<FetchWithCatalog> fetchAllWithCatalog();
void augmentCachedSets(std::vector<Set>& sets) const override;
// Pure parsers for hermetic tests.
@@ -28,6 +43,16 @@ public:
static std::string rewriteReleaseDate(std::string_view isoDate);
static std::string buildSetDetailUrl(std::string_view setId);
// Build one pack checklist from a set-detail body, then gap-fill from catalog.
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog);
// Catalog-only pack (classic products with no TCGdex detail).
static PokemonSetCatalogPack catalogPackFromEnCatalog(
const Set& set, const JapanesePokemonEnCatalog& enCatalog);
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
static void appendMissingClassicProducts(std::vector<Set>& sets);
@@ -1,8 +1,11 @@
#pragma once
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -25,6 +28,9 @@ namespace ccm {
// that endpoint returns a richer set listing (with rarities and release
// dates) than Yugipedia, and we don't need image data for it.
//
// Reverse lookup (set + setNo → name) uses the offline set-completion catalog
// written by Sets → Update Yu-Gi-Oh! (`YuGiOhSetCatalogService`).
//
// Region policy: always English (EN/NA/EU/AU) regardless of the card's
// stored Language. Localized scans are intentionally not queried so the user
// sees a consistent, well-stocked gallery (EN scans are the most complete).
@@ -32,6 +38,12 @@ class YuGiOhCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhCardPreviewSource(IHttpClient& http);
// Optional offline catalog for set+setNo → name reverse lookup. When null
// or empty, detectVariantsBySetNo returns a clear "Update Sets" error.
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
catalogStore_ = catalogStore;
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
@@ -43,6 +55,12 @@ public:
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// ---- Yugipedia helpers (image preview path) ----------------------------
// Build the list of candidate Yugipedia file names to try, in priority
@@ -116,8 +134,29 @@ public:
std::string_view preferredSetName,
std::string_view wantedCardName);
// Offline reverse lookup against a set-completion catalog. `setId` is the
// pack's set code (e.g. "LOB"); `setNo` may be digits ("005") or a full
// collector code ("LOB-005" / "LOB-EN005").
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCatalog(const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::string_view setNo);
// YGOPRODeck cardset= dump filtered by collector digits (HTTP fallback when
// the offline catalog is missing or has no match).
static Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNoFromCardset(const std::string& body,
std::string_view preferredSetName,
std::string_view setNo);
static std::string buildCardsetOnlyUrl(std::string_view setName);
private:
IHttpClient& http_;
IHttpClient& http_;
YuGiOhSetCatalogService* catalogStore_{nullptr};
// Cached offline catalog so reverse auto-detect does not re-parse a
// multi-MB JSON file on every button click.
mutable std::optional<YuGiOhSetCatalog> catalogCache_;
};
} // namespace ccm
@@ -3,6 +3,7 @@
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
namespace ccm {
@@ -17,6 +18,15 @@ public:
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
// Wire offline set catalog for set+setNo → name reverse auto-detect.
void setCatalogService(YuGiOhSetCatalogService* catalogStore) noexcept {
previewSource_.setCatalogService(catalogStore);
}
[[nodiscard]] YuGiOhCardPreviewSource& previewSource() noexcept {
return previewSource_;
}
private:
YuGiOhSetSource setSource_;
YuGiOhCardPreviewSource previewSource_;
@@ -1,21 +1,45 @@
#pragma once
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
// Sets come from cardsets.php; the set-completion catalog is built from the
// unfiltered cardinfo.php dump (card_sets[] per card).
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <vector>
namespace ccm {
class YuGiOhSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
static constexpr const char* kCardInfoEndpoint =
"https://db.ygoprodeck.com/api/v7/cardinfo.php";
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhSetCatalog catalog;
};
explicit YuGiOhSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for
// the pack checklist catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Build the offline checklist from a cardinfo.php body, resolving pack
// ids against the already-parsed sets list (by set_name → Set.id).
static Result<YuGiOhSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
private:
IHttpClient& http_;
};
@@ -0,0 +1,82 @@
#pragma once
// YuGiOhBandaiCardPreviewSource: Yugipedia pageimages + SMW ask for Bandai
// Carddass previews and auto-detect (by English name or Bandai number).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class YuGiOhBandaiCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhBandaiCardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override;
// Prefer "<Name> (Bandai)" / English / Sealdass page depending on setId.
static std::string preferredPageTitle(std::string_view name,
std::string_view setId,
std::string_view setNo);
static std::string buildPageImagesUrl(std::string_view pageTitle);
static std::string buildAskByNameUrl(std::string_view englishName);
static std::string buildAskByNumberUrl(std::string_view setNo);
// True for Jump/Toei promo codes (J1, TA2, …). Yugipedia's SMW
// `Bandai number` property is numeric-only, so these must use the
// promotional gallery instead of `action=ask`.
[[nodiscard]] static bool isAlphanumericPromoNumber(std::string_view setNo);
static Result<std::vector<AutoDetectedPrint>>
parsePromoGalleryResponse(const std::string& body,
std::string_view wantedSetNo);
static Result<std::string, PreviewLookupError>
parsePageImagesResponse(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
parseAskResponse(const std::string& body,
std::string_view preferredSetId,
std::string_view wantedSetNo = {});
static AutoDetectedPrint enrichPrint(AutoDetectedPrint print,
std::string_view pageTitle);
private:
Result<std::string, PreviewLookupError> fetchPageImage(std::string_view pageTitle);
Result<std::vector<AutoDetectedPrint>> askByName(std::string_view name,
std::string_view setId);
Result<std::vector<AutoDetectedPrint>> askByNumber(std::string_view setId,
std::string_view setNo);
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,29 @@
#pragma once
// YuGiOhBandaiGameModule: Bandai Carddass via Yugipedia.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
namespace ccm {
class YuGiOhBandaiGameModule final : public IGameModule {
public:
explicit YuGiOhBandaiGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOhBandai; }
[[nodiscard]] std::string dirName() const override { return "yugiohbandai"; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
YuGiOhBandaiSetSource& bandaiSetSource() noexcept { return setSource_; }
private:
YuGiOhBandaiSetSource setSource_;
YuGiOhBandaiCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,67 @@
#pragma once
// YuGiOhBandaiSetSource: hardcoded Bandai set manifest + Yugipedia gallery
// wikitext catalogs for set completion.
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class YuGiOhBandaiSetSource final : public ISetSource {
public:
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhBandaiSetCatalog catalog;
};
struct SetManifestEntry {
const char* id;
const char* name;
const char* releaseDate; // YYYY/MM/DD
const char* galleryPage; // Yugipedia page title (may be shared)
// For the shared promo gallery: keep cards whose setNo starts with
// this prefix (empty = keep all from that page into this pack).
const char* setNoPrefix;
};
explicit YuGiOhBandaiSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
Result<FetchWithCatalog> fetchAllWithCatalog();
[[nodiscard]] static const std::vector<SetManifestEntry>& setManifest();
static Result<std::vector<Set>> parseResponse(const std::string& /*unused*/);
// Parse one gallery wikitext body into checklist cards.
static Result<std::vector<YuGiOhBandaiCatalogCard>>
parseGalleryWikitext(const std::string& wikitext);
// Map a Bandai number string to a set id (ban1/ban2/ban3/promos/sealdass).
static std::string setIdForNumber(std::string_view setNo);
static std::string setNameForId(std::string_view setId);
// Normalize printed numbers: strip leading zeros on pure-decimal values;
// uppercase letter prefixes (j1 → J1). Sealdass stays unpadded decimal.
static std::string normalizeCardNumber(std::string_view setNo);
static std::string expandRarityCode(std::string_view code);
static std::string buildGalleryParseUrl(std::string_view pageTitle);
static std::string englishNameFromGalleryTitle(std::string_view pageTitle);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -19,6 +19,12 @@ namespace ccm {
struct AutoDetectedPrint {
std::string setNo;
std::string rarity;
// Optional fields used by games that resolve set/name/language during
// auto-detect (e.g. Yu-Gi-Oh! Bandai). Existing games leave them empty.
std::string name;
std::string setId;
std::string setName;
std::string language; // Language enum spelling when known ("Japanese" / "English")
};
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
@@ -79,6 +85,23 @@ public:
return Result<std::vector<AutoDetectedPrint>>::err(
"Print variant listing not supported by this game.");
}
// Optional lookup by set + collector / Bandai number (fills name + rarity).
// `setId` uses the same meaning as detectPrintVariants for the game
// (set id for Pokémon/Bandai; set display name for Digi-Battle; set code
// id for Yu-Gi-Oh! catalog reverse lookup).
virtual Result<AutoDetectedPrint> detectBySetNo(std::string_view /*setId*/,
std::string_view /*setNo*/) {
return Result<AutoDetectedPrint>::err(
"Detect-by-number not supported by this game.");
}
virtual Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNo(std::string_view /*setId*/,
std::string_view /*setNo*/) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Detect-by-number variants not supported by this game.");
}
};
} // namespace ccm
+5
View File
@@ -23,6 +23,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <string_view>
@@ -47,6 +48,10 @@ namespace ccm {
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
std::string_view filter);
// Bandai: name, set.name, setNo, rarity, language, condition, amount, note.
[[nodiscard]] bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card,
std::string_view filter);
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter);
@@ -81,6 +81,15 @@ public:
std::string_view name,
std::string_view setId);
Result<AutoDetectedPrint> detectBySetNo(Game game,
std::string_view setId,
std::string_view setNo);
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
Game game,
std::string_view setId,
std::string_view setNo);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution. Cached by URL (same LRU bound).
Result<std::string> fetchImageBytesByUrl(std::string_view url);
+18
View File
@@ -20,6 +20,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <vector>
@@ -82,6 +83,20 @@ enum class DigiBattle99SortColumn {
Note,
};
enum class YuGiOhBandaiSortColumn {
Name,
SetReleaseDate,
SetNo,
Rarity,
Language,
Condition,
Amount,
Holo,
Signed,
Altered,
Note,
};
// Japanese Pokemon mirrors Pokemon columns.
enum class JapanesePokemonSortColumn {
Name,
@@ -107,6 +122,9 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
DigiBattle99SortColumn column,
bool ascending);
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
YuGiOhBandaiSortColumn column,
bool ascending);
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending);
@@ -80,6 +80,15 @@ public:
return repo_.save(game, map);
}
// Replace the entire collection map in one save (e.g. after bulk set-id sync).
Result<void> saveAll(Game game, std::vector<TCard> cards) {
Map map;
for (auto& card : cards) {
map.insert_or_assign(card.id, std::move(card));
}
return repo_.save(game, map);
}
// Remove the card with the given id. Also deletes any associated images
// via the IImageStore (best-effort - image removal failures are logged in
// the error string but the card itself is still purged from the JSON).
@@ -0,0 +1,36 @@
#pragma once
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
// the configured dataStorage path.
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class DigiBattle99SetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<DigiBattle99SetCatalog> load() const;
Result<void> save(const DigiBattle99SetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,59 @@
#pragma once
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
// inflate the numerator. An optional languageFilter restricts ownership to
// cards of that language (packs with zero matches are omitted).
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99SetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct DigiBattle99ChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,36 @@
#pragma once
// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and
// pokemon/set-catalog-asia.json under the configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class PokemonSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<PokemonSetCatalog> load(PokemonRegion region) const;
Result<void> save(PokemonRegion region, const PokemonSetCatalog& catalog);
[[nodiscard]] bool exists(PokemonRegion region) const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const;
};
} // namespace ccm
@@ -0,0 +1,73 @@
#pragma once
// Pure helpers: Pokemon set-completion progress and per-set checklists.
// Ownership requires matching PokemonRegion for the pack (West vs Asia),
// matching set.id, and a normalized collector number / localId. Duplicates /
// amount / holo / firstEdition do not inflate the numerator. Optional
// regionFilter and languageFilter restrict which cards count (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonSetCompletionProgress {
PokemonRegion region{PokemonRegion::West};
std::string setId;
std::string setName;
std::string releaseDate; // YYYY/MM/DD from owned cards; may be empty
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct PokemonChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection (optionally region-scoped),
// in allLanguages() order.
[[nodiscard]] std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter = std::nullopt);
// Distinct regions that have ≥1 owned card matching a catalog pack.
[[nodiscard]] std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog);
// Packs where the collection owns ≥1 matching card, ordered by releaseDate
// then setName then region. When regionFilter is set, only that region's
// catalog/cards count.
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter = std::nullopt,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
[[nodiscard]] std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
+4
View File
@@ -27,6 +27,10 @@ public:
// repository, and return the new list.
Result<std::vector<Set>> updateSets(Game game);
// Persist an already-fetched set list (no HTTP). Used when a game-specific
// Update Sets path fetches sets + side payloads in one round-trip.
Result<void> saveSets(Game game, const std::vector<Set>& sets);
// Cached read; returns an error if no local data exists yet.
Result<std::vector<Set>> getSets(Game game);
@@ -0,0 +1,35 @@
#pragma once
// YuGiOhBandaiSetCatalogService: load/save yugiohbandai/set-catalog.json.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhBandaiSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhBandaiSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhBandaiSetCatalog> load() const;
Result<void> save(const YuGiOhBandaiSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,51 @@
#pragma once
// Pure helpers: Bandai set-completion progress and per-set checklists.
// Ownership keys on (set.id, normalized setNo). Never name-only.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhBandaiSetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct YuGiOhBandaiChecklistEntry {
std::string setNo;
std::string name;
std::string rarity;
bool owned{false};
};
[[nodiscard]] std::vector<Language>
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& collection);
[[nodiscard]] std::vector<YuGiOhBandaiSetCompletionProgress>
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
[[nodiscard]] std::vector<YuGiOhBandaiChecklistEntry>
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,36 @@
#pragma once
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
// configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhSetCatalog> load() const;
Result<void> save(const YuGiOhSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,60 @@
#pragma once
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
// amount / rarity / firstEdition do not inflate the numerator. An optional
// languageFilter restricts ownership to cards of that language (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhSetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct YuGiOhChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,24 @@
#pragma once
// Shared rule for bidirectional Set # Auto detect (name ↔ set number):
// when both fields are filled, the field the user last edited is the lookup key.
namespace ccm {
enum class CardLookupEditField { None, Name, SetNo };
// Returns true when Auto detect should run setNo → name (reverse).
// `nameEmpty` / `setNoEmpty` are already trimmed/normalized by the caller.
// When both are empty the result is false (caller shows a validation message).
// When only one is filled, that direction wins. When both are filled, SetNo
// wins only if it was the last edited lookup field; otherwise Name wins
// (including `None`, matching the historical default).
[[nodiscard]] inline bool preferDetectBySetNo(bool nameEmpty,
bool setNoEmpty,
CardLookupEditField lastEdited) noexcept {
if (nameEmpty) return !setNoEmpty;
if (setNoEmpty) return false;
return lastEdited == CardLookupEditField::SetNo;
}
} // namespace ccm
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Natural (alphanumeric) ordering for collector / set numbers.
// Digit runs compare as integers so "2" < "10" < "100"; non-digit runs use
// ordinary string order (e.g. "SWSH001" < "SWSH002").
#include <string_view>
namespace ccm {
// strcmp-style: <0 if a < b, 0 if equal (after natural + lex tie-break), >0 if a > b.
[[nodiscard]] int compareSetNoNatural(std::string_view a, std::string_view b) noexcept;
} // namespace ccm
+28 -1
View File
@@ -48,12 +48,39 @@ namespace ccm {
return out;
}
// Digits from a full set code (after '-') or from a digits-only Set # field.
[[nodiscard]] inline std::string ygoCollectorDigitsFromInput(std::string_view raw) {
const std::string_view s = trimAsciiSpaces(raw);
if (s.find('-') != std::string_view::npos) return ygoCollectorDigitsOnly(s);
std::string out;
out.reserve(s.size());
for (unsigned char c : s) {
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
}
return out;
}
[[nodiscard]] inline std::string ygoDigitsStripLeadingZeros(std::string digits) {
std::size_t i = 0;
while (i + 1 < digits.size() && digits[i] == '0') ++i;
if (i > 0) digits.erase(0, i);
return digits;
}
// True when both designate the same collector number, ignoring leading zeros
// ("5" == "005") and accepting either a full set code or digits-only input.
[[nodiscard]] inline bool ygoCollectorDigitsEqual(std::string_view a,
std::string_view b) {
return ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(a)) ==
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(b));
}
// True when both strings designate the same printed slot: same abbreviation
// before the first '-' (ASCII case-insensitive) and the same ordered digit run
// extracted from everything after that dash.
[[nodiscard]] inline bool ygoPrintingSlotsMatch(std::string_view a, std::string_view b) {
if (ygoAbbrevBeforeDash(a) != ygoAbbrevBeforeDash(b)) return false;
return ygoCollectorDigitsOnly(a) == ygoCollectorDigitsOnly(b);
return ygoCollectorDigitsEqual(a, b);
}
// YGOPRODeck sometimes lists European alternate numbering alongside NA prints under
@@ -0,0 +1,40 @@
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
namespace ccm {
const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack(
std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+6 -3
View File
@@ -17,6 +17,7 @@ std::string_view to_string(Game g) noexcept {
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
case Game::DigiBattle99: return "DigiBattle99";
case Game::YuGiOhBandai: return "YuGiOhBandai";
case Game::JapanesePokemon: return "JapanesePokemon";
}
CCM_UNREACHABLE();
@@ -72,6 +73,7 @@ std::optional<Game> gameFromString(std::string_view s) noexcept {
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "DigiBattle99") return Game::DigiBattle99;
if (s == "YuGiOhBandai") return Game::YuGiOhBandai;
if (s == "JapanesePokemon") return Game::JapanesePokemon;
return std::nullopt;
}
@@ -115,9 +117,10 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 4>& allGames() noexcept {
static constexpr std::array<Game, 4> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99};
const std::array<Game, 5>& allGames() noexcept {
static constexpr std::array<Game, 5> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::YuGiOhBandai,
Game::DigiBattle99};
return v;
}
+7
View File
@@ -1,5 +1,7 @@
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const PokemonCard& c) {
@@ -37,6 +39,11 @@ void from_json(const nlohmann::json& j, PokemonCard& c) {
j.at("altered").get_to(c.altered);
// Missing `region` defaults to West so pre-merge West-only files still load.
c.region = j.value("region", PokemonRegion::West);
// Migrate legacy pokemontcg.io West set ids to TCGdex EN on load so the
// next collection save persists canonical ids. Asia ids are untouched.
if (c.region == PokemonRegion::West && !c.set.id.empty()) {
c.set.id = canonicalizeWestSetId(c.set.id);
}
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/PokemonSetCatalog.hpp"
namespace ccm {
const PokemonSetCatalogPack* PokemonSetCatalog::findPack(std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const PokemonCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, PokemonCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const PokemonSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, PokemonSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/YuGiOhBandaiCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"setNo", c.setNo},
{"rarity", c.rarity},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c) {
j.at("id").get_to(c.id);
j.at("amount").get_to(c.amount);
j.at("name").get_to(c.name);
j.at("set").get_to(c.set);
j.at("setNo").get_to(c.setNo);
j.at("rarity").get_to(c.rarity);
j.at("note").get_to(c.note);
j.at("images").get_to(c.images);
j.at("language").get_to(c.language);
j.at("condition").get_to(c.condition);
j.at("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
@@ -0,0 +1,45 @@
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
namespace ccm {
const YuGiOhBandaiSetCatalogPack* YuGiOhBandaiSetCatalog::findPack(
std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}, {"rarity", c.rarity}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
if (j.contains("rarity")) {
j.at("rarity").get_to(c.rarity);
} else {
c.rarity.clear();
}
}
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+41
View File
@@ -0,0 +1,41 @@
#include "ccm/domain/YuGiOhSetCatalog.hpp"
namespace ccm {
const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
if (!c.rarity.empty()) j["rarity"] = c.rarity;
}
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
c.rarity = j.value("rarity", "");
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
@@ -35,6 +35,44 @@ bool cardInPack(const nlohmann::json& card, std::string_view packName) {
return false;
}
// Numeric collector suffix: "ST-01" → "01", "01" → "01", "BO-115" → "115".
std::string numericSuffix(std::string_view setNo) {
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
const auto dash = n.find('-');
const std::string_view tail =
dash == std::string::npos ? std::string_view{n} : std::string_view{n}.substr(dash + 1);
std::string out;
out.reserve(tail.size());
for (unsigned char c : tail) {
if (std::isdigit(c) != 0) out.push_back(static_cast<char>(c));
}
return out;
}
std::string stripLeadingZeros(std::string digits) {
std::size_t i = 0;
while (i + 1 < digits.size() && digits[i] == '0') ++i;
if (i > 0) digits.erase(0, i);
return digits;
}
bool hasAlphabeticPrefix(std::string_view setNo) {
const std::string n = DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
return !n.empty() && std::isalpha(static_cast<unsigned char>(n.front())) != 0;
}
// Exact id match, or digits-only input matched to the numeric suffix with
// leading zeros ignored ("1" ↔ "ST-01", but not "ST-11").
bool cardNumbersMatch(std::string_view wanted, std::string_view actual) {
const std::string a = DigiBattle99CardPreviewSource::normalizeCardNumber(wanted);
const std::string b = DigiBattle99CardPreviewSource::normalizeCardNumber(actual);
if (a.empty() || b.empty()) return false;
if (a == b) return true;
// Full id typed (ST-01): require exact normalized equality only.
if (hasAlphabeticPrefix(a)) return false;
return stripLeadingZeros(numericSuffix(a)) == stripLeadingZeros(numericSuffix(b));
}
} // namespace
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
@@ -144,7 +182,8 @@ DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setName,
std::string_view wantedCardName) {
std::string_view wantedCardName,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
@@ -157,6 +196,7 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrint
const std::string wantedPack = trim(std::string(setName));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
const std::string wantedNo = normalizeCardNumber(wantedSetNo);
std::vector<AutoDetectedPrint> collected;
for (const auto& card : j) {
@@ -166,13 +206,20 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrint
}
if (!cardInPack(card, wantedPack)) continue;
AutoDetectedPrint out;
out.name = trim(card.value("name", ""));
out.setNo = normalizeCardNumber(card.value("id", ""));
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
if (out.setNo.empty()) continue;
// digimoncard.io `card=` is fuzzy (card=1 can return ST-01 and ST-11).
// When the user typed a number, keep only exact / zero-padded matches.
if (!wantedNo.empty() && !cardNumbersMatch(wantedNo, out.setNo)) continue;
collected.push_back(std::move(out));
}
if (collected.empty()) {
if (!wantedNo.empty()) {
return R::err("Could not auto-detect Digi-Battle card name from set number.");
}
if (!wantedNameLower.empty() && !wantedPack.empty()) {
return R::err("Could not auto-detect Digi-Battle set print metadata.");
}
@@ -219,4 +266,40 @@ Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrin
return parsePrintVariants(fallback.value(), setName, name);
}
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectBySetNo(
std::string_view setName,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setName, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Digi-Battle card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectVariantsBySetNo(
std::string_view setName,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
if (trim(std::string(setName)).empty()) return R::err("Select a set first.");
const std::string num = normalizeCardNumber(setNo);
if (num.empty()) return R::err("Card number is empty.");
const std::string url = buildSearchUrl("", setName, num);
auto resp = http_.get(url);
if (resp) {
auto parsed = parsePrintVariants(resp.value(), setName, "", num);
if (parsed && !parsed.value().empty()) return parsed;
}
// Retry number-only; still filter by pack + exact/padded number.
const std::string fallbackUrl = buildSearchUrl("", "", num);
auto fallback = http_.get(fallbackUrl);
if (!fallback) {
if (resp) return R::err("Could not auto-detect Digi-Battle card name from set number.");
return R::err(fallback.error());
}
return parsePrintVariants(fallback.value(), setName, "", num);
}
} // namespace ccm
@@ -1,5 +1,7 @@
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
@@ -40,6 +42,24 @@ std::string releaseDateForPack(const std::string& packName) {
return {};
}
Result<nlohmann::json> parseSearchArray(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return Result<nlohmann::json>::err(
j.value("error", std::string{"digimoncard.io set search error"}));
}
if (!j.is_array()) {
return Result<nlohmann::json>::err(
"digimoncard.io Digi-Battle response is not a JSON array.");
}
return Result<nlohmann::json>::ok(j);
} catch (const std::exception& e) {
return Result<nlohmann::json>::err(
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
}
} // namespace
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
@@ -61,59 +81,126 @@ std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
}
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return Result<std::vector<Set>>::err(
j.value("error", std::string{"digimoncard.io set search error"}));
}
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"digimoncard.io Digi-Battle response is not a JSON array.");
}
auto arr = parseSearchArray(body);
if (!arr) return Result<std::vector<Set>>::err(arr.error());
// Preserve first-seen order of pack names, then sort by release date.
std::unordered_set<std::string> seen;
std::vector<std::string> packNames;
packNames.reserve(16);
for (const auto& entry : j) {
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string name = pack.get<std::string>();
if (name.empty()) continue;
if (seen.insert(name).second) packNames.push_back(name);
}
// Preserve first-seen order of pack names, then sort by release date.
std::unordered_set<std::string> seen;
std::vector<std::string> packNames;
packNames.reserve(16);
for (const auto& entry : arr.value()) {
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string name = pack.get<std::string>();
if (name.empty()) continue;
if (seen.insert(name).second) packNames.push_back(name);
}
std::vector<Set> out;
out.reserve(packNames.size());
for (const auto& name : packNames) {
Set s;
s.id = slugifyPackName(name);
s.name = name;
s.releaseDate = releaseDateForPack(name);
if (s.id.empty()) continue;
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
return a.name < b.name;
});
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
std::vector<Set> out;
out.reserve(packNames.size());
for (const auto& name : packNames) {
Set s;
s.id = slugifyPackName(name);
s.name = name;
s.releaseDate = releaseDateForPack(name);
if (s.id.empty()) continue;
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
return a.name < b.name;
});
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<DigiBattle99SetCatalog> DigiBattle99SetSource::parseCatalog(const std::string& body) {
auto arr = parseSearchArray(body);
if (!arr) return Result<DigiBattle99SetCatalog>::err(arr.error());
// pack display name -> (setId, ordered unique cards by first-seen setNo)
struct PackBuild {
std::string setId;
std::string setName;
std::unordered_set<std::string> seenNos;
std::vector<DigiBattle99CatalogCard> cards;
};
std::unordered_map<std::string, PackBuild> byName;
for (const auto& entry : arr.value()) {
if (!entry.contains("name") || !entry.at("name").is_string()) continue;
if (!entry.contains("id") || !entry.at("id").is_string()) continue;
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
DigiBattle99CatalogCard card;
card.name = entry.at("name").get<std::string>();
card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(
entry.at("id").get<std::string>());
if (card.setNo.empty()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string packName = pack.get<std::string>();
if (packName.empty()) continue;
auto& build = byName[packName];
if (build.setName.empty()) {
build.setName = packName;
build.setId = slugifyPackName(packName);
}
if (build.setId.empty()) continue;
if (!build.seenNos.insert(card.setNo).second) continue;
build.cards.push_back(card);
}
}
DigiBattle99SetCatalog catalog;
catalog.packs.reserve(byName.size());
for (auto& [_, build] : byName) {
if (build.setId.empty()) continue;
std::sort(build.cards.begin(), build.cards.end(),
[](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
DigiBattle99SetCatalogPack pack;
pack.setId = std::move(build.setId);
pack.setName = std::move(build.setName);
pack.cards = std::move(build.cards);
catalog.packs.push_back(std::move(pack));
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) {
return a.setName < b.setName;
});
return Result<DigiBattle99SetCatalog>::ok(std::move(catalog));
}
Result<DigiBattle99SetSource::FetchWithCatalog>
DigiBattle99SetSource::fetchAllWithCatalog() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<FetchWithCatalog>::err(resp.error());
auto sets = parseResponse(resp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
auto catalog = parseCatalog(resp.value());
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog).value();
return Result<FetchWithCatalog>::ok(std::move(out));
}
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
auto both = fetchAllWithCatalog();
if (!both) return Result<std::vector<Set>>::err(both.error());
return Result<std::vector<Set>>::ok(std::move(both).value().sets);
}
} // namespace ccm
@@ -1,5 +1,6 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
@@ -13,18 +14,6 @@ namespace ccm {
namespace {
// Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found.
std::string normalizeNumber(std::string_view setNo) {
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
}
return s;
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
@@ -38,67 +27,165 @@ std::string toLower(std::string s) {
return s;
}
std::string stripLeadingZeros(std::string_view s) {
std::size_t i = 0;
while (i + 1 < s.size() && s[i] == '0') ++i;
return std::string(s.substr(i));
}
// Exact localId match after slash-normalization, or leading-zero-insensitive
// equality ("4" ↔ "04", not "4" ↔ "14").
bool localIdsMatch(std::string_view a, std::string_view b) {
const std::string na = PokemonCardPreviewSource::normalizeCollectorNumber(a);
const std::string nb = PokemonCardPreviewSource::normalizeCollectorNumber(b);
if (na.empty() || nb.empty()) return false;
if (na == nb) return true;
return stripLeadingZeros(na) == stripLeadingZeros(nb);
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
}
return s;
}
std::string PokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
if (imageBase.empty()) return {};
std::string url(imageBase);
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
return url + "/high.png";
}
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
std::string_view setNo) {
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
std::string id = idCanon + "-" + num;
return std::string("https://api.tcgdex.net/v2/en/cards/") + rfc3986PercentEncode(id);
}
std::string PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/en/sets/") +
rfc3986PercentEncode(canonicalizeWestSetId(setId));
}
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
// Build the unencoded query first so the output matches what the Pokemon
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
std::string query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
query += std::string(setId);
}
const std::string num = normalizeNumber(setNo);
if (!num.empty()) {
query += " number:";
query += num;
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
rfc3986PercentEncode(query);
}
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
std::string url = "https://api.tcgdex.net/v2/en/cards?";
bool first = true;
auto append = [&](std::string_view key, std::string_view value) {
if (value.empty()) return;
if (!first) url += '&';
first = false;
url += std::string(key);
url += "=eq:";
url += rfc3986PercentEncode(value);
};
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
std::string_view setId) {
std::string url = buildSearchUrl(name, setId, "");
url += "&select=name,number,rarity,set";
url += "&pageSize=50";
if (!idCanon.empty() && !num.empty()) {
append("set.id", idCanon);
append("localId", num);
} else {
append("name", name);
append("set.id", idCanon);
append("localId", num);
}
return url;
}
Result<std::vector<PokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
PokemonCardPreviewSource::parseSetCards(const std::string& body) {
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
return R::err({K::Transient,
"TCGdex EN set detail missing 'cards' array."});
}
std::vector<SetCardRow> out;
out.reserve(j.at("cards").size());
for (const auto& card : j.at("cards")) {
SetCardRow row;
row.localId = card.value("localId", "");
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
}
row.name = card.value("name", "");
row.rarity = card.value("rarity", "");
if (card.contains("image") && card.at("image").is_string()) {
row.imageBase = card.at("image").get<std::string>();
}
if (row.localId.empty()) continue;
out.push_back(std::move(row));
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("TCGdex EN set detail JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(const std::string& body) {
PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
if (!j.is_object()) {
return R::err({K::Transient, "TCGdex EN card response is not a JSON object."});
}
const auto& data = j.at("data");
if (data.empty()) {
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
if (!j.contains("image") || j.at("image").is_null()) {
return R::err({K::NotFound, "TCGdex EN card has no image."});
}
const auto& first = data.at(0);
if (!first.contains("images") || !first.at("images").is_object()) {
return R::err({K::NotFound, "Card has no 'images' object."});
if (!j.at("image").is_string()) {
return R::err({K::Transient, "TCGdex EN card image field is not a string."});
}
const auto& images = first.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return R::ok(images.at("large").get<std::string>());
const std::string base = j.at("image").get<std::string>();
if (base.empty()) {
return R::err({K::NotFound, "TCGdex EN card has no image."});
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
return R::ok(imageUrlFromBase(base));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
std::string("TCGdex EN card JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseSearchResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return R::err({K::Transient, "TCGdex EN cards search response is not an array."});
}
if (j.empty()) {
return R::err({K::NotFound, "TCGdex EN returned no matching cards."});
}
for (const auto& card : j) {
if (!card.contains("image") || !card.at("image").is_string()) continue;
const std::string base = card.at("image").get<std::string>();
if (base.empty()) continue;
return R::ok(imageUrlFromBase(base));
}
return R::err({K::NotFound, "TCGdex EN matching cards have no image."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("TCGdex EN cards search JSON parse error: ") + e.what()});
}
}
@@ -108,68 +195,54 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string url = buildSearchUrl(name, setId, setNo);
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
if (!idCanon.empty() && !num.empty()) {
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
if (byId) {
auto img = parseCardByIdResponse(byId.value());
if (img) return img;
// NotFound / Transient schema: fall through to search.
}
}
const std::string url = buildSearchUrl(name, idCanon, num);
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
return parseSearchResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setId,
std::string_view /*setId*/,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("Pokemon TCG returned no matching cards.");
}
const std::string wantedSetId = trim(std::string(setId));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
auto pushCard = [&collected](const nlohmann::json& card) {
AutoDetectedPrint out;
out.setNo = trim(card.value("number", ""));
out.rarity = trim(card.value("rarity", ""));
if (out.setNo.empty() && out.rarity.empty()) return;
collected.push_back(std::move(out));
};
for (const auto& card : j.at("data")) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!wantedSetId.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
}
if (cardSetId != wantedSetId) continue;
}
pushCard(card);
}
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedSetId.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::err("Pokemon TCG returned no matching cards.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
const std::string key = p.setNo + '\0' + p.rarity;
if (seen.insert(key).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("Pokemon TCG JSON parse error: ") + e.what());
auto rows = parseSetCards(body);
if (!rows) {
return R::err(rows.error().message);
}
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
if (!wantedLower.empty()) {
if (toLower(trim(row.name)) != wantedLower) continue;
}
const std::string localId = normalizeCollectorNumber(row.localId);
if (localId.empty() || !seen.insert(localId + '\0' + row.rarity).second) continue;
AutoDetectedPrint print;
print.setNo = localId;
print.rarity = row.rarity;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::ok(std::move(out));
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
@@ -186,15 +259,157 @@ Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVari
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildDetectSearchUrl(name, setId);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
const std::string idCanon = canonicalizeWestSetId(setId);
if (!idCanon.empty()) {
auto detail = http_.get(buildSetDetailUrl(idCanon));
if (detail) {
auto parsed = parsePrintVariants(detail.value(), idCanon, name);
if (parsed) return parsed;
}
}
// Fallback: filtered cards search by name (+ optional set).
const std::string url = buildSearchUrl(name, idCanon, "");
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
try {
const auto j = nlohmann::json::parse(resp.value());
if (!j.is_array() || j.empty()) {
return R::err("TCGdex EN returned no matching cards.");
}
const std::string wantedLower = toLower(trim(std::string(name)));
std::vector<AutoDetectedPrint> collected;
std::unordered_set<std::string> seen;
for (const auto& card : j) {
if (!wantedLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedLower) continue;
}
if (!idCanon.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
} else if (card.contains("id") && card.at("id").is_string()) {
// Slim search hits are "setId-localId".
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) cardSetId = id.substr(0, dash);
}
if (cardSetId != idCanon) continue;
}
AutoDetectedPrint print;
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
print.rarity = trim(card.value("rarity", ""));
if (print.setNo.empty() && print.rarity.empty()) continue;
const std::string key = print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
collected.push_back(std::move(print));
}
if (collected.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::ok(std::move(collected));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::parsePrintFromCardById(
const std::string& body) {
using R = Result<AutoDetectedPrint>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object()) {
return R::err("TCGdex EN card response is not a JSON object.");
}
AutoDetectedPrint print;
print.name = trim(j.value("name", ""));
print.setNo = normalizeCollectorNumber(j.value("localId", ""));
print.rarity = trim(j.value("rarity", ""));
if (print.name.empty()) {
return R::err("TCGdex EN card has no name.");
}
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
const std::string id = j.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
}
}
return R::ok(std::move(print));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN card JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectBySetNo(std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err("Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
if (idCanon.empty()) return R::err("Select a set first.");
if (num.empty()) return R::err("Card number is empty.");
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
if (byId) {
auto parsed = parsePrintFromCardById(byId.value());
if (parsed && localIdsMatch(parsed.value().setNo, num)) {
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(parsed).value());
return R::ok(std::move(out));
}
}
// Fallback: filtered search by set.id + localId.
const std::string url = buildSearchUrl("", idCanon, num);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
try {
const auto j = nlohmann::json::parse(resp.value());
if (!j.is_array() || j.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& card : j) {
AutoDetectedPrint print;
print.name = trim(card.value("name", ""));
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
print.rarity = trim(card.value("rarity", ""));
if (print.name.empty()) continue;
if (print.setNo.empty() && card.contains("id") && card.at("id").is_string()) {
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeCollectorNumber(id.substr(dash + 1));
}
}
// Defense-in-depth: TCGdex search can be fuzzy; never accept a
// different localId (e.g. "14" when the user asked for "4").
if (!localIdsMatch(print.setNo, num)) continue;
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
}
const std::string fallbackUrl = buildDetectSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
}
} // namespace ccm
@@ -0,0 +1,70 @@
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
namespace ccm {
namespace {
std::unordered_map<std::string, const Set*> indexById(const std::vector<Set>& sets) {
std::unordered_map<std::string, const Set*> out;
out.reserve(sets.size());
for (const auto& s : sets) {
if (s.id.empty()) continue;
out.emplace(s.id, &s);
}
return out;
}
bool applySetMetadata(PokemonCard& card, const Set& upstream) {
bool changed = false;
if (card.set.name != upstream.name) {
card.set.name = upstream.name;
changed = true;
}
if (card.set.releaseDate != upstream.releaseDate) {
card.set.releaseDate = upstream.releaseDate;
changed = true;
}
return changed;
}
} // namespace
std::size_t syncPokemonCollectionSets(std::vector<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& asiaSets) {
const auto westById = indexById(westSets);
const auto asiaById = indexById(asiaSets);
std::size_t touched = 0;
for (auto& card : cards) {
bool changed = false;
if (card.region == PokemonRegion::West) {
if (!card.set.id.empty()) {
const std::string canon = canonicalizeWestSetId(card.set.id);
if (canon != card.set.id) {
card.set.id = canon;
changed = true;
}
}
if (!card.set.id.empty()) {
if (const auto it = westById.find(card.set.id); it != westById.end()) {
if (applySetMetadata(card, *it->second)) changed = true;
}
}
} else if (card.region == PokemonRegion::Asia) {
if (!card.set.id.empty()) {
if (const auto it = asiaById.find(card.set.id); it != asiaById.end()) {
if (applySetMetadata(card, *it->second)) changed = true;
}
}
}
if (changed) ++touched;
}
return touched;
}
} // namespace ccm
+141 -16
View File
@@ -1,45 +1,170 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <unordered_set>
#include <utility>
namespace ccm {
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
std::string out(isoDate);
for (char& ch : out) {
if (ch == '-') ch = '/';
}
return out;
}
std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/en/sets/") +
rfc3986PercentEncode(setId);
}
Result<std::vector<Set>> PokemonSetSource::parseListResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"Pokemon TCG API response missing 'data' array.");
"TCGdex EN sets response is not a JSON array.");
}
std::vector<Set> out;
out.reserve(j.at("data").size());
for (const auto& entry : j.at("data")) {
out.reserve(j.size());
for (const auto& entry : j) {
Set s;
s.id = entry.value("id", "");
s.name = entry.value("name", "");
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
// no separator rewrite needed (cf. Scryfall's "released_at").
s.releaseDate = entry.value("releaseDate", "");
s.id = entry.value("id", "");
if (s.id.empty()) continue;
s.name = entry.value("name", "");
s.releaseDate = {}; // filled from set detail
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
std::string("TCGdex EN sets JSON parse error: ") + e.what());
}
}
Result<std::string> PokemonSetSource::parseReleaseDate(const std::string& detailBody) {
try {
const auto j = nlohmann::json::parse(detailBody);
if (!j.is_object()) {
return Result<std::string>::err(
"TCGdex EN set detail response is not a JSON object.");
}
const std::string raw = j.value("releaseDate", "");
if (raw.empty()) {
return Result<std::string>::ok(std::string{});
}
return Result<std::string>::ok(rewriteReleaseDate(raw));
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("TCGdex EN set detail JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set) {
auto rows = PokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
const std::string localId =
PokemonCardPreviewSource::normalizeCollectorNumber(row.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name = row.name;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return parsed;
std::vector<Set> out = std::move(parsed).value();
for (auto& s : out) {
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) continue; // keep set with empty date rather than fail all
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> sets = std::move(parsed).value();
PokemonSetCatalog catalog;
catalog.packs.reserve(sets.size());
for (auto& s : sets) {
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) continue;
if (s.releaseDate.empty()) {
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
auto pack = parseCatalogPackFromSetDetail(detail.value(), s);
if (pack) {
catalog.packs.push_back(std::move(pack).value());
}
}
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
FetchWithCatalog out;
out.sets = std::move(sets);
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -0,0 +1,92 @@
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
namespace ccm {
namespace {
// Built by name-matching PokemonTCG/pokemon-tcg-data set ids against
// api.tcgdex.net/v2/en/sets. Only divergences are listed; shared ids
// (base1, swsh1, sv10, sve, …) pass through unchanged.
const std::unordered_map<std::string, std::string>& legacyAliases() {
static const std::unordered_map<std::string, std::string> kMap{
// Classic / EX / HGSS renames
{"base6", "lc"},
{"bp", "bog"},
{"tk1a", "tk-ex-latia"},
{"tk1b", "tk-ex-latio"},
{"tk2a", "tk-ex-p"},
{"tk2b", "tk-ex-m"},
{"hsp", "hgssp"},
// McDonald's Collections
{"mcd11", "2011bw"},
{"mcd12", "2012bw"},
{"mcd14", "2014xy"},
{"mcd15", "2015xy"},
{"mcd16", "2016xy"},
{"mcd17", "2017sm"},
{"mcd18", "2018sm"},
{"mcd19", "2019sm"},
{"mcd21", "2021swsh"},
{"mcd22", "2022swsh"},
{"mcd23", "2023sv"},
{"mcd24", "2024sv"},
// SM specials
{"sm35", "sm3.5"},
{"sm75", "sm7.5"},
// SWSH specials / galleries
{"swsh35", "swsh3.5"},
{"swsh45", "swsh4.5"},
{"swsh45sv", "swsh4.5sv"},
{"cel25c", "cel25cc"},
{"swsh9tg", "swsh9.5tg"},
{"swsh10tg", "swsh10.5tg"},
{"pgo", "swsh10.5"},
{"swsh11tg", "swsh11.5tg"},
{"swsh12tg", "swsh12.5tg"},
{"swsh12pt5", "swsh12.5"},
{"swsh12pt5gg", "swsh12.5gg"},
// Scarlet & Violet (pokemontcg used unpadded / pt5 forms)
{"sv1", "sv01"},
{"sv2", "sv02"},
{"sv3", "sv03"},
{"sv3pt5", "sv03.5"},
{"sv4", "sv04"},
{"sv4pt5", "sv04.5"},
{"sv5", "sv05"},
{"sv6", "sv06"},
{"sv6pt5", "sv06.5"},
{"sv7", "sv07"},
{"sv8", "sv08"},
{"sv8pt5", "sv08.5"},
{"sv9", "sv09"},
{"zsv10pt5", "sv10.5b"},
{"rsv10pt5", "sv10.5w"},
// Mega Evolution era
{"me1", "me01"},
{"me2", "me02"},
{"me2pt5", "me02.5"},
{"me3", "me03"},
{"me4", "me04"},
{"me5", "me05"},
};
return kMap;
}
} // namespace
std::string canonicalizeWestSetId(std::string_view setId) {
if (setId.empty()) return {};
const auto& map = legacyAliases();
const auto it = map.find(std::string(setId));
if (it != map.end()) return it->second;
return std::string(setId);
}
} // namespace ccm
@@ -439,4 +439,113 @@ JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
return parsed;
}
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::parsePrintFromCardResponse(
const std::string& body) {
using R = Result<AutoDetectedPrint>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object()) {
return R::err("TCGdex JA card response is not a JSON object.");
}
AutoDetectedPrint print;
print.name = trim(j.value("name", ""));
print.setNo = normalizeLocalId(j.value("localId", ""));
print.rarity = trim(j.value("rarity", ""));
if (print.name.empty()) {
return R::err("TCGdex JA card has no name.");
}
if (print.setNo.empty() && j.contains("id") && j.at("id").is_string()) {
const std::string id = j.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) {
print.setNo = normalizeLocalId(id.substr(dash + 1));
}
}
return R::ok(std::move(print));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex JA card JSON parse error: ") + e.what());
}
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
std::string_view setId,
std::string_view localId,
const JapanesePokemonEnCatalog& catalog) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string id = normalizeLocalId(localId);
if (setId.empty()) return R::err("Select a set first.");
if (id.empty()) return R::err("Card number is empty.");
// Prefer exact key, then leading-zero-insensitive scan ("1" ↔ "001").
auto found = catalog.findPrint(setId, id);
if (!found) {
for (const auto& print : catalog.printsForSet(setId)) {
if (localIdsMatch(print.localId, id)) {
found = print;
break;
}
}
}
if (!found) {
return R::err("Could not auto-detect card name from set number.");
}
AutoDetectedPrint print;
print.name = !found->nameEn.empty() ? found->nameEn : found->nameJa;
print.setNo = found->localId.empty() ? id : found->localId;
if (print.name.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(print));
return R::ok(std::move(out));
}
Result<AutoDetectedPrint> JapanesePokemonCardPreviewSource::detectBySetNo(
std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
if (setId.empty()) return R::err("Select a set first.");
const std::string id = normalizeLocalId(setNo);
if (id.empty()) return R::err("Card number is empty.");
auto cardResp = http_.get(buildCardUrl(setId, id));
if (cardResp) {
auto parsed = parsePrintFromCardResponse(cardResp.value());
if (parsed && localIdsMatch(parsed.value().setNo, id)) {
// Prefer EN catalog name when available (exact or zero-insensitive).
if (auto cat = catalog_.findPrint(setId, id); cat && !cat->nameEn.empty()) {
parsed.value().name = cat->nameEn;
} else {
for (const auto& p : catalog_.printsForSet(setId)) {
if (localIdsMatch(p.localId, id) && !p.nameEn.empty()) {
parsed.value().name = p.nameEn;
break;
}
}
}
std::vector<AutoDetectedPrint> out;
out.push_back(std::move(parsed).value());
return R::ok(std::move(out));
}
}
if (catalog_.hasPrintsForSet(setId)) {
return detectVariantsBySetNoFromCatalog(setId, id, catalog_);
}
return R::err("Could not auto-detect card name from set number.");
}
} // namespace ccm
@@ -144,6 +144,20 @@ bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noe
return it != printKeysBySet_.end() && !it->second.empty();
}
std::vector<JapanesePokemonPrintEnInfo>
JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const {
std::vector<JapanesePokemonPrintEnInfo> out;
const auto keysIt = printKeysBySet_.find(std::string(setId));
if (keysIt == printKeysBySet_.end()) return out;
out.reserve(keysIt->second.size());
for (const auto& key : keysIt->second) {
const auto pit = printsByKey_.find(key);
if (pit == printsByKey_.end()) continue;
out.push_back(pit->second);
}
return out;
}
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
if (productId.empty()) return {};
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
@@ -1,6 +1,8 @@
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <nlohmann/json.hpp>
@@ -8,6 +10,8 @@
#include <array>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace ccm {
@@ -55,6 +59,42 @@ const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
return false;
}
void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
const JapanesePokemonEnCatalog& enCatalog) {
std::unordered_set<std::string> seen;
for (const auto& card : pack.cards) {
seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo));
}
for (const auto& print : enCatalog.printsForSet(pack.setId)) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name = print.nameEn;
if (name.empty()) name = print.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
}
void sortPackCards(PokemonSetCatalogPack& pack) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
}
void applyEnglishSetName(Set& s, const JapanesePokemonEnCatalog& catalog) {
if (auto en = catalog.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
}
} // namespace
JapanesePokemonSetSource::JapanesePokemonSetSource(
@@ -152,6 +192,70 @@ JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) {
}
}
PokemonSetCatalogPack JapanesePokemonSetSource::catalogPackFromEnCatalog(
const Set& set, const JapanesePokemonEnCatalog& enCatalog) {
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
for (const auto& print : enCatalog.printsForSet(set.id)) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
if (localId.empty()) continue;
std::string name = print.nameEn;
if (name.empty()) name = print.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
sortPackCards(pack);
return pack;
}
Result<PokemonSetCatalogPack> JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog) {
auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
// Transient/NotFound from parse — treat empty cards as catalog-only.
if (rows.error().kind == PreviewLookupError::Kind::NotFound) {
auto pack = catalogPackFromEnCatalog(set, enCatalog);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err(
"No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name;
if (auto print = enCatalog.findPrint(set.id, localId)) {
name = print->nameEn;
if (name.empty()) name = print->nameJa;
}
if (name.empty()) name = row.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
gapFillFromEnCatalog(pack, enCatalog);
sortPackCards(pack);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
@@ -163,13 +267,7 @@ Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
for (auto& s : out) {
// Prefer catalog English; never leave Japanese TCGdex names in Set.name
// (the set picker must stay English-only).
if (auto en = catalog_.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
applyEnglishSetName(s, catalog_);
if (!s.releaseDate.empty()) continue;
auto detail = http_.get(buildSetDetailUrl(s.id));
@@ -185,19 +283,67 @@ Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<JapanesePokemonSetSource::FetchWithCatalog>
JapanesePokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> sets = std::move(parsed).value();
PokemonSetCatalog catalog;
catalog.packs.reserve(sets.size());
for (auto& s : sets) {
applyEnglishSetName(s, catalog_);
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) {
// Classic / catalog-only products often have no TCGdex detail.
auto pack = catalogPackFromEnCatalog(s, catalog_);
if (!pack.cards.empty()) {
catalog.packs.push_back(std::move(pack));
}
continue;
}
if (s.releaseDate.empty()) {
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_);
if (pack) {
catalog.packs.push_back(std::move(pack).value());
} else {
auto fallback = catalogPackFromEnCatalog(s, catalog_);
if (!fallback.cards.empty()) {
catalog.packs.push_back(std::move(fallback));
}
}
}
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
FetchWithCatalog out;
out.sets = std::move(sets);
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
for (auto& s : sets) {
if (auto en = catalog_.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty() && s.releaseDate.empty()) {
s.releaseDate = en->releaseDate;
}
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
applyEnglishSetName(s, catalog_);
}
appendMissingClassicProducts(sets);
std::sort(sets.begin(), sets.end(),
@@ -555,4 +555,176 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
return parsePrintVariants(fallback.value(), canonicalSetName, name);
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string packId = std::string(trimAsciiSpaces(setId));
if (packId.empty()) return R::err("Select a set first.");
const std::string rawNo = std::string(trimAsciiSpaces(setNo));
if (rawNo.empty()) return R::err("Card number is empty.");
const std::string wantDigits =
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(rawNo));
if (wantDigits.empty()) return R::err("Card number is empty.");
const YuGiOhSetCatalogPack* pack = catalog.findPack(packId);
if (pack == nullptr) {
// Allow callers to pass the display set name (HTTP fallback path).
for (const auto& candidate : catalog.packs) {
if (candidate.setName == packId) {
pack = &candidate;
break;
}
}
}
if (pack == nullptr) {
return R::err("Set not found in offline catalog. Run Sets → Update Yu-Gi-Oh! first.");
}
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seenNames;
for (const auto& card : pack->cards) {
if (!ygoCollectorDigitsEqual(card.setNo, rawNo)) continue;
if (card.name.empty()) continue;
if (!seenNames.insert(card.name).second) continue;
AutoDetectedPrint print;
print.name = card.name;
print.setNo = card.setNo;
print.rarity = card.rarity;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
}
std::string YuGiOhCardPreviewSource::buildCardsetOnlyUrl(std::string_view setName) {
return std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?cardset=") +
rfc3986PercentEncode(setName);
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
const std::string& body,
std::string_view preferredSetName,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string wantDigits =
ygoDigitsStripLeadingZeros(ygoCollectorDigitsFromInput(setNo));
if (wantDigits.empty()) return R::err("Card number is empty.");
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err("YGOPRODeck response missing 'data' array.");
}
const std::string preferredLower = toLower(trim(std::string(preferredSetName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& card : j.at("data")) {
const std::string cardName = trim(card.value("name", ""));
if (cardName.empty()) continue;
if (!card.contains("card_sets") || !card.at("card_sets").is_array()) continue;
for (const auto& printing : card.at("card_sets")) {
const std::string setName = trim(printing.value("set_name", ""));
const std::string setCode = trim(printing.value("set_code", ""));
if (setCode.empty()) continue;
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
if (!preferredLower.empty() && toLower(setName) != preferredLower) continue;
if (!ygoCollectorDigitsEqual(setCode, setNo)) continue;
AutoDetectedPrint print;
print.name = cardName;
print.setNo = setCode;
print.rarity = trim(printing.value("set_rarity", ""));
const std::string key = print.name + '\0' + print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
out.push_back(std::move(print));
}
}
if (out.empty()) {
return R::err("Could not auto-detect card name from set number.");
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("YGOPRODeck JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> YuGiOhCardPreviewSource::detectBySetNo(std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect card name from set number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string setKey = std::string(trimAsciiSpaces(setId));
if (setKey.empty()) return R::err("Select a set first.");
if (ygoCollectorDigitsFromInput(setNo).empty()) {
return R::err("Card number is empty.");
}
// 1) Offline catalog (preferred — fast once cached).
if (catalogStore_ != nullptr) {
if (!catalogCache_) {
auto loaded = catalogStore_->load();
if (loaded) catalogCache_ = std::move(loaded).value();
}
if (catalogCache_ && !catalogCache_->empty()) {
auto fromCatalog =
detectVariantsBySetNoFromCatalog(*catalogCache_, setKey, setNo);
// Prefer YGOPRODeck when reachable so rarity (and multi-rarity
// variants) come through — the offline catalog may predate the
// rarity field or only keep one rarity per printing slot.
const YuGiOhSetCatalogPack* pack = catalogCache_->findPack(setKey);
std::string setName = setKey;
if (pack != nullptr) {
setName = pack->setName;
} else {
for (const auto& candidate : catalogCache_->packs) {
if (candidate.setName == setKey) {
setName = candidate.setName;
break;
}
}
}
if (!setName.empty()) {
auto resp = http_.get(buildCardsetOnlyUrl(setName));
if (resp) {
auto fromHttp =
detectVariantsBySetNoFromCardset(resp.value(), setName, setNo);
if (fromHttp) return fromHttp;
}
}
if (fromCatalog) return fromCatalog;
// Prefer catalog miss text when HTTP also missed / was unreachable.
return fromCatalog;
}
}
// 2) No catalog: treat setKey as display set name and query YGOPRODeck.
auto resp = http_.get(buildCardsetOnlyUrl(setKey));
if (!resp) {
return R::err(
"Set catalog missing and YGOPRODeck lookup failed. "
"Run Sets → Update Yu-Gi-Oh! or check your network.");
}
return detectVariantsBySetNoFromCardset(resp.value(), setKey, setNo);
}
} // namespace ccm
+160
View File
@@ -1,10 +1,15 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cctype>
#include <string>
#include <unordered_map>
#include <utility>
namespace ccm {
namespace {
@@ -39,6 +44,47 @@ void appendMissingSetAliases(std::vector<Set>& sets) {
}
}
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
const std::string digits = ygoCollectorDigitsOnly(setNo);
if (abbrev.empty() || digits.empty()) return {};
return abbrev + "|" + digits;
}
[[nodiscard]] bool ygoHasEnRegionInfix(std::string_view setCode) {
const std::string_view s = trimAsciiSpaces(setCode);
const auto dash = s.find('-');
if (dash == std::string_view::npos || dash + 3 > s.size()) return false;
const std::string_view tail = s.substr(dash + 1);
if (tail.size() < 3) return false;
return (tail[0] == 'E' || tail[0] == 'e') && (tail[1] == 'N' || tail[1] == 'n')
&& std::isdigit(static_cast<unsigned char>(tail[2])) != 0;
}
[[nodiscard]] std::string uppercaseAscii(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
return s;
}
[[nodiscard]] std::string resolvePackId(const std::unordered_map<std::string, std::string>& nameToId,
const std::string& setName,
const std::string& setCode) {
const auto it = nameToId.find(setName);
if (it != nameToId.end() && !it->second.empty()) return it->second;
const std::string abbrev = uppercaseAscii(ygoAbbrevBeforeDash(setCode));
return abbrev;
}
struct PackBuild {
std::string setId;
std::string setName;
// slotKey → index into cards (for EN preference upgrades).
std::unordered_map<std::string, std::size_t> slotIndex;
std::vector<YuGiOhCatalogCard> cards;
};
} // namespace
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
@@ -73,10 +119,124 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
}
}
Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object() || !j.contains("data") || !j.at("data").is_array()) {
return Result<YuGiOhSetCatalog>::err(
"YGOPRODeck cardinfo response missing data array.");
}
std::unordered_map<std::string, std::string> nameToId;
nameToId.reserve(sets.size());
for (const auto& set : sets) {
if (set.name.empty() || set.id.empty()) continue;
// First wins — aliases and upstream rows rarely collide by name.
nameToId.emplace(set.name, set.id);
}
// Keyed by pack setId.
std::unordered_map<std::string, PackBuild> byId;
for (const auto& cardJson : j.at("data")) {
const std::string cardName = cardJson.value("name", "");
if (cardName.empty()) continue;
if (!cardJson.contains("card_sets") || !cardJson.at("card_sets").is_array()) {
continue;
}
for (const auto& printing : cardJson.at("card_sets")) {
const std::string setName = printing.value("set_name", "");
const std::string setCode = printing.value("set_code", "");
if (setName.empty() || setCode.empty()) continue;
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
const std::string slot = ygoSlotKey(setCode);
if (slot.empty()) continue;
const std::string packId = resolvePackId(nameToId, setName, setCode);
if (packId.empty()) continue;
auto& build = byId[packId];
if (build.setId.empty()) {
build.setId = packId;
build.setName = setName;
}
const auto existing = build.slotIndex.find(slot);
if (existing == build.slotIndex.end()) {
build.slotIndex.emplace(slot, build.cards.size());
const std::string setRarity(
trimAsciiSpaces(printing.value("set_rarity", "")));
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName, setRarity});
continue;
}
// Prefer an EN-embedded code over a bare / other-region equivalent.
auto& prev = build.cards[existing->second];
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
prev.setNo = setCode;
if (!cardName.empty()) prev.name = cardName;
const std::string setRarity(
trimAsciiSpaces(printing.value("set_rarity", "")));
if (!setRarity.empty()) prev.rarity = setRarity;
} else if (prev.rarity.empty()) {
prev.rarity = std::string(
trimAsciiSpaces(printing.value("set_rarity", "")));
}
}
}
YuGiOhSetCatalog catalog;
catalog.packs.reserve(byId.size());
for (auto& [_, build] : byId) {
if (build.setId.empty() || build.cards.empty()) continue;
std::sort(build.cards.begin(), build.cards.end(),
[](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
YuGiOhSetCatalogPack pack;
pack.setId = std::move(build.setId);
pack.setName = std::move(build.setName);
pack.cards = std::move(build.cards);
catalog.packs.push_back(std::move(pack));
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) {
return a.setName < b.setName;
});
return Result<YuGiOhSetCatalog>::ok(std::move(catalog));
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("YGOPRODeck catalog parse error: ") + e.what());
}
}
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
Result<YuGiOhSetSource::FetchWithCatalog> YuGiOhSetSource::fetchAllWithCatalog() {
auto setsResp = http_.get(kEndpoint);
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
auto sets = parseResponse(setsResp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
auto infoResp = http_.get(kCardInfoEndpoint);
if (!infoResp) return Result<FetchWithCatalog>::err(infoResp.error());
auto catalog = parseCatalog(infoResp.value(), sets.value());
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog).value();
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -0,0 +1,453 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <sstream>
namespace ccm {
namespace {
using K = PreviewLookupError::Kind;
std::string trimCopy(std::string_view s) {
while (!s.empty() &&
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
s.front() == '\r')) {
s.remove_prefix(1);
}
while (!s.empty() &&
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
s.back() == '\r')) {
s.remove_suffix(1);
}
return std::string(s);
}
std::string urlEncode(std::string_view s) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() * 3);
for (unsigned char c : s) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else if (c == ' ') {
out.push_back('+');
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
std::string wikiTitleEncode(std::string_view title) {
// MediaWiki titles use underscores for spaces in the titles= parameter.
std::string s;
s.reserve(title.size());
for (char c : title) {
s.push_back(c == ' ' ? '_' : c);
}
return urlEncode(s);
}
bool endsWith(std::string_view s, std::string_view suffix) {
return s.size() >= suffix.size() &&
s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
}
int askMatchRank(std::string_view pageTitle, std::string_view preferredSetId) {
// Lower is better.
if (preferredSetId == "bansealdass") {
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 0;
if (endsWith(pageTitle, " (Bandai)")) return 1;
return 5;
}
if (preferredSetId == "ban3") {
if (endsWith(pageTitle, " (Bandai)")) return 0;
if (endsWith(pageTitle, " (English Bandai)")) return 1;
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 4;
return 5;
}
if (endsWith(pageTitle, " (Bandai)")) return 0;
if (endsWith(pageTitle, " (English Bandai)")) return 1;
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 3;
return 5;
}
} // namespace
YuGiOhBandaiCardPreviewSource::YuGiOhBandaiCardPreviewSource(IHttpClient& http)
: http_(http) {}
std::string YuGiOhBandaiCardPreviewSource::preferredPageTitle(
std::string_view name,
std::string_view setId,
std::string_view setNo) {
const std::string n = trimCopy(name);
if (n.empty()) return {};
const std::string num = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
if (setId == "bansealdass") {
return n + " (Bandai Sealdass)";
}
// Promo pages on Yugipedia often omit the "(Bandai)" disambiguator
// (e.g. Blue-Eyes White Dragon's 3-Body Connection for TA2).
if (setId == "banpromo-j" || setId == "banpromo-ta" ||
isAlphanumericPromoNumber(num)) {
return n;
}
if (num == "118" || setId == "ban3") {
// Prefer JP Bandai page for most ban3 cards; English #118 uses the
// English Bandai title when setNo is 118.
if (num == "118") return n + " (English Bandai)";
}
return n + " (Bandai)";
}
std::string YuGiOhBandaiCardPreviewSource::buildPageImagesUrl(
std::string_view pageTitle) {
return std::string(
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=pageimages&piprop=original&titles=") +
wikiTitleEncode(pageTitle);
}
std::string YuGiOhBandaiCardPreviewSource::buildAskByNameUrl(
std::string_view englishName) {
// [[Category:Bandai cards]][[English name::<name>]]|?English name|?Bandai number|?Rarity|limit=20
std::ostringstream q;
q << "[[Category:Bandai cards]][[English name::" << englishName
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
urlEncode(q.str());
}
std::string YuGiOhBandaiCardPreviewSource::buildAskByNumberUrl(
std::string_view setNo) {
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
std::ostringstream q;
q << "[[Category:Bandai cards]][[Bandai number::" << n
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
urlEncode(q.str());
}
bool YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber(
std::string_view setNo) {
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
for (unsigned char c : n) {
if (std::isalpha(c)) return true;
}
return false;
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::parsePromoGalleryResponse(
const std::string& body,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string want = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
if (want.empty()) return R::err("Card number is empty.");
std::string wikitext;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
return R::err("Yugipedia promo gallery response missing parse.wikitext");
}
wikitext = j.at("parse").at("wikitext").get<std::string>();
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia promo gallery JSON parse error: ") +
e.what());
}
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wikitext);
if (!cards) return R::err(cards.error());
std::vector<AutoDetectedPrint> out;
for (const auto& card : cards.value()) {
if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo) != want) continue;
AutoDetectedPrint print;
print.name = card.name;
print.setNo = card.setNo;
print.rarity = card.rarity;
print.setId = YuGiOhBandaiSetSource::setIdForNumber(card.setNo);
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
print.language = "Japanese";
out.push_back(std::move(print));
}
return R::ok(std::move(out));
}
AutoDetectedPrint YuGiOhBandaiCardPreviewSource::enrichPrint(
AutoDetectedPrint print,
std::string_view pageTitle) {
print.name = YuGiOhBandaiSetSource::englishNameFromGalleryTitle(pageTitle);
if (endsWith(pageTitle, " (Bandai Sealdass)")) {
print.setId = "bansealdass";
print.language = "Japanese";
} else if (endsWith(pageTitle, " (English Bandai)")) {
print.setId = "ban3";
print.language = "English";
} else {
if (print.setId.empty() && !print.setNo.empty()) {
print.setId = YuGiOhBandaiSetSource::setIdForNumber(print.setNo);
}
print.language = "Japanese";
}
if (!print.setId.empty()) {
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
}
return print;
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").contains("pages")) {
return R::err({K::Transient, "Yugipedia pageimages: missing query.pages"});
}
const auto& pages = j.at("query").at("pages");
for (auto it = pages.begin(); it != pages.end(); ++it) {
const auto& page = it.value();
if (page.contains("missing") || page.contains("invalid")) continue;
if (page.contains("original") && page.at("original").contains("source")) {
const auto url = page.at("original").at("source").get<std::string>();
if (!url.empty()) return R::ok(url);
}
if (page.contains("thumbnail") && page.at("thumbnail").contains("original")) {
const auto url = page.at("thumbnail").at("original").get<std::string>();
if (!url.empty()) return R::ok(url);
}
}
return R::err({K::NotFound, "Yugipedia pageimages: no image for page"});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Yugipedia pageimages JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body,
std::string_view preferredSetId,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").contains("results")) {
return R::err("Yugipedia ask: missing query.results");
}
const auto& results = j.at("query").at("results");
if (!results.is_object() || results.empty()) {
return R::ok({});
}
const std::string wantNo = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
std::vector<std::pair<int, AutoDetectedPrint>> ranked;
for (auto it = results.begin(); it != results.end(); ++it) {
const std::string pageTitle = it.key();
const auto& printouts = it.value().value("printouts", nlohmann::json::object());
AutoDetectedPrint print;
if (printouts.contains("Bandai number") &&
printouts.at("Bandai number").is_array() &&
!printouts.at("Bandai number").empty()) {
const auto& num = printouts.at("Bandai number").at(0);
if (num.is_number_integer()) {
print.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(
std::to_string(num.get<int>()));
} else if (num.is_string()) {
print.setNo =
YuGiOhBandaiSetSource::normalizeCardNumber(num.get<std::string>());
}
}
// Defense-in-depth: SMW ask should be exact, but never accept a
// different Bandai number (e.g. #11 when the user asked for #1).
if (!wantNo.empty() &&
YuGiOhBandaiSetSource::normalizeCardNumber(print.setNo) != wantNo) {
continue;
}
if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() &&
!printouts.at("Rarity").empty()) {
const auto& rar = printouts.at("Rarity").at(0);
if (rar.is_object() && rar.contains("fulltext")) {
print.rarity = rar.at("fulltext").get<std::string>();
} else if (rar.is_string()) {
print.rarity = rar.get<std::string>();
}
}
if (printouts.contains("English name") &&
printouts.at("English name").is_array() &&
!printouts.at("English name").empty()) {
print.name = printouts.at("English name").at(0).get<std::string>();
}
print = enrichPrint(std::move(print), pageTitle);
if (print.name.empty()) continue;
ranked.emplace_back(askMatchRank(pageTitle, preferredSetId), std::move(print));
}
std::sort(ranked.begin(), ranked.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
std::vector<AutoDetectedPrint> out;
out.reserve(ranked.size());
for (auto& [rank, print] : ranked) {
(void)rank;
out.push_back(std::move(print));
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia ask JSON parse error: ") + e.what());
}
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::fetchPageImage(std::string_view pageTitle) {
using R = Result<std::string, PreviewLookupError>;
if (pageTitle.empty()) {
return R::err({K::NotFound, "Empty Bandai page title"});
}
const std::string url = buildPageImagesUrl(pageTitle);
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parsePageImagesResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByName(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string n = trimCopy(name);
if (n.empty()) return R::err("Card name is empty.");
const std::string url = buildAskByNameUrl(n);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parseAskResponse(resp.value(), setId);
}
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByNumber(
std::string_view setId,
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
if (n.empty()) return R::err("Card number is empty.");
// Promo codes (J1, TA2, …) are not valid values for SMW's numeric
// `Bandai number` property — ask returns a type error. Resolve them from
// the promotional set gallery instead.
R list = [&]() -> R {
if (isAlphanumericPromoNumber(n)) {
static constexpr const char* kPromoGallery =
"Set Card Galleries:Promotional Cards (Bandai)";
const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parsePromoGalleryResponse(resp.value(), n);
}
const std::string url = buildAskByNumberUrl(n);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parseAskResponse(resp.value(), setId, n);
}();
if (!list) return list;
const std::string wantSet = trimCopy(setId);
if (wantSet.empty()) return list;
std::vector<AutoDetectedPrint> filtered;
filtered.reserve(list.value().size());
for (auto& print : list.value()) {
if (print.setId == wantSet) filtered.push_back(std::move(print));
}
if (filtered.empty()) {
return R::err("No Bandai card matched that number in the selected set.");
}
return R::ok(std::move(filtered));
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectBySetNo(
std::string_view setId,
std::string_view setNo) {
auto list = detectVariantsBySetNo(setId, setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Bandai card from number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setId,
std::string_view setNo) {
return askByNumber(setId, setNo);
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
const std::string title = preferredPageTitle(name, setId, setNo);
auto direct = fetchPageImage(title);
if (direct) return direct;
// Try English Bandai if JP page missed for #118.
if (YuGiOhBandaiSetSource::normalizeCardNumber(setNo) == "118") {
auto en = fetchPageImage(trimCopy(name) + " (English Bandai)");
if (en) return en;
}
// Fall back to SMW ask by name, then pageimages on the best hit.
auto variants = askByName(name, setId);
if (!variants) {
// Prefer the original NotFound if ask also failed transiently only
// after a clean miss; otherwise surface ask error as Transient.
if (direct.error().kind == K::NotFound) {
return R::err({K::Transient, variants.error()});
}
return direct;
}
if (variants.value().empty()) {
return R::err({K::NotFound, "No Bandai card matched the name"});
}
const auto& best = variants.value().front();
std::string askTitle = preferredPageTitle(best.name, best.setId, best.setNo);
if (best.language == "English") {
askTitle = best.name + " (English Bandai)";
} else if (best.setId == "bansealdass") {
askTitle = best.name + " (Bandai Sealdass)";
}
return fetchPageImage(askTitle);
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectFirstPrint(
std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err("Could not auto-detect Bandai print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name,
std::string_view setId) {
return askByName(name, setId);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
namespace ccm {
YuGiOhBandaiGameModule::YuGiOhBandaiGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
@@ -0,0 +1,267 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <regex>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
std::string trimCopy(std::string_view s) {
while (!s.empty() &&
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
s.front() == '\r')) {
s.remove_prefix(1);
}
while (!s.empty() &&
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
s.back() == '\r')) {
s.remove_suffix(1);
}
return std::string(s);
}
std::string urlEncode(std::string_view s) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() * 3);
for (unsigned char c : s) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else if (c == ' ') {
out.push_back('+');
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
} // namespace
YuGiOhBandaiSetSource::YuGiOhBandaiSetSource(IHttpClient& http) : http_(http) {}
const std::vector<YuGiOhBandaiSetSource::SetManifestEntry>&
YuGiOhBandaiSetSource::setManifest() {
static const std::vector<SetManifestEntry> kManifest{
{"ban1", "1st Generation", "1998/09/01",
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation", ""},
{"ban2", "2nd Generation", "1998/11/01",
"Set Card Galleries:2nd Generation (Bandai)", ""},
{"ban3", "3rd Generation", "1999/03/06",
"Set Card Galleries:3rd Generation (Bandai)", ""},
{"banpromo-j", "Jump Promos", "1998/01/01",
"Set Card Galleries:Promotional Cards (Bandai)", "J"},
{"banpromo-ta", "Toei Promos", "1999/03/06",
"Set Card Galleries:Promotional Cards (Bandai)", "TA"},
{"bansealdass", "Sealdass", "1999/06/01",
"Set Card Galleries:Yu-Gi-Oh! Bandai Sealdass", ""},
};
return kManifest;
}
Result<std::vector<Set>> YuGiOhBandaiSetSource::parseResponse(
const std::string& /*unused*/) {
std::vector<Set> out;
for (const auto& e : setManifest()) {
out.push_back(Set{e.id, e.name, e.releaseDate});
}
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<std::vector<Set>> YuGiOhBandaiSetSource::fetchAll() {
return parseResponse({});
}
std::string YuGiOhBandaiSetSource::buildGalleryParseUrl(std::string_view pageTitle) {
return std::string(
"https://yugipedia.com/api.php?action=parse&format=json&formatversion=2"
"&prop=wikitext&page=") +
urlEncode(pageTitle);
}
std::string YuGiOhBandaiSetSource::normalizeCardNumber(std::string_view setNo) {
std::string s = trimCopy(setNo);
if (s.empty()) return {};
// Strip a leading '#' if present.
if (s.front() == '#') s.erase(s.begin());
// Uppercase letter prefix forms: j1 / ta2.
bool hasAlpha = false;
for (char& c : s) {
if (std::isalpha(static_cast<unsigned char>(c))) {
hasAlpha = true;
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
}
if (hasAlpha) return s;
// Pure decimal: strip leading zeros but keep a single zero.
std::size_t i = 0;
while (i + 1 < s.size() && s[i] == '0') ++i;
return s.substr(i);
}
std::string YuGiOhBandaiSetSource::expandRarityCode(std::string_view code) {
const std::string c = trimCopy(code);
if (c == "C") return "Common";
if (c == "R") return "Rare";
if (c == "SR") return "Super Rare";
if (c == "UR") return "Ultra Rare";
if (c == "HFR" || c == "Holo Seal" || c == "HS") return "Holo Seal";
if (c.empty()) return {};
return c;
}
std::string YuGiOhBandaiSetSource::englishNameFromGalleryTitle(
std::string_view pageTitle) {
std::string name = trimCopy(pageTitle);
const auto stripSuffix = [&](std::string_view suffix) {
if (name.size() > suffix.size() &&
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
name.resize(name.size() - suffix.size());
name = trimCopy(name);
}
};
stripSuffix(" (Bandai Sealdass)");
stripSuffix(" (English Bandai)");
stripSuffix(" (Bandai)");
return name;
}
std::string YuGiOhBandaiSetSource::setIdForNumber(std::string_view setNo) {
const std::string n = normalizeCardNumber(setNo);
if (n.empty()) return {};
if (!n.empty() && (n[0] == 'J' || n[0] == 'j')) return "banpromo-j";
if (n.size() >= 2 && (n[0] == 'T' || n[0] == 't') &&
(n[1] == 'A' || n[1] == 'a')) {
return "banpromo-ta";
}
// Pure decimal → generation by range. Callers that need Sealdass must
// pass set context; number alone cannot disambiguate 142 vs Sealdass.
bool pureDecimal = true;
for (char c : n) {
if (!std::isdigit(static_cast<unsigned char>(c))) {
pureDecimal = false;
break;
}
}
if (!pureDecimal) return {};
const int v = std::stoi(n);
if (v >= 1 && v <= 42) return "ban1";
if (v >= 43 && v <= 88) return "ban2";
if (v >= 89 && v <= 118) return "ban3";
return {};
}
std::string YuGiOhBandaiSetSource::setNameForId(std::string_view setId) {
for (const auto& e : setManifest()) {
if (e.id == setId) return e.name;
}
return {};
}
Result<std::vector<YuGiOhBandaiCatalogCard>>
YuGiOhBandaiSetSource::parseGalleryWikitext(const std::string& wikitext) {
using R = Result<std::vector<YuGiOhBandaiCatalogCard>>;
std::vector<YuGiOhBandaiCatalogCard> out;
// Generation galleries (raw):
// … | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|ja}}
// Promo galleries (often expanded with <br />):
// … | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}
static const std::regex kLine(
R"((?:\{\{pound\}\}|\[\[)([A-Za-z0-9]+)(?:\]\])?(?:\s*\(\[\[([A-Za-z0-9]+)\]\]\))?[^\n]*?\{\{Gallery card names\|([^}|]+))",
std::regex::ECMAScript);
std::unordered_set<std::string> seen;
for (std::sregex_iterator it(wikitext.begin(), wikitext.end(), kLine), end;
it != end; ++it) {
const std::smatch& m = *it;
YuGiOhBandaiCatalogCard card;
card.setNo = normalizeCardNumber(m[1].str());
if (card.setNo.empty()) continue;
if (m[2].matched) {
card.rarity = expandRarityCode(m[2].str());
}
card.name = englishNameFromGalleryTitle(m[3].str());
if (card.name.empty()) continue;
if (!seen.insert(card.setNo).second) continue;
out.push_back(std::move(card));
}
return R::ok(std::move(out));
}
Result<YuGiOhBandaiSetSource::FetchWithCatalog>
YuGiOhBandaiSetSource::fetchAllWithCatalog() {
using R = Result<FetchWithCatalog>;
auto sets = parseResponse({});
if (!sets) return R::err(sets.error());
YuGiOhBandaiSetCatalog catalog;
std::unordered_map<std::string, std::string> pageCache;
for (const auto& entry : setManifest()) {
const std::string page = entry.galleryPage;
std::string body;
auto cached = pageCache.find(page);
if (cached != pageCache.end()) {
body = cached->second;
} else {
const std::string url = buildGalleryParseUrl(page);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
body = std::move(resp).value();
pageCache.emplace(page, body);
}
std::string wikitext;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
return R::err("Yugipedia gallery response missing parse.wikitext");
}
wikitext = j.at("parse").at("wikitext").get<std::string>();
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia gallery JSON parse error: ") +
e.what());
}
auto cards = parseGalleryWikitext(wikitext);
if (!cards) return R::err(cards.error());
YuGiOhBandaiSetCatalogPack pack;
pack.setId = entry.id;
pack.setName = entry.name;
const std::string prefix = entry.setNoPrefix;
for (const auto& card : cards.value()) {
if (!prefix.empty()) {
if (card.setNo.size() < prefix.size() ||
card.setNo.compare(0, prefix.size(), prefix) != 0) {
continue;
}
}
pack.cards.push_back(card);
}
catalog.packs.push_back(std::move(pack));
}
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog);
return R::ok(std::move(out));
}
} // namespace ccm
+16
View File
@@ -83,6 +83,22 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi
return false;
}
bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card, std::string_view filter) {
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(card.rarity, needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
return false;
}
bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter) {
if (filter.empty()) return true;
+29
View File
@@ -262,6 +262,35 @@ Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
return it->second->detectPrintVariants(name, setId);
}
Result<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
}
return it->second->detectBySetNo(setId, setNo);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo(
Game game,
std::string_view setId,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<std::vector<AutoDetectedPrint>>::err(
"No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Auto-detect not enabled for this game.");
}
return it->second->detectVariantsBySetNo(setId, setNo);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
// The by-URL path is used for fixed per-game card-back fallback images.
// A failure there is always transient (the URL itself is constant), so
+73
View File
@@ -293,6 +293,79 @@ void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
}
}
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
YuGiOhBandaiSortColumn column,
bool ascending) {
switch (column) {
case YuGiOhBandaiSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case YuGiOhBandaiSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
}, ascending));
break;
case YuGiOhBandaiSortColumn::SetNo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.setNo) < asciiLower(b.setNo);
}, ascending));
break;
case YuGiOhBandaiSortColumn::Rarity:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.rarity) < asciiLower(b.rarity);
}, ascending));
break;
case YuGiOhBandaiSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
}, ascending));
break;
case YuGiOhBandaiSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
}, ascending));
break;
case YuGiOhBandaiSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.holo < b.holo;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending) {
@@ -0,0 +1,50 @@
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
DigiBattle99SetCatalogService::DigiBattle99SetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path DigiBattle99SetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::DigiBattle99) /
"set-catalog.json";
}
bool DigiBattle99SetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<DigiBattle99SetCatalog> DigiBattle99SetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<DigiBattle99SetCatalog>::err(
"Digimon Digi-Battle set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<DigiBattle99SetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<DigiBattle99SetCatalog>::ok(j.get<DigiBattle99SetCatalog>());
} catch (const std::exception& e) {
return Result<DigiBattle99SetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> DigiBattle99SetCatalogService::save(const DigiBattle99SetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
@@ -0,0 +1,129 @@
#include "ccm/services/DigiBattle99SetCompletion.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
bool passesLanguageFilter(const DigiBattle99Card& card,
std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSetNosBySetId(const std::vector<DigiBattle99Card>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
} // namespace
std::vector<Language>
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<DigiBattle99SetCompletionProgress>
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
std::vector<DigiBattle99SetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedNos] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
DigiBattle99SetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const DigiBattle99SetCompletionProgress& a,
const DigiBattle99SetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<DigiBattle99ChecklistEntry>
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<DigiBattle99ChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
DigiBattle99ChecklistEntry entry;
entry.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
entry.name = card.name;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const DigiBattle99ChecklistEntry& a,
const DigiBattle99ChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
@@ -0,0 +1,54 @@
#include "ccm/services/PokemonSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
PokemonSetCatalogService::PokemonSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path PokemonSetCatalogService::catalogPath(PokemonRegion region) const {
const char* file = region == PokemonRegion::Asia ? "set-catalog-asia.json"
: "set-catalog-west.json";
return fs::path(config_.current().dataStorage) / dirName_(Game::Pokemon) / file;
}
bool PokemonSetCatalogService::exists(PokemonRegion region) const {
return fs_.exists(catalogPath(region));
}
Result<PokemonSetCatalog> PokemonSetCatalogService::load(PokemonRegion region) const {
const auto p = catalogPath(region);
if (!fs_.exists(p)) {
return Result<PokemonSetCatalog>::err(
region == PokemonRegion::Asia
? "Asia Pokemon set catalog not yet downloaded."
: "West Pokemon set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<PokemonSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<PokemonSetCatalog>::ok(j.get<PokemonSetCatalog>());
} catch (const std::exception& e) {
return Result<PokemonSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> PokemonSetCatalogService::save(PokemonRegion region,
const PokemonSetCatalog& catalog) {
const auto p = catalogPath(region);
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+217
View File
@@ -0,0 +1,217 @@
#include "ccm/services/PokemonSetCompletion.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
struct OwnedSetInfo {
std::unordered_set<std::string> nos;
std::string releaseDate;
};
using OwnedBySet = std::unordered_map<std::string, OwnedSetInfo>;
bool passesLanguageFilter(const PokemonCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
bool passesRegionFilter(const PokemonCard& card, std::optional<PokemonRegion> regionFilter) {
return !regionFilter.has_value() || card.region == *regionFilter;
}
std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
if (region == PokemonRegion::Asia) {
return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo);
}
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
}
std::string westSetKey(std::string_view setId) {
return canonicalizeWestSetId(setId);
}
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
PokemonRegion region,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (setNo.empty()) continue;
const std::string setKey =
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
auto& info = out[setKey];
info.nos.insert(setNo);
if (info.releaseDate.empty() && !card.set.releaseDate.empty()) {
info.releaseDate = card.set.releaseDate;
}
}
return out;
}
std::vector<PokemonSetCompletionProgress>
computeForCatalog(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& catalog,
PokemonRegion region,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, region, languageFilter);
std::vector<PokemonSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, info] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo = normalizeForRegion(region, card.setNo);
if (!catalogNo.empty() && info.nos.count(catalogNo) != 0) ++matched;
}
PokemonSetCompletionProgress row;
row.region = region;
row.setId = pack->setId;
row.setName = pack->setName;
row.releaseDate = info.releaseDate;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
return out;
}
} // namespace
std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
if (!passesRegionFilter(card, regionFilter)) continue;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog) {
std::vector<PokemonRegion> out;
const auto westRows =
computeForCatalog(collection, westCatalog, PokemonRegion::West, std::nullopt);
if (!westRows.empty()) out.push_back(PokemonRegion::West);
const auto asiaRows =
computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, std::nullopt);
if (!asiaRows.empty()) out.push_back(PokemonRegion::Asia);
return out;
}
std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter,
std::optional<Language> languageFilter) {
std::vector<PokemonSetCompletionProgress> out;
const bool includeWest =
!regionFilter.has_value() || *regionFilter == PokemonRegion::West;
const bool includeAsia =
!regionFilter.has_value() || *regionFilter == PokemonRegion::Asia;
if (includeWest) {
auto west = computeForCatalog(collection, westCatalog, PokemonRegion::West,
languageFilter);
out.insert(out.end(), std::make_move_iterator(west.begin()),
std::make_move_iterator(west.end()));
}
if (includeAsia) {
auto asia = computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia,
languageFilter);
out.insert(out.end(), std::make_move_iterator(asia.begin()),
std::make_move_iterator(asia.end()));
}
std::sort(out.begin(), out.end(),
[](const PokemonSetCompletionProgress& a,
const PokemonSetCompletionProgress& b) {
// YYYY/MM/DD lex order is chronological (CardSorter parity).
if (a.releaseDate != b.releaseDate) {
return a.releaseDate < b.releaseDate;
}
if (a.setName != b.setName) return a.setName < b.setName;
return static_cast<int>(a.region) < static_cast<int>(b.region);
});
return out;
}
std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter) {
const PokemonSetCatalog& catalog =
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
const std::string wantSetId =
region == PokemonRegion::West ? westSetKey(setId) : std::string(setId);
const auto* pack = catalog.findPack(wantSetId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
const std::string cardSetId =
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
if (cardSetId != wantSetId) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<PokemonChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
PokemonChecklistEntry entry;
entry.setNo = normalizeForRegion(region, card.setNo);
entry.name = card.name;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+4
View File
@@ -20,6 +20,10 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
return fetched;
}
Result<void> SetService::saveSets(Game game, const std::vector<Set>& sets) {
return repo_.save(game, sets);
}
Result<std::vector<Set>> SetService::getSets(Game game) {
auto loaded = repo_.load(game);
if (!loaded) return loaded;
@@ -0,0 +1,50 @@
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
YuGiOhBandaiSetCatalogService::YuGiOhBandaiSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path YuGiOhBandaiSetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOhBandai) /
"set-catalog.json";
}
bool YuGiOhBandaiSetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<YuGiOhBandaiSetCatalog> YuGiOhBandaiSetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<YuGiOhBandaiSetCatalog>::err(
"Yu-Gi-Oh! (Bandai) set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<YuGiOhBandaiSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<YuGiOhBandaiSetCatalog>::ok(j.get<YuGiOhBandaiSetCatalog>());
} catch (const std::exception& e) {
return Result<YuGiOhBandaiSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> YuGiOhBandaiSetCatalogService::save(const YuGiOhBandaiSetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
@@ -0,0 +1,128 @@
#include "ccm/services/YuGiOhBandaiSetCompletion.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
bool passesLanguageFilter(const YuGiOhBandaiCard& card,
std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSetNosBySetId(const std::vector<YuGiOhBandaiCard>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
} // namespace
std::vector<Language>
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<YuGiOhBandaiSetCompletionProgress>
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
std::vector<YuGiOhBandaiSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedNos] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo =
YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
YuGiOhBandaiSetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhBandaiSetCompletionProgress& a,
const YuGiOhBandaiSetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<YuGiOhBandaiChecklistEntry>
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<YuGiOhBandaiChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
YuGiOhBandaiChecklistEntry entry;
entry.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
entry.name = card.name;
entry.rarity = card.rarity;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhBandaiChecklistEntry& a,
const YuGiOhBandaiChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
@@ -0,0 +1,49 @@
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
YuGiOhSetCatalogService::YuGiOhSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path YuGiOhSetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOh) /
"set-catalog.json";
}
bool YuGiOhSetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<YuGiOhSetCatalog> YuGiOhSetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<YuGiOhSetCatalog>::err("Yu-Gi-Oh! set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<YuGiOhSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<YuGiOhSetCatalog>::ok(j.get<YuGiOhSetCatalog>());
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> YuGiOhSetCatalogService::save(const YuGiOhSetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+132
View File
@@ -0,0 +1,132 @@
#include "ccm/services/YuGiOhSetCompletion.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
const std::string digits = ygoCollectorDigitsOnly(setNo);
if (abbrev.empty() || digits.empty()) return {};
return abbrev + "|" + digits;
}
bool passesLanguageFilter(const YuGiOhCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSlotsBySetId(const std::vector<YuGiOhCard>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string key = ygoSlotKey(card.setNo);
if (key.empty()) continue;
out[card.set.id].insert(key);
}
return out;
}
} // namespace
std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSlotsBySetId(collection, languageFilter);
std::vector<YuGiOhSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedSlots] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string key = ygoSlotKey(card.setNo);
if (!key.empty() && ownedSlots.count(key) != 0) ++matched;
}
YuGiOhSetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhSetCompletionProgress& a,
const YuGiOhSetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedSlots;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string key = ygoSlotKey(card.setNo);
if (!key.empty()) ownedSlots.insert(key);
}
std::vector<YuGiOhChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
YuGiOhChecklistEntry entry;
entry.setNo = card.setNo;
entry.name = card.name;
const std::string key = ygoSlotKey(card.setNo);
entry.owned = !key.empty() && ownedSlots.count(key) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhChecklistEntry& a, const YuGiOhChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+3 -1
View File
@@ -18,7 +18,7 @@ struct Replacement {
std::string_view to;
};
constexpr std::array<Replacement, 14> kReplacements{{
constexpr std::array<Replacement, 16> kReplacements{{
{"'", ""},
{"`", ""},
{",", ""},
@@ -34,6 +34,8 @@ constexpr std::array<Replacement, 14> kReplacements{{
{"\xC3\xBB", "u"}, // u-circumflex
// Remaining accented vowels appear in modern Scryfall data but were not
// listed in the Rust source. Keeping behavior 1:1 deliberately.
{"\xE2\x99\x82", "male"}, // ♂ male sign
{"\xE2\x99\x80", "female"}, // ♀ female sign
}};
void replaceAllInPlace(std::string& s, std::string_view from, std::string_view to) {
+69
View File
@@ -0,0 +1,69 @@
#include "ccm/util/SetNoNatural.hpp"
#include <cctype>
namespace ccm {
namespace {
[[nodiscard]] bool isAsciiDigit(char c) noexcept {
return std::isdigit(static_cast<unsigned char>(c)) != 0;
}
[[nodiscard]] int cmpChar(char a, char b) noexcept {
const auto ua = static_cast<unsigned char>(a);
const auto ub = static_cast<unsigned char>(b);
if (ua < ub) return -1;
if (ua > ub) return 1;
return 0;
}
} // namespace
int compareSetNoNatural(std::string_view a, std::string_view b) noexcept {
std::size_t i = 0;
std::size_t j = 0;
while (i < a.size() && j < b.size()) {
const bool aDigit = isAsciiDigit(a[i]);
const bool bDigit = isAsciiDigit(b[j]);
if (aDigit && bDigit) {
std::size_t aEnd = i;
while (aEnd < a.size() && isAsciiDigit(a[aEnd])) ++aEnd;
std::size_t bEnd = j;
while (bEnd < b.size() && isAsciiDigit(b[bEnd])) ++bEnd;
std::size_t aSig = i;
while (aSig < aEnd && a[aSig] == '0') ++aSig;
std::size_t bSig = j;
while (bSig < bEnd && b[bSig] == '0') ++bSig;
const std::size_t aLen = aEnd - aSig;
const std::size_t bLen = bEnd - bSig;
if (aLen != bLen) return aLen < bLen ? -1 : 1;
for (std::size_t k = 0; k < aLen; ++k) {
const int c = cmpChar(a[aSig + k], b[bSig + k]);
if (c != 0) return c;
}
i = aEnd;
j = bEnd;
continue;
}
const int c = cmpChar(a[i], b[j]);
if (c != 0) return c;
++i;
++j;
}
if (i == a.size() && j == b.size()) {
if (a == b) return 0;
return a < b ? -1 : 1;
}
return i == a.size() ? -1 : 1;
}
} // namespace ccm
+3 -3
View File
@@ -11,13 +11,13 @@ Long-form contributor documentation that lives outside the source tree.
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon (West + Asia backends), Yu-Gi-Oh!, and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), the Japanese Pokémon EN catalog asset (Asia region), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon (West + Asia backends), Yu-Gi-Oh!, Yu-Gi-Oh! (Bandai), and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), the Japanese Pokémon EN catalog asset (Asia region), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
- `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching).
- `README.md` — index page that clusters docs by area and links to all documents in this directory.
## Subdirectories
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-ygo-bandai.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats.
## Conventions
@@ -28,7 +28,7 @@ Long-form contributor documentation that lives outside the source tree.
## Required follow-ups
- After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`.
- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `YuGiOhBandaiSetSource`, `YuGiOhBandaiCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior.
- After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples.
- After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete.
- Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`).
+6 -4
View File
@@ -141,7 +141,7 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header
- `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);`
- `static Result<std::string> parseResponse(const std::string& body);`
If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Keep both optional per game — default behavior should remain an explicit unsupported error.
If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. For bidirectional identify (set + name → setNo, or set + setNo → name), also override `detectBySetNo(...)` / `detectVariantsBySetNo(setId, setNo)` and wire them through `CardPreviewService`. In the edit dialog, bind Set # `wxEVT_TEXT` to `markSetNoLookupEdited()` and branch Auto detect with `shouldDetectBySetNo(...)` so the last typed field wins when both are filled (see existing Yu-Gi-Oh! / Pokémon / Digi-Battle / Bandai dialogs). `AutoDetectedPrint` carries `setNo` + `rarity` for every game; optional `name` / `setId` / `setName` / `language` fields stay empty when unused. Keep all of these optional per game — default behavior should remain an explicit unsupported error.
Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover:
@@ -325,6 +325,7 @@ Derive from `BaseCardEditDialog<<Name>Card>`. Override:
- `readExtraFromCard()` — copy fields from `constCard()` into your widgets.
- `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`.
- `updateMenuName()` — return `"Update <Display>"`. This is what the dialog's "no sets cached" hint shows the user.
- `validateExtraFields()` — optional; called from OK after name/set checks. Return `false` to block save (show your own themed dialog). Yu-Gi-Oh! (Bandai) requires a non-empty set number here.
Optional `BaseCardEditDialog` extension points (defaults keep a single read-only set combo in the **Set** row):
@@ -360,12 +361,13 @@ Implement the virtuals:
- `gameId()` returns `Game::<Name>`.
- `displayName()` returns `"<Display>"`.
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. Activation is raised from `BaseCardListPanel` via `CallAfter` so Edit's `ShowModal` does not run inside the list notify path. **The binding must live here**, in the typed `IGameView`, not in `MainFrame``MainFrame` only sees `IGameView` and never `<Name>Card`.
- `selectedPanel(parent)` — lazily allocates the selected panel.
- `refreshCollection()` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open.
- `refreshCollection(selectId = nullopt)` — calls `collection_.list(Game::<Name>)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(..., selectId)` (preserves the selected card by `id` when still present; when `selectId` is set — e.g. after Add — selects that card instead). Also re-syncs the selected panel.
- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `<Name>CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. After a successful Add, call `refreshCollection(added.value())` so the new card stays selected. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open.
- `onUpdateSets(parent)` — calls `sets_.updateSets(Game::<Name>)`, refreshes `setsCache_`, returns a status string.
- `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`.
- `nudgeSelection(delta)` — forwards to `listPanel_->nudgeSelection(delta)` so Up/Down from the filter text box can move the table selection without stealing caret focus. Bind `wxEVT_KEY_DOWN` on the filter (MainFrame toolbar or in-game toolbar) for `WXK_UP` / `WXK_DOWN` accordingly.
- `applyTheme(palette)` — forwards to both panels' `applyTheme`.
- `updateSetsMenuLabel()` — returns `"Update <Display>"`. This is what the `Sets` menu entry shows.
+105 -10
View File
@@ -20,19 +20,39 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects the backend below. Collection: `pokemon/collection.json`. West sets: `pokemon/sets-west.json`. Asia sets: `pokemon/sets-asia.json`. Language choices: West → English/German/French/Spanish/Italian/Russian; Asia → Japanese/S-Chinese/T-Chinese/Korean.
### West (`Game::Pokemon`, pokemontcg.io)
### West (`Game::Pokemon`, TCGdex EN)
**Info API:** `https://api.pokemontcg.io/v2/sets`
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
Upstream: [TCGdex REST API](https://tcgdex.dev/) locale `en`. No API key. Canonical West set ids are TCGdex EN ids (e.g. `base1`, `sv01`, `swsh12.5tg`). Legacy pokemontcg.io ids (`sv1`, `pgo`, `swsh12tg`, …) are rewritten via `canonicalizeWestSetId` on West collection load, preview/auto-detect lookups, and set-completion matching so existing collections keep working; the next save persists TCGdex ids.
**Info API:** `https://api.tcgdex.net/v2/en/sets`
Used by `PokemonSetSource` to fetch the slim set list (`id`, `name`). Release dates are not on the list endpoint — each sets `GET /v2/en/sets/{id}` supplies `releaseDate` as `YYYY-MM-DD`, rewritten to `YYYY/MM/DD`, then the list is sorted ascending by release date.
**Asset API:** `https://api.tcgdex.net/v2/en/cards/{setId}-{localId}` (by id), `https://api.tcgdex.net/v2/en/cards?…` (filtered search), and set-detail `cards[]` for auto-detect. Image CDN bases live on `assets.tcgdex.net`; the preview source appends `/high.png` (wxImage decodes PNG, not webp).
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
Used by `PokemonCardPreviewSource` in two ways:
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/en/cards/{setId}-{localId}` (card object with `image` base). On HTTP failure or missing image, falls back to a filtered search `set.id=eq:…&localId=eq:…` (collector numbers are unique within a set). When Set # or set id is missing, uses `name=eq:…` with optional `set.id` / `localId`. Legacy set ids are canonicalized before URL build.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Prefers `GET /v2/en/sets/{setId}` and filters `cards[]` by exact case-insensitive card name. Maps `localId``AutoDetectedPrint::setNo` and `rarity``AutoDetectedPrint::rarity` (the edit dialog does not auto-sync holo flags from rarity). If set detail fails, falls back to a filtered cards search and still restricts rows to the chosen set id when present. Distinct `(setNo, rarity)` pairs are deduped.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
3. **Reverse auto-detect (`detectVariantsBySetNo`, same Set # Auto detect button).** Requires a selected set. When **Name** is blank and **Set #** is filled, uses `GET /v2/en/cards/{setId}-{localId}` (then filtered search) to fill the card **name**. Returned `localId`s are post-filtered so a fuzzy hit cannot win on a shared digit prefix (`4` must not accept `14`). When Name is filled, behavior stays name → setNo as above. Set is always required for either direction.
The edit dialog offers **Auto detect**, **Next**, silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `localId` path expects only the printed-number component.
### Set-completion catalog (West)
**Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes:
1. The set list (`pokemon/sets-west.json`) from `/v2/en/sets` + per-set detail dates
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-west.json` from each sets detail `cards[]` (`localId``setNo`, `name` → name)
Each catalog pack stores `id` (TCGdex EN set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by `localId` (normalized by stripping anything after `/`). Duplicate collector numbers within a pack collapse to one checklist row. The Pokemon **Set Completion** tab reads this file offline; ownership for a West pack requires `PokemonRegion::West`, a canonicalized `card.set.id` match, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts.
After a successful Update, `PokemonGameView` also runs `syncPokemonCollectionSets` against the refreshed set lists: West cards get legacy set-id migration plus `set.name` / `releaseDate` refresh when the id is present; Asia cards refresh name/date the same way. Changed cards are persisted via `CollectionService::saveAll`.
If `set-catalog-west.json` is missing (and the active region filter is West or All with no Asia catalog either), the Set Completion tab prompts the user to run Update Pokemon.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
@@ -80,8 +100,52 @@ Used in two situations:
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Yu-Gi-Oh! edit dialog).** Uses `fname=` plus **`cardset=`** set to the **display set name** from the picker (must match `card_sets[].set_name` in the payload). If that request fails (for example unknown set label), it retries with **`fname=` only** and still filters prints by preferred `set_name`. `YuGiOhCardPreviewSource::parsePrintVariants(...)` walks every `(set_code, set_rarity)` pair for rows whose **card name matches exactly** (case-insensitive) so the dialog can offer ring-buffer **Next** controls: one cycles distinct `set_code` values for that name+set (and resets rarity to the first upstream rarity for the newly selected code); another cycles distinct `set_rarity` values for the **current** `set_code` without changing the collector number. Shared HTTP and parsing rules live beside `parseFirstPrint`. When the dialog passes both an exact card name and a display `set_name`, an upstream miss on that label returns an error instead of falling back to unfiltered `card_sets[]` rows — otherwise unrelated products (same card name, different `set_name` on each printing) could be blended into one bogus variant list. The Yu-Gi-Oh! edit dialog additionally drops European alternate `set_code` rows that use the `-E###` pattern (single `E` before digits, e.g. `LOB-E003`) when the card language is **English**, because YGOPRODeck keeps those alongside NA numbering (`LOB-005`) under the same English `set_name`; it also collapses `LOB-005`-style and `LOB-EN005`-style codes to one **Next** slot via digit-tail matching (`ccm/util/YuGiOhPrintingSlot.hpp`). No image data is needed for this path, so Yugipedia is not consulted.
3. **Reverse auto-detect (`detectVariantsBySetNo`).** When **Name** is blank and **Set #** is filled, the Set # Auto detect button looks up the offline `yugioh/set-catalog.json` checklist (same file as Set Completion) by `Set.id` + collector digits / full code, and fills the card **name** (and **rarity** when present on the catalog row or when YGOPRODeck `cardset=` enrichment succeeds). Digit matching strips leading zeros but is not a prefix match (`5``LOB-005`, `1` does not match `LOB-011`). Name→Set # Auto detect also applies the matched prints rarity. When **both** Name and Set # are filled, the field last typed by the user is the lookup key (so editing Set # after a name detect and clicking Auto detect again resolves by set number, not by re-running the name path). Requires a prior **Sets → Update Yu-Gi-Oh!** so the catalog exists (re-run Update to refresh rarities on older catalogs). Set is always required for both directions.
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
### Set-completion catalog (`cardinfo.php` all-cards dump)
**Sets → Update Yu-Gi-Oh!** uses `YuGiOhSetSource::fetchAllWithCatalog()` so two HTTP responses write:
1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases)
2. A pack checklist at `<dataStorage>/yugioh/set-catalog.json` from the unfiltered `cardinfo.php` dump
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name, rarity? }` drawn from each cards `card_sets[]` (`set_rarity` when present). European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
## Yu-Gi-Oh! (Bandai) APIs (Yugipedia)
Bandai Carddass (pre-Konami) is wired as `Game::YuGiOhBandai` (`dirName` `yugiohbandai`, UI label **Yu-Gi-Oh! (Bandai)**). There is no dedicated Bandai REST API; everything goes through Yugipedia MediaWiki + Semantic MediaWiki.
### Info API (sets + catalog)
`YuGiOhBandaiSetSource` keeps an **app-owned set manifest** (stable ids, no fragile category scrape):
| id | Name | Numbers |
|---|---|---|
| `ban1` | 1st Generation | 142 |
| `ban2` | 2nd Generation | 4388 |
| `ban3` | 3rd Generation | 89118 |
| `banpromo-j` | Jump Promos | J1J3 |
| `banpromo-ta` | Toei Promos | TA1TA2 |
| `bansealdass` | Sealdass | 142 |
`fetchAll()` returns that manifest (offline — no HTTP). `fetchAllWithCatalog()` additionally `GET`s each sets Yugipedia gallery page via `action=parse&prop=wikitext` and parses lines like `… | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|…}}` into checklist entries `{setNo, name, rarity}` (rarity codes `C`/`R`/`SR` → Common/Rare/Super Rare). The shared promo gallery is split by `setNo` prefix (`J*` vs `TA*`). Persisted at `yugiohbandai/set-catalog.json`.
**Set Completion** ownership keys on `(set.id, normalized setNo)`. Because `fetchAll()` is offline, Add/Edit can work before any catalog download; the catalog is filled on the first visit to the Set Completion tab (or via **Sets → Update Yu-Gi-Oh! (Bandai)**). Cards without a set number do not count toward progress.
English Blue-Eyes is **not** a separate set — it is `ban3` card `#118` with language English.
### Asset API (preview + auto-detect)
1. **Preview:** `pageimages` on preferred titles `Name (Bandai)` / `Name (English Bandai)` / `Name (Bandai Sealdass)`, falling back to SMW `ask` by English name then `pageimages` on the best hit.
2. **Auto-detect by name:** SMW `ask` `[[Category:Bandai cards]][[English name::…]]` → fills `name`, `setId`/`setName`, `setNo`, `rarity`, `language`. Requires a selected set.
3. **Auto-detect by number:** SMW `ask` `[[Bandai number::…]]` (or promo gallery parse for `J*`/`TA*` codes) → same fields, then **filtered to the selected set**. Ask results are also dropped when the returned Bandai number does not match the requested one after normalization (`1` must not accept `11`). The Set # Auto detect button is bidirectional: blank name + number fills name; name filled fills number/rarity. Set is always required.
Card-back fallback URL: `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`.
## Digimon Digi-Battle (1999) APIs (digimoncard.io)
English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse).
@@ -100,6 +164,19 @@ and collects unique `set_name[]` pack strings. Each pack becomes a `Set` with:
Unknown future packs get an empty release date and sort last.
Cached on disk as `<dataStorage>/digibattle99/sets.json` via `SetService` / `JsonSetRepository`.
### Set-completion catalog (same `search.php` payload)
**Sets → Update Digimon (Digi-Battle)** uses `DigiBattle99SetSource::fetchAllWithCatalog()` so one HTTP response writes both:
1. The set list (`sets.json`) as above
2. A pack checklist at `<dataStorage>/digibattle99/set-catalog.json`
Each catalog pack stores `id` (slug), `name` (display), and `cards[]` of `{ setNo, name }` (API `id` normalized like preview — alphabetic prefix uppercased). A card listed in multiple `set_name[]` packs appears under **each** pack. The Digimon **Set Completion** tab reads this file offline (no live HTTP while browsing); ownership for a pack requires matching `card.set.id` plus normalized `setNo`.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Digimon (Digi-Battle).
### Asset API: CDN images + `search.php` lookup
Card scans live at:
@@ -117,11 +194,13 @@ where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also s
**Auto-detect** (`detectPrintVariants`): same search; distinct `id` values become `AutoDetectedPrint::setNo`. Digi-Battle UI is Pokémon-like (no persisted rarity).
**Reverse auto-detect** (`detectVariantsBySetNo`): when Name is blank and Set # is filled, search with `card=` + `pack=` fills `AutoDetectedPrint::name` (and normalizes `setNo`). Hits are post-filtered so digits-only input matches the numeric suffix with leading zeros ignored (`1``ST-01`, not `ST-11`). Set (pack display name) is always required for either direction.
Empty search array / `{"error":"..."}``NotFound`; bad JSON / HTTP → `Transient`.
## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend
Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemon`, same data directory as West). It is **not** a separate Game menu entry: the unified **Pokemon** UI stores both West and Asia cards in `pokemon/collection.json` with a per-card `region` (`West` / `Asia`). Set caches are split by filename under that directory (`pokemon/sets-west.json` vs `pokemon/sets-asia.json`). `JsonSetRepository` migrate-on-load promotes legacy `pokemon/sets.json``sets-west.json` and `pokemonjp/sets.json``sets-asia.json` when the new files are missing. **Sets > Update Pokemon** refreshes both lists. Upstream: [TCGdex REST API](https://tcgdex.dev/). No API key. Japanese set IDs (e.g. `PMCG1`, `SV1a`) are never merged into Western pokemontcg.io ids.
Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemon`, same data directory as West). It is **not** a separate Game menu entry: the unified **Pokemon** UI stores both West and Asia cards in `pokemon/collection.json` with a per-card `region` (`West` / `Asia`). Set caches are split by filename under that directory (`pokemon/sets-west.json` vs `pokemon/sets-asia.json`). `JsonSetRepository` migrate-on-load promotes legacy `pokemon/sets.json``sets-west.json` and `pokemonjp/sets.json``sets-asia.json` when the new files are missing. **Sets > Update Pokemon** refreshes both lists. Upstream: [TCGdex REST API](https://tcgdex.dev/). No API key. Japanese set IDs (e.g. `PMCG1`, `SV1a`) are never merged into Western TCGdex EN ids.
### Info API: TCGdex `GET /v2/ja/sets` (+ per-set detail)
@@ -146,6 +225,19 @@ Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemo
Seed data lives in `tools/pokemon_jp/classic_missing_sets.json` + `classic_missing_prints.json` (merged into the EN catalog via `merge_classic_missing.py`). LocalIds for these products are sequential `001`… within each product (cards were unnumbered in print). Refresh `UnnumberedPromo` prints from Bulbapedia with `python tools/pokemon_jp/harvest_unnumbered_promos.py`, then fill preview images with `python tools/pokemon_jp/enrich_unnumbered_promo_images.py` (prefers Unnumbered / Japanese reprint-gallery scans over English Wizards `|image=` primaries; EN-only Bulbapedia pages leave `image_url` empty), then re-run `merge_classic_missing.py`. Numbered Japanese promo eras (`SV-P`, `S-P`, …) remain out of scope — TCGdex does not expose them, and they are not part of this curated set.
### Set-completion catalog (Asia)
**Sets → Update Pokemon** uses `JapanesePokemonSetSource::fetchAllWithCatalog()` so the Asia path writes:
1. The set list (`pokemon/sets-asia.json`) as above (EN names + classic product injection)
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-asia.json`
For each set, the source `GET`s `/v2/ja/sets/{id}` and builds checklist rows from `cards[]` (`localId``setNo`, display name prefers EN catalog `nameEn`, else TCGdex Japanese `name`). Prints present in the bundled EN catalog but missing from TCGdex `cards[]` are **gap-filled** into the pack (covers UnnumberedPromo / City Gym / Expansion Sheets / Southern Islands and sparse classic sets). Catalog-only products with no TCGdex detail become packs entirely from `JapanesePokemonEnCatalog::printsForSet`.
The Pokemon **Set Completion** tab also loads this file offline; ownership for an Asia pack requires `PokemonRegion::Asia`, matching `card.set.id`, and `normalizeLocalId` on `setNo`. Region and language filters on the tab restrict which packs/cards count. West and Asia never cross-count.
If `set-catalog-asia.json` is missing (and the active region filter needs it), the Set Completion tab prompts the user to run Update Pokemon.
### Sets without printed collector numbers (`UnnumberedPromo`)
Physically unnumbered Japanese promos (and the other classic catalog-only products above) have **no printed set number**. The app still stores a synthetic `setNo` / catalog `local_id` (`001`, `002`, …) so preview and collection JSON stay keyed by `(setId, localId)` — but that value must not be treated as something the user can read off the card.
@@ -178,6 +270,8 @@ It does **not** substitute another printing of the same Pokémon when both TCGde
Auto-detect / Next uses the same set-detail `cards[]`, matching the typed name against catalog English names or TCGdex Japanese names. Catalog EN aliases are applied only when the catalog `name_ja` agrees with the TCGdex row (stale seed mappings like Charmander→`001` are ignored).
Reverse auto-detect (`detectVariantsBySetNo`) uses `GET /v2/ja/cards/{setId}-{localId}` (preferring catalog `nameEn` when present) or a catalog scan with leading-zero-insensitive `localId` matching (`1``001`, not `011`) for catalog-only sets, filling Name when Set # is known and Name is blank. Set is always required.
Pokémon English aliases in the catalog come from National Dex → species table (`dexId`) for ordinary Pokémon. When `name_ja` carries a known owner / Rocket's / Dark / Light / Shining prefix, `enrich_preview_images.py` composes the **full English product title** (e.g. `エリカのナゾノクサ``Erika's Oddish`, `わるいリザードン``Dark Charizard`, `R団のサンダー``Rocket's Zapdos`, neo garbled `輝くセレビ``Shining Celebi`). Those rows use `name_en_source: "species-table-variant"`. Trainer/Energy English aliases come from the offline JA→EN map `tools/pokemon_jp/non_pokemon_en_by_ja.json` (e.g. Switch ← `ポケモンいれかえ`).
That trainer/energy map is maintained to cover **at least the first 15 chronological main Japanese expansions** present in TCGdex (PMCG1PMCG6, neo1neo4, VS1, web1, E1E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name.
@@ -246,7 +340,7 @@ Three mechanisms reduce preview latency for **all** games (Magic, Pokemon West/A
- **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview.
- **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `<exeDir>/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path.
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, and `images.digimoncard.io` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway.
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, and `images.digimoncard.io` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway.
`CardPreviewService` consults the tiers in order **memory → disk → source/HTTP**. On a disk hit (positive *or* negative) the entry is promoted into the in-memory LRU so the next click on the same row never re-touches the disk cache. On HTTP success the bytes are written through to both tiers in one shot. On a `NotFound` source error the **negative** marker is written through to both tiers; on `Transient` source errors nothing is written, so the next selection retries cleanly.
@@ -262,6 +356,7 @@ Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match C
- Pokémon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg`
- Japanese Pokémon: `https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg`
- Yu-Gi-Oh!: Yugipedia English TCG back — try `https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png`, then `https://ms.yugipedia.com/e/e5/Back-EN.png`; if both fail, load `<exeDir>/assets/ygo_card_back.png` (shipped from `ui_wx/assets/ygo_card_back.png` at link time). `fallbackImageUrlForGame(Game::YuGiOh)` returns the thumbnail URL for helpers that only consult a single string.
- Yu-Gi-Oh! (Bandai): `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`.
- Digimon (Digi-Battle): no stable public back URL; load `<exeDir>/assets/digibattle99_card_back.png` (shipped from `ui_wx/assets/digibattle99_card_back.png` at link time).
If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available."
@@ -273,6 +368,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon West (`GET /v2/cards/{setId}-{number}``data` object, or search `data[]`; `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
Binary file not shown.

After

Width:  |  Height:  |  Size: 407 KiB

+9 -4
View File
@@ -19,11 +19,16 @@
- `card_preview_service_tests.cpp``CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
- `local_preview_byte_cache_tests.cpp``LocalPreviewByteCache` adapter against `StdFileSystem` (real disk under a unique `temp_directory_path()/ccm_preview_cache_test_*` per case, RAII `TempDir` cleanup; see also `std_file_system_tests.cpp`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
- `std_file_system_tests.cpp``StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. Drives `fetchAll` via `FixedHttpClient`.
- `pokemon_west_set_id_tests.cpp``canonicalizeWestSetId` identity + legacy pokemontcg → TCGdex EN mappings (`sv1``sv01`, `pgo``swsh10.5`, …) and unknown passthrough.
- `pokemon_collection_set_sync_tests.cpp``syncPokemonCollectionSets` West id migration + name/date refresh; Asia metadata-only refresh.
- `pokemon_set_source_tests.cpp``PokemonSetSource::parseListResponse` (TCGdex EN `/v2/en/sets` top-level array) + `parseReleaseDate` / `parseCatalogPackFromSetDetail`. Drives `fetchAll` / `fetchAllWithCatalog` via routing HTTP fakes and asserts EN endpoints.
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` / `buildCardByIdUrl` (canonicalization + `localId` filters), `parseSearchResponse` / `parseCardByIdResponse` (`image` + `/high.png`), and `fetchImageUrl` / auto-detect via `FixedHttpClient`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`.
- `digibattle99_set_completion_tests.cpp``computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`.
- `yugioh_set_completion_tests.cpp``computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`.
- `pokemon_set_completion_tests.cpp``computePokemonSetCompletion` / `pokemonChecklistForSet` West/Asia ownership isolation + region/language filters + `PokemonSetCatalogService` dual-path FS round-trip.
- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`.
- `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
- `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. Also `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist from `cardinfo.php`.
- `yugioh_set_lookup_tests.cpp``lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh / DigiBattle99) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
- `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005``LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
+10
View File
@@ -19,10 +19,19 @@ add_executable(ccm_core_tests
card_preview_service_tests.cpp
local_preview_byte_cache_tests.cpp
std_file_system_tests.cpp
pokemon_west_set_id_tests.cpp
pokemon_collection_set_sync_tests.cpp
pokemon_set_source_tests.cpp
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
digibattle99_set_completion_tests.cpp
yugiohbandai_set_source_tests.cpp
yugiohbandai_card_preview_source_tests.cpp
yugiohbandai_set_completion_tests.cpp
yugioh_set_completion_tests.cpp
pokemon_set_completion_tests.cpp
set_no_natural_tests.cpp
japanese_pokemon_en_catalog_tests.cpp
japanese_pokemon_set_source_tests.cpp
japanese_pokemon_card_preview_source_tests.cpp
@@ -34,6 +43,7 @@ add_executable(ccm_core_tests
card_sorter_tests.cpp
card_filter_tests.cpp
ascii_utils_tests.cpp
card_lookup_detect_tests.cpp
http_get_mapping_tests.cpp
cpr_http_client_tests.cpp
+34
View File
@@ -10,6 +10,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/services/CardFilter.hpp"
@@ -287,6 +288,39 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
}
}
TEST_SUITE("CardFilter::matchesYuGiOhBandaiFilter") {
TEST_CASE("matches name set setNo rarity language") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
c.set.name = "1st Generation";
c.setNo = "14";
c.rarity = "Rare";
c.language = Language::Japanese;
CHECK(matchesYuGiOhBandaiFilter(c, "magician"));
CHECK(matchesYuGiOhBandaiFilter(c, "1st"));
CHECK(matchesYuGiOhBandaiFilter(c, "14"));
CHECK(matchesYuGiOhBandaiFilter(c, "rare"));
CHECK(matchesYuGiOhBandaiFilter(c, "japanese"));
CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "blue-eyes"));
}
TEST_CASE("empty filter matches everything") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
CHECK(matchesYuGiOhBandaiFilter(c, ""));
}
TEST_CASE("boolean flag columns are not matched") {
YuGiOhBandaiCard c;
c.name = "Dark Magician";
c.holo = true;
c.signed_ = true;
c.altered = true;
CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "true"));
CHECK(matchesYuGiOhBandaiFilter(c, "dark"));
}
}
TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") {
TEST_CASE("matches by name and set.name") {
JapanesePokemonCard c;
+33
View File
@@ -0,0 +1,33 @@
#include <doctest/doctest.h>
#include "ccm/util/CardLookupDetect.hpp"
using namespace ccm;
TEST_SUITE("preferDetectBySetNo") {
TEST_CASE("only set number filled uses reverse lookup") {
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::None));
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::Name));
CHECK(preferDetectBySetNo(true, false, CardLookupEditField::SetNo));
}
TEST_CASE("only name filled uses name lookup") {
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::None));
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::Name));
CHECK_FALSE(preferDetectBySetNo(false, true, CardLookupEditField::SetNo));
}
TEST_CASE("both empty does not prefer reverse") {
CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::None));
CHECK_FALSE(preferDetectBySetNo(true, true, CardLookupEditField::SetNo));
}
TEST_CASE("both filled: last edited SetNo prefers reverse") {
CHECK(preferDetectBySetNo(false, false, CardLookupEditField::SetNo));
}
TEST_CASE("both filled: Name or None keeps name lookup") {
CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::Name));
CHECK_FALSE(preferDetectBySetNo(false, false, CardLookupEditField::None));
}
}
+56
View File
@@ -34,6 +34,7 @@ public:
std::string lastSetNo;
std::string detectLastName;
std::string detectLastSetId;
std::string detectLastSetNo;
AutoDetectedPrint detectedPrint{"LOB-001", "Ultra Rare"};
bool allowAutoDetect{true};
@@ -67,6 +68,23 @@ public:
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
Result<AutoDetectedPrint> detectBySetNo(std::string_view setId,
std::string_view setNo) override {
detectLastSetId = std::string(setId);
detectLastSetNo = std::string(setNo);
return Result<AutoDetectedPrint>::ok(detectedPrint);
}
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setId,
std::string_view setNo) override {
detectLastSetId = std::string(setId);
detectLastSetNo = std::string(setNo);
std::vector<AutoDetectedPrint> v;
v.push_back(detectedPrint);
return Result<std::vector<AutoDetectedPrint>>::ok(std::move(v));
}
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override {
return allowAutoDetect;
}
@@ -992,3 +1010,41 @@ TEST_SUITE("CardPreviewService::detectPrintVariants") {
std::string::npos);
}
}
TEST_SUITE("CardPreviewService::detectVariantsBySetNo") {
TEST_CASE("routes set-scoped reverse lookup to registered source") {
FakeSource source;
source.detectedPrint = AutoDetectedPrint{"4", "Rare", "Pikachu"};
FakeGameModule module;
module.gameId = Game::Pokemon;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectVariantsBySetNo(Game::Pokemon, "base1", "4");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Pikachu");
CHECK(out.value()[0].setNo == "4");
CHECK(source.detectLastSetId == "base1");
CHECK(source.detectLastSetNo == "4");
}
TEST_CASE("returns error when game does not enable auto-detect") {
FakeSource source;
source.allowAutoDetect = false;
FakeGameModule module;
module.gameId = Game::Magic;
module.preview = &source;
FixedHttpClient http;
CardPreviewService svc{http};
svc.registerModule(module);
const auto out = svc.detectVariantsBySetNo(Game::Magic, "lea", "1");
CHECK(out.isErr());
CHECK(out.error().find("not enabled") != std::string::npos);
}
}
+46
View File
@@ -10,6 +10,7 @@
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/CardSorter.hpp"
@@ -548,6 +549,51 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") {
}
}
TEST_SUITE("CardSorter - YuGiOhBandai columns") {
TEST_CASE("Holo sorts false before true; Rarity and SetNo sort") {
YuGiOhBandaiCard a;
a.id = 1;
a.name = "a";
a.set = Set{"ban1", "1st Generation", "1998/09/01"};
a.setNo = "14";
a.rarity = "Rare";
a.holo = true;
YuGiOhBandaiCard b;
b.id = 2;
b.name = "b";
b.set = Set{"ban1", "1st Generation", "1998/09/01"};
b.setNo = "9";
b.rarity = "Common";
b.holo = false;
std::vector<YuGiOhBandaiCard> v = {a, b};
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Holo, /*ascending=*/true);
CHECK(v[0].id == 2);
CHECK(v[1].id == 1);
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetNo, /*ascending=*/true);
CHECK(v[0].setNo == "14");
CHECK(v[1].setNo == "9");
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Rarity, /*ascending=*/true);
CHECK(v[0].rarity == "Common");
}
TEST_CASE("Set column sorts by release date") {
YuGiOhBandaiCard a;
a.id = 1;
a.set = Set{"ban3", "3rd Generation", "1999/03/06"};
YuGiOhBandaiCard b;
b.id = 2;
b.set = Set{"ban1", "1st Generation", "1998/09/01"};
std::vector<YuGiOhBandaiCard> v = {a, b};
sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetReleaseDate, /*ascending=*/true);
CHECK(v[0].id == 2);
CHECK(v[1].id == 1);
}
}
TEST_SUITE("CardSorter - JapanesePokemon columns") {
TEST_CASE("Holo and FirstEdition sort false before true") {
std::vector<JapanesePokemonCard> v = {
+18
View File
@@ -236,4 +236,22 @@ TEST_SUITE("CollectionService<MagicCard>") {
CHECK(store.removed[0].second == "a.png");
CHECK(store.removed[1].second == "b.png");
}
TEST_CASE("saveAll replaces the collection map") {
InMemoryRepo repo;
StubImageStore store;
CollectionService<MagicCard> svc{repo, store};
REQUIRE(svc.add(Game::Magic, makeCard("A")).isOk());
REQUIRE(svc.add(Game::Magic, makeCard("B")).isOk());
MagicCard only = makeCard("Only");
only.id = 7;
REQUIRE(svc.saveAll(Game::Magic, {only}).isOk());
auto listed = svc.list(Game::Magic);
REQUIRE(listed.isOk());
REQUIRE(listed.value().size() == 1);
CHECK(listed.value()[0].id == 7);
CHECK(listed.value()[0].name == "Only");
}
}
@@ -177,3 +177,56 @@ TEST_SUITE("DigiBattle99CardPreviewSource::detectPrintVariants") {
CHECK(out.value()[1].setNo == "ST-126");
}
}
TEST_SUITE("DigiBattle99CardPreviewSource::detectVariantsBySetNo") {
TEST_CASE("card id search fills name within pack") {
FixedHttpClient http;
http.body = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "st-01");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Agumon");
CHECK(out.value()[0].setNo == "ST-01");
CHECK(http.lastUrl.find("card=ST-01") != std::string::npos);
}
TEST_CASE("digits-only 1 matches ST-01 not ST-11 from fuzzy API hits") {
FixedHttpClient http;
http.body = R"([
{"name":"Patamon","id":"ST-11","set_name":["Series 1 Starter Set"]},
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Other","id":"BO-1","set_name":["Booster 1"]}
])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "1");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Agumon");
CHECK(out.value()[0].setNo == "ST-01");
}
TEST_CASE("digits-only 11 matches ST-11 not ST-01") {
FixedHttpClient http;
http.body = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Patamon","id":"ST-11","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("Series 1 Starter Set", "11");
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Patamon");
CHECK(out.value()[0].setNo == "ST-11");
}
TEST_CASE("empty pack is rejected") {
FixedHttpClient http;
DigiBattle99CardPreviewSource src{http};
const auto out = src.detectVariantsBySetNo("", "ST-01");
REQUIRE(out.isErr());
CHECK(out.error().find("set") != std::string::npos);
}
}
+263
View File
@@ -0,0 +1,263 @@
#include <doctest/doctest.h>
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/DigiBattle99SetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
DigiBattle99Card makeOwned(std::string setId, std::string setName, std::string setNo) {
DigiBattle99Card c;
c.id = 1;
c.name = "Owned";
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
return c;
}
DigiBattle99SetCatalog sampleCatalog() {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack starter;
starter.setId = "series-1-starter-set";
starter.setName = "Series 1 Starter Set";
starter.cards = {
{"ST-01", "Agumon"},
{"ST-02", "Greymon"},
{"ST-03", "Gabumon"},
};
DigiBattle99SetCatalogPack booster;
booster.setId = "series-1-booster-pack";
booster.setName = "Series 1 Booster Pack";
booster.cards = {
{"ST-01", "Agumon"},
{"BO-01", "MetalGreymon"},
};
catalog.packs.push_back(std::move(booster));
catalog.packs.push_back(std::move(starter));
return catalog;
}
} // namespace
TEST_SUITE("computeDigiBattle99SetCompletion") {
TEST_CASE("only packs with owned cards appear") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("unique setNo within a pack; amount does not inflate") {
const auto catalog = sampleCatalog();
DigiBattle99Card a = makeOwned("series-1-starter-set", "Series 1 Starter Set", "st-01");
a.amount = 4;
DigiBattle99Card b = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
b.id = 2;
DigiBattle99Card c = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
c.id = 3;
const auto rows =
computeDigiBattle99SetCompletion({a, b, c}, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 66);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
}
TEST_CASE("empty catalog yields no rows") {
DigiBattle99SetCatalog empty;
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, empty).empty());
}
TEST_CASE("owned set missing from catalog is skipped") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack onlyBooster;
onlyBooster.setId = "series-1-booster-pack";
onlyBooster.setName = "Series 1 Booster Pack";
onlyBooster.cards = {{"BO-01", "MetalGreymon"}};
catalog.packs.push_back(std::move(onlyBooster));
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty());
}
TEST_CASE("language filter hides packs with no cards in that language") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
en.language = Language::English;
const auto allRows = computeDigiBattle99SetCompletion({en}, catalog);
REQUIRE(allRows.size() == 1);
const auto deRows =
computeDigiBattle99SetCompletion({en}, catalog, Language::German);
CHECK(deRows.empty());
const auto enRows =
computeDigiBattle99SetCompletion({en}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
}
TEST_CASE("same setNo in two languages counts once aggregated; filter is exclusive") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
en.language = Language::English;
DigiBattle99Card de =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
de.id = 2;
de.language = Language::German;
const auto allRows = computeDigiBattle99SetCompletion({en, de}, catalog);
REQUIRE(allRows.size() == 1);
CHECK(allRows[0].ownedUnique == 1);
const auto enRows =
computeDigiBattle99SetCompletion({en, de}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
DigiBattle99Card deOnly =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
deOnly.id = 3;
deOnly.language = Language::German;
const auto deRows = computeDigiBattle99SetCompletion({en, de, deOnly}, catalog,
Language::German);
REQUIRE(deRows.size() == 1);
CHECK(deRows[0].ownedUnique == 2);
}
}
TEST_SUITE("digiBattle99ChecklistForSet") {
TEST_CASE("greys missing cards and marks owned ones") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"),
};
const auto list =
digiBattle99ChecklistForSet(collection, catalog, "series-1-starter-set");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "ST-01");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "ST-02");
CHECK(list[1].owned == true);
CHECK(list[2].setNo == "ST-03");
CHECK(list[2].owned == false);
}
TEST_CASE("unknown set returns empty") {
const auto catalog = sampleCatalog();
CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty());
}
TEST_CASE("owned flags respect language filter") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
en.language = Language::English;
const auto filtered =
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
Language::German);
REQUIRE(filtered.size() == 3);
CHECK(filtered[0].owned == false);
CHECK(filtered[1].owned == false);
CHECK(filtered[2].owned == false);
const auto english =
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
Language::English);
REQUIRE(english.size() == 3);
CHECK(english[1].owned == true);
}
}
TEST_SUITE("digiBattle99LanguagesInCollection") {
TEST_CASE("empty collection yields empty") {
CHECK(digiBattle99LanguagesInCollection({}).empty());
}
TEST_CASE("returns distinct languages in allLanguages order") {
DigiBattle99Card jp =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
jp.language = Language::Japanese;
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
en.id = 2;
en.language = Language::English;
DigiBattle99Card enDup =
makeOwned("series-1-booster-pack", "Series 1 Booster Pack", "BO-01");
enDup.id = 3;
enDup.language = Language::English;
DigiBattle99Card de =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-03");
de.id = 4;
de.language = Language::German;
const auto langs = digiBattle99LanguagesInCollection({jp, en, enDup, de});
REQUIRE(langs.size() == 3);
CHECK(langs[0] == Language::English);
CHECK(langs[1] == Language::German);
CHECK(langs[2] == Language::Japanese);
}
}
TEST_SUITE("DigiBattle99SetCatalogService") {
TEST_CASE("save then load round-trips") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
DigiBattle99SetCatalogService store{fs, config, [](Game) { return "digibattle99"; }};
CHECK_FALSE(store.exists());
CHECK(store.load().isErr());
const auto catalog = sampleCatalog();
REQUIRE(store.save(catalog).isOk());
CHECK(store.exists());
const auto loaded = store.load();
REQUIRE(loaded.isOk());
CHECK(loaded.value() == catalog);
}
}
+71
View File
@@ -109,4 +109,75 @@ TEST_SUITE("DigiBattle99SetSource::fetchAll") {
CHECK(out.value().front().id == "series-1-starter-set");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
TEST_CASE("fetchAllWithCatalog returns sets and pack cards in one GET") {
FixedHttpClient http;
http.ok = true;
http.body = R"([
{"name":"Agumon","id":"st-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99SetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
CHECK(out.value().sets.size() == 2);
const auto* starter = out.value().catalog.findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 2);
CHECK(starter->cards[0].setNo == "ST-01");
CHECK(starter->cards[0].name == "Agumon");
const auto* booster = out.value().catalog.findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 1);
CHECK(booster->cards[0].setNo == "ST-01");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
}
TEST_SUITE("DigiBattle99SetSource::parseCatalog") {
TEST_CASE("lists a card under every pack in set_name") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
REQUIRE(out.value().packs.size() == 2);
const auto* booster = out.value().findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 2);
CHECK(booster->cards[0].setNo == "BO-01");
CHECK(booster->cards[1].setNo == "ST-01");
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].setNo == "ST-01");
}
TEST_CASE("dedupes the same setNo within one pack") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon Alt","id":"ST-01","set_name":["Series 1 Starter Set"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].name == "Agumon");
}
TEST_CASE("empty array returns an empty catalog") {
const auto out = DigiBattle99SetSource::parseCatalog("[]");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
TEST_CASE("error object is an error") {
const auto out = DigiBattle99SetSource::parseCatalog(
R"({"error":"No cards found for this search."})");
CHECK(out.isErr());
}
}
+210 -1
View File
@@ -2,10 +2,15 @@
#include "ccm/domain/Configuration.hpp"
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/Set.hpp"
@@ -28,6 +33,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
nlohmann::json jDigi = "DigiBattle99";
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
nlohmann::json jBandai = "YuGiOhBandai";
CHECK(jBandai.get<Game>() == Game::YuGiOhBandai);
nlohmann::json jJp = "JapanesePokemon";
CHECK(jJp.get<Game>() == Game::JapanesePokemon);
@@ -74,7 +82,7 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
for (const auto game : allGames()) {
CHECK(game != Game::JapanesePokemon);
}
CHECK(allGames().size() == 4);
CHECK(allGames().size() == 5);
CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon);
CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon);
CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon);
@@ -215,6 +223,48 @@ TEST_SUITE("PokemonCard JSON") {
const PokemonCard legacy = j.get<PokemonCard>();
CHECK(legacy.region == PokemonRegion::West);
}
TEST_CASE("West load migrates legacy pokemontcg set ids to TCGdex EN") {
nlohmann::json j = {
{"id", 1},
{"amount", 1},
{"name", "Charizard"},
{"set", {{"id", "sv1"}, {"name", "Scarlet & Violet"}, {"releaseDate", "2023/03/31"}}},
{"setNo", "6"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "English"},
{"condition", "NearMint"},
{"firstEdition", false},
{"holo", false},
{"signed", false},
{"altered", false},
{"region", "West"},
};
const PokemonCard back = j.get<PokemonCard>();
CHECK(back.set.id == "sv01");
}
TEST_CASE("Asia load does not rewrite set ids through West aliases") {
nlohmann::json j = {
{"id", 1},
{"amount", 1},
{"name", "Charmander"},
{"set", {{"id", "sv1"}, {"name", "Keep Asia id"}, {"releaseDate", "2023/01/01"}}},
{"setNo", "001"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "Japanese"},
{"condition", "NearMint"},
{"firstEdition", false},
{"holo", false},
{"signed", false},
{"altered", false},
{"region", "Asia"},
};
const PokemonCard back = j.get<PokemonCard>();
CHECK(back.set.id == "sv1");
}
}
TEST_SUITE("DigiBattle99Card JSON") {
@@ -245,6 +295,165 @@ TEST_SUITE("DigiBattle99Card JSON") {
}
}
TEST_SUITE("DigiBattle99SetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack pack;
pack.setId = "series-1-starter-set";
pack.setName = "Series 1 Starter Set";
pack.cards.push_back(DigiBattle99CatalogCard{"ST-01", "Agumon"});
pack.cards.push_back(DigiBattle99CatalogCard{"ST-126", "Agumon"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "series-1-starter-set");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "ST-01");
const DigiBattle99SetCatalog back = j.get<DigiBattle99SetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("series-1-starter-set") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("YuGiOhBandaiCard JSON") {
TEST_CASE("round-trips with setNo, rarity, holo and signed alias") {
YuGiOhBandaiCard c;
c.id = 14;
c.amount = 2;
c.name = "Dark Magician";
c.set = Set{"ban1", "1st Generation", "1998/09/01"};
c.setNo = "14";
c.rarity = "Rare";
c.note = "classic";
c.images = {"a.png"};
c.language = Language::Japanese;
c.condition = Condition::NearMint;
c.holo = true;
c.signed_ = true;
c.altered = false;
nlohmann::json j = c;
CHECK(j.at("setNo") == "14");
CHECK(j.at("rarity") == "Rare");
CHECK(j.at("holo") == true);
CHECK(j.at("signed") == true);
CHECK_FALSE(j.contains("firstEdition"));
const YuGiOhBandaiCard back = j.get<YuGiOhBandaiCard>();
CHECK(back == c);
}
TEST_CASE("missing each required key throws") {
const nlohmann::json full = {
{"id", 14},
{"amount", 1},
{"name", "Dark Magician"},
{"set", nlohmann::json{
{"id", "ban1"},
{"name", "1st Generation"},
{"releaseDate", "1998/09/01"},
}},
{"setNo", "14"},
{"rarity", "Rare"},
{"note", ""},
{"images", nlohmann::json::array()},
{"language", "Japanese"},
{"condition", "NearMint"},
{"holo", false},
{"signed", false},
{"altered", false},
};
for (const char* key : {
"id", "amount", "name", "set", "setNo", "rarity", "note", "images",
"language", "condition", "holo", "signed", "altered"}) {
nlohmann::json partial = full;
partial.erase(key);
CHECK_THROWS(partial.get<YuGiOhBandaiCard>());
}
}
}
TEST_SUITE("YuGiOhBandaiSetCatalog JSON") {
TEST_CASE("round-trips packs with rarity") {
YuGiOhBandaiSetCatalog catalog;
YuGiOhBandaiSetCatalogPack pack;
pack.setId = "ban1";
pack.setName = "1st Generation";
pack.cards.push_back(YuGiOhBandaiCatalogCard{"14", "Dark Magician", "Rare"});
pack.cards.push_back(YuGiOhBandaiCatalogCard{"9", "Blue-Eyes White Dragon", "Super Rare"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").at(0).at("id") == "ban1");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "14");
CHECK(j.at("packs").at(0).at("cards").at(0).at("rarity") == "Rare");
const YuGiOhBandaiSetCatalog back = j.get<YuGiOhBandaiSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("ban1") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("YuGiOhSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.setName = "Legend of Blue Eyes White Dragon";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon", "Ultra Rare"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician", "Ultra Rare"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "LOB");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "LOB-001");
CHECK(j.at("packs").at(0).at("cards").at(0).at("rarity") == "Ultra Rare");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("LOB") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
TEST_CASE("legacy catalog JSON without rarity still loads") {
const auto j = nlohmann::json::parse(R"({
"packs":[{"id":"LOB","name":"Legend of Blue Eyes White Dragon",
"cards":[{"setNo":"LOB-001","name":"Blue-Eyes White Dragon"}]}]
})");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
REQUIRE(back.packs.size() == 1);
REQUIRE(back.packs[0].cards.size() == 1);
CHECK(back.packs[0].cards[0].rarity.empty());
}
}
TEST_SUITE("PokemonSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
PokemonSetCatalog catalog;
PokemonSetCatalogPack pack;
pack.setId = "base1";
pack.setName = "Base";
pack.cards.push_back(PokemonCatalogCard{"4", "Charizard"});
pack.cards.push_back(PokemonCatalogCard{"58", "Growlithe"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "base1");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "4");
const PokemonSetCatalog back = j.get<PokemonSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("base1") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c;
+7
View File
@@ -28,6 +28,13 @@ TEST_SUITE("FsNames::formatTextForFs") {
TEST_CASE("idempotent on already-clean strings") {
CHECK(formatTextForFs("AlreadyClean") == "AlreadyClean");
}
TEST_CASE("male and female signs become male/female") {
CHECK(formatTextForFs("\xE2\x99\x82") == "male");
CHECK(formatTextForFs("\xE2\x99\x80") == "female");
CHECK(formatTextForFs("Nidoran \xE2\x99\x82") == "Nidoranmale");
CHECK(formatTextForFs("Nidoran \xE2\x99\x80") == "Nidoranfemale");
}
}
TEST_SUITE("FsNames::parseIndexFromFilename") {
+12
View File
@@ -5,6 +5,7 @@
#include "ccm/games/pokemon/PokemonGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
using namespace ccm;
@@ -65,6 +66,17 @@ TEST_SUITE("game modules expose stable identity and wiring") {
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("YuGiOhBandai module reports canonical metadata") {
NoopHttpClient http;
YuGiOhBandaiGameModule module(http);
CHECK(module.id() == Game::YuGiOhBandai);
CHECK(module.dirName() == "yugiohbandai");
CHECK(module.displayName() == "Yu-Gi-Oh! (Bandai)");
CHECK(module.cardPreviewSource() != nullptr);
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
}
TEST_CASE("JapanesePokemon module reports canonical metadata") {
NoopHttpClient http;
JapanesePokemonGameModule module(http);
@@ -591,3 +591,58 @@ TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only")
CHECK(shining.value()[0].setNo == "013");
}
}
TEST_SUITE("JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog") {
TEST_CASE("resolves English name from setId + localId") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"}
]
})");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "001", catalog.value());
REQUIRE(out.isOk());
REQUIRE(out.value().size() == 1);
CHECK(out.value()[0].name == "Bulbasaur");
CHECK(out.value()[0].setNo == "001");
}
TEST_CASE("leading-zero-insensitive localId still resolves") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"},
{"set_id":"PMCG1","local_id":"011","name_en":"Weedle","name_ja":"ビードル"}
]
})");
REQUIRE(catalog.isOk());
const auto byOne = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "1", catalog.value());
REQUIRE(byOne.isOk());
REQUIRE(byOne.value().size() == 1);
CHECK(byOne.value()[0].name == "Bulbasaur");
CHECK(byOne.value()[0].setNo == "001");
const auto byEleven =
JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "11", catalog.value());
REQUIRE(byEleven.isOk());
REQUIRE(byEleven.value().size() == 1);
CHECK(byEleven.value()[0].name == "Weedle");
}
TEST_CASE("unknown localId is an error") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Bulbasaur","name_ja":"フシギダネ"}
]
})");
REQUIRE(catalog.isOk());
const auto out = JapanesePokemonCardPreviewSource::detectVariantsBySetNoFromCatalog(
"PMCG1", "999", catalog.value());
REQUIRE(out.isErr());
}
}
@@ -139,6 +139,21 @@ TEST_SUITE("JapanesePokemonEnCatalog") {
CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1"));
}
TEST_CASE("printsForSet returns all prints for a set id") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"A","local_id":"1","name_en":"One"},
{"set_id":"A","local_id":"2","name_en":"Two"},
{"set_id":"B","local_id":"1","name_en":"Other"}
]
})");
REQUIRE(catalog.isOk());
const auto prints = catalog.value().printsForSet("A");
REQUIRE(prints.size() == 2);
CHECK(catalog.value().printsForSet("missing").empty());
}
TEST_CASE("missing set/print returns nullopt") {
JapanesePokemonEnCatalog empty;
CHECK_FALSE(empty.findSet("X").has_value());

Some files were not shown because too many files have changed in this diff Show More