mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-09-02 01:01:33 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22283de974 | |||
| d3b4762b76 | |||
| 2eb7c59f78 | |||
| 7cf25d671f | |||
| ab0e3c5ae2 | |||
| 9917e364c1 |
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Digimon (Digi-Battle)</summary>
|
||||
|
||||
|
||||
+1
-1
@@ -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`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `PokemonSetCatalogService`, `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`).
|
||||
|
||||
+28
-1
@@ -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"
|
||||
@@ -23,6 +25,7 @@
|
||||
#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"
|
||||
@@ -31,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>
|
||||
@@ -54,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";
|
||||
@@ -91,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;
|
||||
{
|
||||
@@ -112,11 +118,17 @@ 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);
|
||||
@@ -131,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
|
||||
@@ -161,6 +177,7 @@ 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.
|
||||
@@ -175,6 +192,9 @@ public:
|
||||
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
|
||||
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*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_,
|
||||
@@ -185,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_);
|
||||
@@ -208,14 +230,17 @@ 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_;
|
||||
@@ -223,6 +248,7 @@ private:
|
||||
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_;
|
||||
@@ -230,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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -4,11 +4,11 @@
|
||||
|
||||
## Layer pointers
|
||||
|
||||
- `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), `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/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/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
|
||||
- `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` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/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/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `parseCatalog` / `fetchAllWithCatalog` (or Asia equivalents) 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/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
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ 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
|
||||
@@ -24,6 +26,8 @@ add_library(ccm_core STATIC
|
||||
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
|
||||
|
||||
@@ -36,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
|
||||
@@ -45,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
|
||||
|
||||
@@ -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,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
|
||||
@@ -16,6 +16,9 @@ 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;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
|
||||
// TCG. When set id + collector number are both known, prefers
|
||||
// GET https://api.pokemontcg.io/v2/cards/{setId}-{number}
|
||||
// then falls back to a name-less search `set.id:… number:…`. Name-based
|
||||
// search is kept for lookups that lack a set number (or set id). Returns
|
||||
// `images.large` (with `images.small` as a graceful fallback).
|
||||
// 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,45 +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.
|
||||
// When both setId and setNo are non-empty, omits the name: clause so the
|
||||
// Lucene query cannot miss on name∩number intersections.
|
||||
// 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);
|
||||
|
||||
// Direct card endpoint: /v2/cards/{setId}-{normalizedNumber}.
|
||||
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string name;
|
||||
std::string imageBase;
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
// Strip everything after the first '/' (e.g. "4/102" -> "4"). Used by
|
||||
// preview lookups, auto-detect, and set-completion ownership matching.
|
||||
static std::string normalizeCollectorNumber(std::string_view setNo);
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
// 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);
|
||||
|
||||
// Parse a Pokemon TCG /v2/cards *search* response body (`data` array) 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);
|
||||
|
||||
// Parse a Pokemon TCG /v2/cards/{id} response (`data` object).
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseCardByIdResponse(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.
|
||||
// Parse a slim TCGdex cards-array search response; prefer first hit with image.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseSearchResponse(const std::string& body);
|
||||
|
||||
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,12 +1,8 @@
|
||||
#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`.
|
||||
// Set-completion catalog is built from a paginated /v2/cards dump.
|
||||
// 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"
|
||||
@@ -14,15 +10,14 @@
|
||||
#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* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards";
|
||||
static constexpr int kCardsPageSize = 250;
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
@@ -33,28 +28,18 @@ public:
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Sets endpoint + paginated cards dump for the offline checklist.
|
||||
// List + per-set detail (cards + release date) for the offline checklist.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
// 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);
|
||||
|
||||
// Build / merge checklist packs from one /v2/cards page body. Pass an
|
||||
// accumulating catalog; returns page count metadata for pagination.
|
||||
struct CardsPageMeta {
|
||||
int page{1};
|
||||
int pageSize{kCardsPageSize};
|
||||
int count{0};
|
||||
int totalCount{0};
|
||||
};
|
||||
static Result<CardsPageMeta> mergeCardsPage(const std::string& body,
|
||||
PokemonSetCatalog& catalog,
|
||||
const std::vector<Set>& sets);
|
||||
|
||||
static Result<PokemonSetCatalog> parseCatalog(const std::string& body,
|
||||
const std::vector<Set>& sets);
|
||||
|
||||
static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize);
|
||||
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_;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -23,6 +23,7 @@ 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};
|
||||
|
||||
@@ -50,8 +51,9 @@ pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog);
|
||||
|
||||
// Packs where the collection owns ≥1 matching card, ordered by setName then
|
||||
// region. When regionFilter is set, only that region's catalog/cards count.
|
||||
// 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,
|
||||
|
||||
@@ -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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -11,11 +11,13 @@ const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) c
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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,6 @@
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
@@ -26,20 +27,20 @@ std::string toLower(std::string s) {
|
||||
return s;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError> imageUrlFromCardObject(const nlohmann::json& card) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
if (!card.contains("images") || !card.at("images").is_object()) {
|
||||
return R::err({K::NotFound, "Card has no 'images' object."});
|
||||
}
|
||||
const auto& images = card.at("images");
|
||||
if (images.contains("large") && images.at("large").is_string()) {
|
||||
return R::ok(images.at("large").get<std::string>());
|
||||
}
|
||||
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."});
|
||||
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
|
||||
@@ -47,8 +48,6 @@ Result<std::string, PreviewLookupError> imageUrlFromCardObject(const nlohmann::j
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
|
||||
// Pokemon TCG search uses an unquoted `number:` clause (e.g. number:4 or
|
||||
// number:TG14). Cards are commonly stored as `4/102`; strip the suffix.
|
||||
std::string s(setNo);
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
@@ -57,68 +56,85 @@ std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
// When both set id and collector number are known, omit name: — Lucene
|
||||
// name∩number intersections can miss even when the print is real, and
|
||||
// collector numbers are unique within a set.
|
||||
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
|
||||
std::string query;
|
||||
if (!setId.empty() && !num.empty()) {
|
||||
query = "set.id:";
|
||||
query += std::string(setId);
|
||||
query += " number:";
|
||||
query += num;
|
||||
} else {
|
||||
query = "name:\"";
|
||||
query += std::string(name);
|
||||
query += "\"";
|
||||
if (!setId.empty()) {
|
||||
query += " set.id:";
|
||||
query += std::string(setId);
|
||||
}
|
||||
if (!num.empty()) {
|
||||
query += " number:";
|
||||
query += num;
|
||||
}
|
||||
}
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
|
||||
rfc3986PercentEncode(query);
|
||||
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 num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
|
||||
std::string id = std::string(setId) + "-" + num;
|
||||
return std::string("https://api.pokemontcg.io/v2/cards/") + rfc3986PercentEncode(id);
|
||||
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::buildDetectSearchUrl(std::string_view name,
|
||||
std::string_view setId) {
|
||||
std::string url = buildSearchUrl(name, setId, "");
|
||||
url += "&select=name,number,rarity,set";
|
||||
url += "&pageSize=50";
|
||||
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) {
|
||||
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);
|
||||
};
|
||||
|
||||
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::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
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.contains("data") || !j.at("data").is_array()) {
|
||||
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
|
||||
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex EN set detail missing 'cards' array."});
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
|
||||
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 imageUrlFromCardObject(data.at(0));
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what()});
|
||||
std::string("TCGdex EN set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,13 +144,48 @@ PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_object()) {
|
||||
return R::err({K::Transient, "Pokemon TCG card response missing 'data' object."});
|
||||
if (!j.is_object()) {
|
||||
return R::err({K::Transient, "TCGdex EN card response is not a JSON object."});
|
||||
}
|
||||
return imageUrlFromCardObject(j.at("data"));
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex EN card has no image."});
|
||||
}
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex EN card image field is not a 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."});
|
||||
}
|
||||
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()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,79 +196,53 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
if (!setId.empty() && !num.empty()) {
|
||||
auto byId = http_.get(buildCardByIdUrl(setId, num));
|
||||
if (!idCanon.empty() && !num.empty()) {
|
||||
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
|
||||
if (byId) {
|
||||
auto img = parseCardByIdResponse(byId.value());
|
||||
if (img) return img;
|
||||
// NotFound (no images) or Transient (schema): fall through to search.
|
||||
// NotFound / Transient schema: fall through to search.
|
||||
}
|
||||
// HTTP failure (404/5xx/offline): fall through to search.
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, setId, setNo);
|
||||
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,
|
||||
@@ -234,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
|
||||
@@ -1,193 +1,168 @@
|
||||
#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_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
void finalizeCatalog(PokemonSetCatalog& catalog) {
|
||||
for (auto& pack : catalog.packs) {
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
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.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"TCGdex EN sets response is not a JSON array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.size());
|
||||
for (const auto& entry : j) {
|
||||
Set s;
|
||||
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));
|
||||
}
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
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 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;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"Pokemon TCG API response missing 'data' array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
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", "");
|
||||
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 PokemonSetSource::buildCardsPageUrl(int page, int pageSize) {
|
||||
return std::string(kCardsEndpoint) + "?select=name,number,set&pageSize=" +
|
||||
std::to_string(pageSize) + "&page=" + std::to_string(page);
|
||||
}
|
||||
|
||||
Result<PokemonSetSource::CardsPageMeta>
|
||||
PokemonSetSource::mergeCardsPage(const std::string& body,
|
||||
PokemonSetCatalog& catalog,
|
||||
const std::vector<Set>& sets) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<CardsPageMeta>::err(
|
||||
"Pokemon TCG cards response missing 'data' array.");
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::string> idToName;
|
||||
idToName.reserve(sets.size());
|
||||
for (const auto& set : sets) {
|
||||
if (!set.id.empty()) idToName.emplace(set.id, set.name);
|
||||
}
|
||||
|
||||
// Index existing packs for multi-page merges.
|
||||
std::unordered_map<std::string, std::size_t> packIndex;
|
||||
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
|
||||
packIndex.emplace(catalog.packs[i].setId, i);
|
||||
}
|
||||
std::vector<std::unordered_set<std::string>> seenByPack(catalog.packs.size());
|
||||
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
|
||||
for (const auto& card : catalog.packs[i].cards) {
|
||||
seenByPack[i].insert(card.setNo);
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& entry : j.at("data")) {
|
||||
const std::string name = entry.value("name", "");
|
||||
const std::string number =
|
||||
PokemonCardPreviewSource::normalizeCollectorNumber(entry.value("number", ""));
|
||||
if (name.empty() || number.empty()) continue;
|
||||
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
if (entry.contains("set") && entry.at("set").is_object()) {
|
||||
setId = entry.at("set").value("id", "");
|
||||
setName = entry.at("set").value("name", "");
|
||||
}
|
||||
if (setId.empty()) continue;
|
||||
if (const auto it = idToName.find(setId); it != idToName.end() && !it->second.empty()) {
|
||||
setName = it->second;
|
||||
}
|
||||
if (setName.empty()) setName = setId;
|
||||
|
||||
auto pit = packIndex.find(setId);
|
||||
if (pit == packIndex.end()) {
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = setId;
|
||||
pack.setName = setName;
|
||||
pack.cards.push_back(PokemonCatalogCard{number, name});
|
||||
packIndex.emplace(setId, catalog.packs.size());
|
||||
seenByPack.emplace_back(std::unordered_set<std::string>{number});
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::size_t idx = pit->second;
|
||||
if (!seenByPack[idx].insert(number).second) continue;
|
||||
if (catalog.packs[idx].setName.empty() && !setName.empty()) {
|
||||
catalog.packs[idx].setName = setName;
|
||||
}
|
||||
catalog.packs[idx].cards.push_back(PokemonCatalogCard{number, name});
|
||||
}
|
||||
|
||||
CardsPageMeta meta;
|
||||
meta.page = j.value("page", 1);
|
||||
meta.pageSize = j.value("pageSize", kCardsPageSize);
|
||||
meta.count = j.value("count", static_cast<int>(j.at("data").size()));
|
||||
meta.totalCount = j.value("totalCount", meta.count);
|
||||
return Result<CardsPageMeta>::ok(meta);
|
||||
} catch (const std::exception& e) {
|
||||
return Result<CardsPageMeta>::err(
|
||||
std::string("Pokemon TCG cards JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalog> PokemonSetSource::parseCatalog(const std::string& body,
|
||||
const std::vector<Set>& sets) {
|
||||
PokemonSetCatalog catalog;
|
||||
auto meta = mergeCardsPage(body, catalog, sets);
|
||||
if (!meta) return Result<PokemonSetCatalog>::err(meta.error());
|
||||
finalizeCatalog(catalog);
|
||||
return Result<PokemonSetCatalog>::ok(std::move(catalog));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::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());
|
||||
|
||||
PokemonSetCatalog catalog;
|
||||
int page = 1;
|
||||
int totalCount = 0;
|
||||
int fetched = 0;
|
||||
for (;;) {
|
||||
auto cardsResp = http_.get(buildCardsPageUrl(page));
|
||||
if (!cardsResp) return Result<FetchWithCatalog>::err(cardsResp.error());
|
||||
auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value());
|
||||
if (!meta) return Result<FetchWithCatalog>::err(meta.error());
|
||||
|
||||
fetched += meta.value().count;
|
||||
totalCount = meta.value().totalCount;
|
||||
if (meta.value().count <= 0 || fetched >= totalCount) break;
|
||||
++page;
|
||||
// Safety: avoid unbounded loops if the API lies about totals.
|
||||
if (page > 10000) {
|
||||
return Result<FetchWithCatalog>::err(
|
||||
"Pokemon TCG cards pagination exceeded safety limit.");
|
||||
}
|
||||
}
|
||||
|
||||
finalizeCatalog(catalog);
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.sets = std::move(sets);
|
||||
out.catalog = std::move(catalog);
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
@@ -78,7 +79,8 @@ void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
|
||||
void sortPackCards(PokemonSetCatalogPack& pack) {
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -166,7 +166,9 @@ Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& b
|
||||
const auto existing = build.slotIndex.find(slot);
|
||||
if (existing == build.slotIndex.end()) {
|
||||
build.slotIndex.emplace(slot, build.cards.size());
|
||||
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
|
||||
const std::string setRarity(
|
||||
trimAsciiSpaces(printing.value("set_rarity", "")));
|
||||
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName, setRarity});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -175,6 +177,12 @@ Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& b
|
||||
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", "")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 1–42 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#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>
|
||||
@@ -12,7 +14,12 @@ namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
|
||||
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;
|
||||
@@ -29,6 +36,10 @@ std::string normalizeForRegion(PokemonRegion region, std::string_view 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) {
|
||||
@@ -39,7 +50,13 @@ OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
|
||||
if (card.set.id.empty()) continue;
|
||||
const std::string setNo = normalizeForRegion(region, card.setNo);
|
||||
if (setNo.empty()) continue;
|
||||
out[card.set.id].insert(setNo);
|
||||
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;
|
||||
}
|
||||
@@ -54,20 +71,21 @@ computeForCatalog(const std::vector<PokemonCard>& collection,
|
||||
std::vector<PokemonSetCompletionProgress> out;
|
||||
out.reserve(owned.size());
|
||||
|
||||
for (const auto& [setId, ownedNos] : owned) {
|
||||
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() && ownedNos.count(catalogNo) != 0) ++matched;
|
||||
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));
|
||||
@@ -142,6 +160,10 @@ computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
|
||||
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);
|
||||
});
|
||||
@@ -157,14 +179,18 @@ pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
|
||||
std::optional<Language> languageFilter) {
|
||||
const PokemonSetCatalog& catalog =
|
||||
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
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;
|
||||
if (card.set.id != setId) 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);
|
||||
}
|
||||
@@ -181,7 +207,8 @@ pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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/`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -20,28 +20,37 @@ 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 set’s `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` (by id) and `https://api.pokemontcg.io/v2/cards?q=...` (search)
|
||||
Used by `PokemonCardPreviewSource` in two ways:
|
||||
|
||||
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/cards/{setId}-{number}` (single-card `data` object) — same idea as Asia’s direct localId fetch — so Lucene `name:` ∩ `number:` misses cannot blank the preview after Auto-detect fills Set #. On HTTP failure or missing images, falls back to a name-less search `set.id:… number:…` (collector numbers are unique within a set). When Set # or set id is missing, keeps the older `name:"…"` search with optional `set.id` / `number`. The parser takes `images.large` first and falls back to `images.small`.
|
||||
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 search 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 picker’s **`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 card’s `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 and card-id path expect only the printed-number component (unquoted `number:4` / `number:TG14`; do not wrap alphanumeric numbers in Lucene quotes when combining with other clauses — that has been observed to 500 on the live API).
|
||||
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/sets` (same as before)
|
||||
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-west.json` from a paginated `/v2/cards?select=name,number,set&pageSize=250` dump
|
||||
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 set’s detail `cards[]` (`localId` → `setNo`, `name` → name)
|
||||
|
||||
Each catalog pack stores `id` (pokemontcg.io set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by the API `number` field (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`, matching `card.set.id`, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts.
|
||||
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.
|
||||
|
||||
@@ -91,6 +100,8 @@ 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 print’s 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. Yugipedia’s 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)
|
||||
@@ -100,10 +111,41 @@ YGOPRODeck publishes rate limits and asks clients to cache responses and avoid a
|
||||
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 }` drawn from each card’s `card_sets[]`. 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.
|
||||
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 card’s `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 | 1–42 |
|
||||
| `ban2` | 2nd Generation | 43–88 |
|
||||
| `ban3` | 3rd Generation | 89–118 |
|
||||
| `banpromo-j` | Jump Promos | J1–J3 |
|
||||
| `banpromo-ta` | Toei Promos | TA1–TA2 |
|
||||
| `bansealdass` | Sealdass | 1–42 |
|
||||
|
||||
`fetchAll()` returns that manifest (offline — no HTTP). `fetchAllWithCatalog()` additionally `GET`s each set’s 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).
|
||||
@@ -152,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)
|
||||
|
||||
@@ -226,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 (PMCG1–PMCG6, neo1–neo4, VS1, web1, E1–E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name.
|
||||
@@ -294,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.
|
||||
|
||||
@@ -310,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."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 407 KiB |
+4
-2
@@ -19,8 +19,10 @@
|
||||
- `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. `parseCatalog` / `mergeCardsPage` for set-completion checklists. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
|
||||
- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (name-less `set.id`+`number` when both present; `name:` when Set # empty; collector-number `4/102` -> `4` normalization), `buildCardByIdUrl`, `parseResponse` / `parseCardByIdResponse`, and `fetchImageUrl` (card-by-id first, search fallback) 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`.
|
||||
|
||||
@@ -19,13 +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
|
||||
@@ -37,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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+143
-3
@@ -9,6 +9,8 @@
|
||||
#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"
|
||||
|
||||
@@ -31,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);
|
||||
|
||||
@@ -77,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);
|
||||
@@ -218,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") {
|
||||
@@ -270,26 +317,119 @@ TEST_SUITE("DigiBattle99SetCatalog JSON") {
|
||||
}
|
||||
}
|
||||
|
||||
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"});
|
||||
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician"});
|
||||
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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,186 +24,113 @@ public:
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
|
||||
TEST_CASE("name and setId produce a percent-encoded query") {
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Pikachu", "base1", "");
|
||||
CHECK(url.find("https://api.pokemontcg.io/v2/cards?q=") == 0);
|
||||
CHECK(url.find("%22Pikachu%22") != std::string::npos);
|
||||
CHECK(url.find("set.id%3Abase1") != std::string::npos);
|
||||
// No number term when setNo is empty.
|
||||
CHECK(url.find("number") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("setId plus setNo omits name to avoid Lucene name-number misses") {
|
||||
TEST_CASE("setId plus setNo uses localId and set.id filters without name") {
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Charizard", "base1", "4");
|
||||
CHECK(url.find("number%3A4") != std::string::npos);
|
||||
CHECK(url.find("set.id%3Abase1") != std::string::npos);
|
||||
CHECK(url.find("name") == std::string::npos);
|
||||
CHECK(url.find("Charizard") == std::string::npos);
|
||||
CHECK(url.find("https://api.tcgdex.net/v2/en/cards?") == 0);
|
||||
CHECK(url.find("set.id=eq:base1") != std::string::npos);
|
||||
CHECK(url.find("localId=eq:4") != std::string::npos);
|
||||
CHECK(url.find("name=") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("legacy swsh12tg is canonicalized to swsh12.5tg") {
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Pikachu", "swsh12tg", "TG14");
|
||||
CHECK(url.find("set.id=eq:swsh12.5tg") != std::string::npos);
|
||||
CHECK(url.find("localId=eq:TG14") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("setNo with a slash is normalized to the printed number") {
|
||||
// Pokemon collection numbers are commonly stored as "4/102" — the
|
||||
// Pokemon TCG search API only accepts the printed-number portion.
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Charizard", "base1", "4/102");
|
||||
CHECK(url.find("number%3A4") != std::string::npos);
|
||||
CHECK(url.find("localId=eq:4") != std::string::npos);
|
||||
CHECK(url.find("102") == std::string::npos);
|
||||
CHECK(url.find("name") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("name with spaces is percent-encoded") {
|
||||
TEST_CASE("name-only search percent-encodes the name") {
|
||||
const auto url = PokemonCardPreviewSource::buildSearchUrl(
|
||||
"Mr. Mime", "base1", "");
|
||||
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("empty setId keeps name and appends number") {
|
||||
const auto url =
|
||||
PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25");
|
||||
CHECK(url.find("set.id") == std::string::npos);
|
||||
CHECK(url.find("%22Pikachu%22") != std::string::npos);
|
||||
CHECK(url.find("number%3A25") != std::string::npos);
|
||||
CHECK(url.find("name=eq:Mr.%20Mime") != std::string::npos);
|
||||
CHECK(url.find("set.id=eq:base1") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::buildCardByIdUrl") {
|
||||
TEST_CASE("joins setId and normalized number with a hyphen") {
|
||||
const auto url = PokemonCardPreviewSource::buildCardByIdUrl("base1", "4");
|
||||
CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4");
|
||||
CHECK(url == "https://api.tcgdex.net/v2/en/cards/base1-4");
|
||||
}
|
||||
|
||||
TEST_CASE("percent-encodes alphanumeric collector numbers") {
|
||||
TEST_CASE("canonicalizes legacy set ids") {
|
||||
const auto url =
|
||||
PokemonCardPreviewSource::buildCardByIdUrl("swsh12tg", "TG14");
|
||||
CHECK(url == "https://api.pokemontcg.io/v2/cards/swsh12tg-TG14");
|
||||
CHECK(url == "https://api.tcgdex.net/v2/en/cards/swsh12.5tg-TG14");
|
||||
}
|
||||
|
||||
TEST_CASE("strips slash form before building the id") {
|
||||
const auto url =
|
||||
PokemonCardPreviewSource::buildCardByIdUrl("base1", "4/102");
|
||||
CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4");
|
||||
CHECK(url == "https://api.tcgdex.net/v2/en/cards/base1-4");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
|
||||
TEST_CASE("returns images.large when present") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{
|
||||
"name": "Pikachu",
|
||||
"images": {
|
||||
"small": "https://images.pokemontcg.io/small.png",
|
||||
"large": "https://images.pokemontcg.io/large.png"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(json);
|
||||
TEST_SUITE("PokemonCardPreviewSource::parseSearchResponse") {
|
||||
TEST_CASE("appends /high.png to the first card image base") {
|
||||
const std::string json = R"([
|
||||
{"id":"base1-25","localId":"25","name":"Pikachu",
|
||||
"image":"https://assets.tcgdex.net/en/base/base1/25"}
|
||||
])";
|
||||
const auto out = PokemonCardPreviewSource::parseSearchResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.pokemontcg.io/large.png");
|
||||
CHECK(out.value() ==
|
||||
"https://assets.tcgdex.net/en/base/base1/25/high.png");
|
||||
}
|
||||
|
||||
TEST_CASE("falls back to images.small when large is absent") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","images":{"small":"https://small.only/img.png"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://small.only/img.png");
|
||||
}
|
||||
|
||||
TEST_CASE("empty data array is classified as NotFound (negative-cacheable)") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":[]})");
|
||||
TEST_CASE("empty array is NotFound") {
|
||||
const auto out = PokemonCardPreviewSource::parseSearchResponse("[]");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("missing data array is classified as Transient (schema deviation)") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(R"({"meta":{}})");
|
||||
TEST_CASE("object shape is Transient") {
|
||||
const auto out = PokemonCardPreviewSource::parseSearchResponse(R"({"data":[]})");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("'data' present but not an array is Transient") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(R"({"data":{}})");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("'images' present but not an object is NotFound") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parseResponse(R"({"data":[{"images":[]}]})");
|
||||
TEST_CASE("cards without image are NotFound") {
|
||||
const auto out = PokemonCardPreviewSource::parseSearchResponse(
|
||||
R"([{"id":"base1-1","localId":"1","name":"X"}])");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("large unusable type falls back to small string") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(R"({
|
||||
"data":[{"images":{"large":123,"small":"https://only.small/img.png"}}]
|
||||
})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://only.small/img.png");
|
||||
}
|
||||
|
||||
TEST_CASE("no usable large or small string yields NotFound") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(R"({
|
||||
"data":[{"images":{"large":null,"small":false}}]
|
||||
})");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("entry without images is classified as NotFound") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse(
|
||||
R"({"data":[{"name":"Pikachu"}]})");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON is classified as Transient") {
|
||||
const auto out = PokemonCardPreviewSource::parseResponse("{not json");
|
||||
TEST_CASE("invalid JSON is Transient") {
|
||||
const auto out = PokemonCardPreviewSource::parseSearchResponse("{not json");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::parseCardByIdResponse") {
|
||||
TEST_CASE("returns images.large from data object") {
|
||||
TEST_CASE("returns image base with /high.png") {
|
||||
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({
|
||||
"data": {
|
||||
"id": "base1-4",
|
||||
"images": {
|
||||
"small": "https://images.pokemontcg.io/small.png",
|
||||
"large": "https://images.pokemontcg.io/large.png"
|
||||
}
|
||||
}
|
||||
"id": "base1-4",
|
||||
"image": "https://assets.tcgdex.net/en/base/base1/4"
|
||||
})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.pokemontcg.io/large.png");
|
||||
CHECK(out.value() == "https://assets.tcgdex.net/en/base/base1/4/high.png");
|
||||
}
|
||||
|
||||
TEST_CASE("falls back to images.small when large is absent") {
|
||||
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({
|
||||
"data": {"images":{"small":"https://small.only/img.png"}}
|
||||
})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://small.only/img.png");
|
||||
}
|
||||
|
||||
TEST_CASE("missing images is NotFound") {
|
||||
TEST_CASE("null image is NotFound") {
|
||||
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(
|
||||
R"({"data":{"id":"base1-4","name":"Charizard"}})");
|
||||
R"({"id":"base1-4","image":null})");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("data array shape is Transient") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parseCardByIdResponse(R"({"data":[]})");
|
||||
TEST_CASE("array shape is Transient") {
|
||||
const auto out = PokemonCardPreviewSource::parseCardByIdResponse("[]");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
@@ -228,15 +155,16 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
|
||||
TEST_CASE("with setNo prefers card-by-id endpoint") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":{"images":{"large":"https://l/by-id.png"}}})";
|
||||
http.body = R"({"id":"base1-25","image":"https://assets.tcgdex.net/en/base/base1/25"})";
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Pikachu", "base1", "25");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://l/by-id.png");
|
||||
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/cards/base1-25");
|
||||
CHECK(out.value() ==
|
||||
"https://assets.tcgdex.net/en/base/base1/25/high.png");
|
||||
CHECK(http.lastUrl == "https://api.tcgdex.net/v2/en/cards/base1-25");
|
||||
}
|
||||
|
||||
TEST_CASE("falls back to name-less search when card-by-id HTTP fails") {
|
||||
TEST_CASE("falls back to search when card-by-id HTTP fails") {
|
||||
class RoutingHttp final : public IHttpClient {
|
||||
public:
|
||||
int calls = 0;
|
||||
@@ -244,250 +172,130 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
++calls;
|
||||
if (url.find("/v2/cards?") == std::string::npos) {
|
||||
if (url.find("/v2/en/cards?") == std::string::npos) {
|
||||
return Result<std::string>::err("HTTP 404 from card id");
|
||||
}
|
||||
return Result<std::string>::ok(
|
||||
R"({"data":[{"images":{"large":"https://l/search.png"}}]})");
|
||||
R"([{"id":"base1-4","localId":"4","name":"Charizard",
|
||||
"image":"https://assets.tcgdex.net/en/base/base1/4"}])");
|
||||
}
|
||||
} http;
|
||||
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Charizard", "base1", "4");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://l/search.png");
|
||||
CHECK(out.value() ==
|
||||
"https://assets.tcgdex.net/en/base/base1/4/high.png");
|
||||
CHECK(http.calls == 2);
|
||||
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("number%3A4") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("name") == std::string::npos);
|
||||
CHECK(http.lastUrl.find("set.id=eq:base1") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("localId=eq:4") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("empty setNo uses name search without card-by-id") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":[{"images":{"large":"https://l/x.png"}}]})";
|
||||
http.body = R"([{"id":"base1-25","localId":"25","name":"Pikachu",
|
||||
"image":"https://assets.tcgdex.net/en/base/base1/25"}])";
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Pikachu", "base1", "");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://l/x.png");
|
||||
CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("/v2/cards/base1-") == std::string::npos);
|
||||
CHECK(http.lastUrl.find("name=eq:Pikachu") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("/v2/en/cards/base1-") == std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
const char* kCharizardSwsh4 = R"({
|
||||
"data": [
|
||||
const char* kCharizardSwsh4Detail = R"({
|
||||
"id": "swsh4",
|
||||
"name": "Vivid Voltage",
|
||||
"cards": [
|
||||
{
|
||||
"id": "swsh4-25",
|
||||
"localId": "25",
|
||||
"name": "Charizard",
|
||||
"number": "25",
|
||||
"rarity": "Rare",
|
||||
"set": {
|
||||
"id": "swsh4",
|
||||
"name": "Vivid Voltage",
|
||||
"printedTotal": 185
|
||||
}
|
||||
"image": "https://assets.tcgdex.net/en/swsh/swsh4/25"
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
const char* kMultiVariantPayload = R"({
|
||||
"data": [
|
||||
{
|
||||
"name": "Pikachu",
|
||||
"number": "25",
|
||||
"rarity": "Common",
|
||||
"set": {"id": "base1", "printedTotal": 102}
|
||||
},
|
||||
{
|
||||
"name": "Pikachu",
|
||||
"number": "58",
|
||||
"rarity": "Rare",
|
||||
"set": {"id": "base1", "printedTotal": 102}
|
||||
},
|
||||
{
|
||||
"name": "Pikachu",
|
||||
"number": "25",
|
||||
"rarity": "Common",
|
||||
"set": {"id": "base2", "printedTotal": 64}
|
||||
}
|
||||
const char* kMultiVariantDetail = R"({
|
||||
"id": "base1",
|
||||
"cards": [
|
||||
{"localId":"25","name":"Pikachu","rarity":"Common"},
|
||||
{"localId":"58","name":"Pikachu","rarity":"Rare"},
|
||||
{"localId":"1","name":"Alakazam","rarity":"Rare"}
|
||||
]
|
||||
})";
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") {
|
||||
TEST_CASE("maps API number into setNo without printedTotal suffix") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Charizard");
|
||||
TEST_CASE("maps localId into setNo from set detail") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(
|
||||
kCharizardSwsh4Detail, "swsh4", "Charizard");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().setNo == "25");
|
||||
CHECK(out.value().front().rarity == "Rare");
|
||||
}
|
||||
|
||||
TEST_CASE("filters by set id and keeps multiple numbers in the same set") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base1", "Pikachu");
|
||||
TEST_CASE("filters by card name within the set") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(
|
||||
kMultiVariantDetail, "base1", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].setNo == "25");
|
||||
CHECK(out.value()[1].setNo == "58");
|
||||
}
|
||||
|
||||
TEST_CASE("wrong set id yields explicit error when name and set are supplied") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "base1", "Charizard");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
TEST_CASE("wrong card name is filtered out") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants(kCharizardSwsh4, "swsh4", "Blastoise");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
TEST_CASE("empty data array yields error") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants(R"({"data":[]})", "base1", "Pikachu");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
|
||||
TEST_CASE("name-only payload still filters to requested set id") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants(kMultiVariantPayload, "base2", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().setNo == "25");
|
||||
}
|
||||
|
||||
TEST_CASE("keeps bare number when printedTotal is zero") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
|
||||
"data": [
|
||||
{
|
||||
"name": "Promo",
|
||||
"number": "7",
|
||||
"rarity": "Promo",
|
||||
"set": {"id": "promo1", "printedTotal": 0}
|
||||
}
|
||||
]
|
||||
})",
|
||||
"promo1", "Promo");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().setNo == "7");
|
||||
}
|
||||
|
||||
TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
|
||||
"data":[
|
||||
{"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"},
|
||||
{"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}}
|
||||
]
|
||||
})",
|
||||
"base1", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().setNo == "26");
|
||||
}
|
||||
|
||||
TEST_CASE("empty setId skips set filter and collects prints across sets") {
|
||||
const char* crossSet = R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}},
|
||||
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("empty wanted card name skips name filter within the set") {
|
||||
const char* twoInSet = R"({
|
||||
"data": [
|
||||
{"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}},
|
||||
{"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", "");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2u);
|
||||
}
|
||||
|
||||
TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({
|
||||
"data": [
|
||||
{"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}}
|
||||
]
|
||||
})",
|
||||
"base1", "Pikachu");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
TEST_CASE("no matches with empty setId yields generic no matching cards message") {
|
||||
TEST_CASE("wrong card name yields error") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(
|
||||
R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})",
|
||||
"",
|
||||
"Nobody");
|
||||
kCharizardSwsh4Detail, "swsh4", "Blastoise");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Pokemon TCG returned no matching cards.");
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON in parsePrintVariants yields parse error") {
|
||||
const auto out =
|
||||
PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu");
|
||||
TEST_CASE("missing cards array is an error") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(
|
||||
R"({"id":"base1"})", "base1", "Pikachu");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("empty wanted name collects all prints in the set") {
|
||||
const auto out = PokemonCardPreviewSource::parsePrintVariants(
|
||||
kMultiVariantDetail, "base1", "");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 3u);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
|
||||
TEST_CASE("supports auto-detect and returns first print") {
|
||||
TEST_CASE("supports auto-detect and returns first print from set detail") {
|
||||
FixedHttpClient http;
|
||||
http.body = kCharizardSwsh4;
|
||||
http.body = kCharizardSwsh4Detail;
|
||||
PokemonCardPreviewSource src{http};
|
||||
CHECK(src.supportsAutoDetectPrint());
|
||||
const auto first = src.detectFirstPrint("Charizard", "swsh4");
|
||||
REQUIRE(first.isOk());
|
||||
CHECK(first.value().setNo == "25");
|
||||
CHECK(http.lastUrl == "https://api.tcgdex.net/v2/en/sets/swsh4");
|
||||
}
|
||||
|
||||
TEST_CASE("uses slim set-scoped search URL without number clause") {
|
||||
FixedHttpClient http;
|
||||
http.body = kCharizardSwsh4;
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.detectPrintVariants("Charizard", "swsh4");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(http.lastUrl.find("number%3A") == std::string::npos);
|
||||
CHECK(http.lastUrl.find("set.id%3Aswsh4") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("select=name,number,rarity,set") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("pageSize=50") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("buildDetectSearchUrl requests only parser fields") {
|
||||
const auto url = PokemonCardPreviewSource::buildDetectSearchUrl("Charizard", "swsh4");
|
||||
CHECK(url.find("select=name,number,rarity,set") != std::string::npos);
|
||||
CHECK(url.find("pageSize=50") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("retries name-only query when the set-scoped request fails") {
|
||||
TEST_CASE("falls back to cards search when set detail fails") {
|
||||
class FallbackHttpClient final : public IHttpClient {
|
||||
public:
|
||||
int calls = 0;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
++calls;
|
||||
if (calls == 1) return Result<std::string>::err("offline");
|
||||
if (url.find("set.id") != std::string::npos) {
|
||||
return Result<std::string>::err("unexpected set-scoped retry");
|
||||
if (std::string(url).find("/sets/") != std::string::npos) {
|
||||
return Result<std::string>::err("offline");
|
||||
}
|
||||
return Result<std::string>::ok(kMultiVariantPayload);
|
||||
return Result<std::string>::ok(R"([
|
||||
{"id":"base1-25","localId":"25","name":"Pikachu","rarity":"Common"},
|
||||
{"id":"base1-58","localId":"58","name":"Pikachu","rarity":"Rare"}
|
||||
])");
|
||||
}
|
||||
} http;
|
||||
|
||||
@@ -498,7 +306,7 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
|
||||
CHECK(http.calls == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") {
|
||||
TEST_CASE("surfaces search HTTP error when set detail and search fail") {
|
||||
class AlwaysFailHttp final : public IHttpClient {
|
||||
public:
|
||||
int calls = 0;
|
||||
@@ -514,13 +322,79 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") {
|
||||
CHECK(out.error() == "offline");
|
||||
CHECK(http.calls == 2);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") {
|
||||
TEST_SUITE("PokemonCardPreviewSource::detectVariantsBySetNo") {
|
||||
TEST_CASE("card-by-id fills name from TCGdex response") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})";
|
||||
http.body = R"({
|
||||
"id":"base1-4",
|
||||
"localId":"4",
|
||||
"name":"Charmander",
|
||||
"rarity":"Common",
|
||||
"image":"https://assets.tcgdex.net/en/base/base1/4"
|
||||
})";
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.detectFirstPrint("Promo", "promo1");
|
||||
const auto out = src.detectVariantsBySetNo("base1", "4");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].name == "Charmander");
|
||||
CHECK(out.value()[0].setNo == "4");
|
||||
CHECK(out.value()[0].rarity == "Common");
|
||||
CHECK(http.lastUrl.find("/v2/en/cards/base1-4") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("search fallback rejects localIds that only share a digit prefix") {
|
||||
struct ScriptedHttp : IHttpClient {
|
||||
int n = 0;
|
||||
Result<std::string> get(std::string_view) override {
|
||||
++n;
|
||||
if (n == 1) {
|
||||
return Result<std::string>::err("not found");
|
||||
}
|
||||
// Fuzzy search returns both "14" and "4"; only "4" may be kept.
|
||||
return Result<std::string>::ok(R"([
|
||||
{"id":"base1-14","localId":"14","name":"Wrong","rarity":"Common"},
|
||||
{"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"}
|
||||
])");
|
||||
}
|
||||
} http;
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.detectVariantsBySetNo("base1", "4");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].name == "Charmander");
|
||||
CHECK(out.value()[0].setNo == "4");
|
||||
}
|
||||
|
||||
TEST_CASE("card-by-id response with mismatched localId falls through to search") {
|
||||
struct ScriptedHttp : IHttpClient {
|
||||
int n = 0;
|
||||
Result<std::string> get(std::string_view) override {
|
||||
++n;
|
||||
if (n == 1) {
|
||||
return Result<std::string>::ok(R"({
|
||||
"id":"base1-14","localId":"14","name":"Wrong","rarity":"Rare"
|
||||
})");
|
||||
}
|
||||
return Result<std::string>::ok(R"([
|
||||
{"id":"base1-4","localId":"4","name":"Charmander","rarity":"Common"}
|
||||
])");
|
||||
}
|
||||
} http;
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.detectVariantsBySetNo("base1", "4");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].name == "Charmander");
|
||||
CHECK(out.value()[0].setNo == "4");
|
||||
}
|
||||
|
||||
TEST_CASE("empty set id is rejected") {
|
||||
FixedHttpClient http;
|
||||
PokemonCardPreviewSource src{http};
|
||||
const auto out = src.detectVariantsBySetNo("", "4");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error() == "Could not auto-detect set print metadata.");
|
||||
CHECK(out.error().find("set") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
PokemonCard makeWest(std::string setId, std::string setName, std::string releaseDate) {
|
||||
PokemonCard c;
|
||||
c.id = 1;
|
||||
c.name = "Card";
|
||||
c.region = PokemonRegion::West;
|
||||
c.set.id = std::move(setId);
|
||||
c.set.name = std::move(setName);
|
||||
c.set.releaseDate = std::move(releaseDate);
|
||||
c.setNo = "1";
|
||||
c.language = Language::English;
|
||||
return c;
|
||||
}
|
||||
|
||||
PokemonCard makeAsia(std::string setId, std::string setName, std::string releaseDate) {
|
||||
PokemonCard c = makeWest(std::move(setId), std::move(setName), std::move(releaseDate));
|
||||
c.region = PokemonRegion::Asia;
|
||||
c.language = Language::Japanese;
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("syncPokemonCollectionSets") {
|
||||
TEST_CASE("migrates West legacy set id and refreshes name/date") {
|
||||
std::vector<PokemonCard> cards{
|
||||
makeWest("sv1", "Old Name", "2000/01/01"),
|
||||
};
|
||||
const std::vector<Set> west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}};
|
||||
const std::vector<Set> asia;
|
||||
|
||||
CHECK(syncPokemonCollectionSets(cards, west, asia) == 1);
|
||||
CHECK(cards[0].set.id == "sv01");
|
||||
CHECK(cards[0].set.name == "Scarlet & Violet");
|
||||
CHECK(cards[0].set.releaseDate == "2023/03/31");
|
||||
}
|
||||
|
||||
TEST_CASE("Asia cards refresh metadata without West id aliases") {
|
||||
std::vector<PokemonCard> cards{
|
||||
makeAsia("sv1", "Old", "2000/01/01"),
|
||||
};
|
||||
const std::vector<Set> west{Set{"sv01", "Scarlet & Violet", "2023/03/31"}};
|
||||
const std::vector<Set> asia{Set{"sv1", "Asia Set", "2023/01/20"}};
|
||||
|
||||
CHECK(syncPokemonCollectionSets(cards, west, asia) == 1);
|
||||
CHECK(cards[0].set.id == "sv1");
|
||||
CHECK(cards[0].set.name == "Asia Set");
|
||||
CHECK(cards[0].set.releaseDate == "2023/01/20");
|
||||
}
|
||||
|
||||
TEST_CASE("unchanged cards are not counted") {
|
||||
std::vector<PokemonCard> cards{
|
||||
makeWest("base1", "Base Set", "1999/01/09"),
|
||||
};
|
||||
const std::vector<Set> west{Set{"base1", "Base Set", "1999/01/09"}};
|
||||
CHECK(syncPokemonCollectionSets(cards, west, {}) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("unknown set id still migrates when aliased") {
|
||||
std::vector<PokemonCard> cards{makeWest("pgo", "GO", "")};
|
||||
// No matching upstream set — id still migrates.
|
||||
CHECK(syncPokemonCollectionSets(cards, {}, {}) == 1);
|
||||
CHECK(cards[0].set.id == "swsh10.5");
|
||||
CHECK(cards[0].set.name == "GO");
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,34 @@ TEST_SUITE("computePokemonSetCompletion") {
|
||||
CHECK(rows[1].ownedUnique == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("orders packs by releaseDate then setName") {
|
||||
PokemonSetCatalog west;
|
||||
PokemonSetCatalogPack newer;
|
||||
newer.setId = "sv01";
|
||||
newer.setName = "Scarlet & Violet";
|
||||
newer.cards = {{"1", "Sprigatito"}};
|
||||
PokemonSetCatalogPack older;
|
||||
older.setId = "base1";
|
||||
older.setName = "Zoo Set"; // would sort after Scarlet by name
|
||||
older.cards = {{"4", "Charizard"}};
|
||||
west.packs.push_back(std::move(newer));
|
||||
west.packs.push_back(std::move(older));
|
||||
PokemonSetCatalog emptyAsia;
|
||||
|
||||
PokemonCard base = makeOwned(PokemonRegion::West, "base1", "4");
|
||||
base.set.releaseDate = "1999/01/09";
|
||||
PokemonCard sv = makeOwned(PokemonRegion::West, "sv01", "1");
|
||||
sv.id = 2;
|
||||
sv.set.releaseDate = "2023/03/31";
|
||||
|
||||
const auto rows = computePokemonSetCompletion({base, sv}, west, emptyAsia);
|
||||
REQUIRE(rows.size() == 2);
|
||||
CHECK(rows[0].setId == "base1");
|
||||
CHECK(rows[0].releaseDate == "1999/01/09");
|
||||
CHECK(rows[1].setId == "sv01");
|
||||
CHECK(rows[1].releaseDate == "2023/03/31");
|
||||
}
|
||||
|
||||
TEST_CASE("region filter isolates catalogs") {
|
||||
const auto west = westCatalog();
|
||||
const auto asia = asiaCatalog();
|
||||
@@ -127,6 +155,28 @@ TEST_SUITE("computePokemonSetCompletion") {
|
||||
CHECK(rows[0].ownedUnique == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("legacy pokemontcg West set id matches TCGdex catalog pack") {
|
||||
PokemonSetCatalog west;
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = "sv01";
|
||||
pack.setName = "Scarlet & Violet";
|
||||
pack.cards = {{"6", "Charizard"}};
|
||||
west.packs.push_back(std::move(pack));
|
||||
PokemonSetCatalog emptyAsia;
|
||||
std::vector<PokemonCard> collection{
|
||||
makeOwned(PokemonRegion::West, "sv1", "6"),
|
||||
};
|
||||
const auto rows = computePokemonSetCompletion(collection, west, emptyAsia);
|
||||
REQUIRE(rows.size() == 1);
|
||||
CHECK(rows[0].setId == "sv01");
|
||||
CHECK(rows[0].ownedUnique == 1);
|
||||
|
||||
const auto checklist = pokemonChecklistForSet(
|
||||
collection, west, emptyAsia, PokemonRegion::West, "sv01");
|
||||
REQUIRE(checklist.size() == 1);
|
||||
CHECK(checklist[0].owned);
|
||||
}
|
||||
|
||||
TEST_CASE("amount does not inflate unique ownership") {
|
||||
const auto west = westCatalog();
|
||||
PokemonSetCatalog emptyAsia;
|
||||
@@ -182,6 +232,30 @@ TEST_SUITE("pokemonChecklistForSet") {
|
||||
CHECK(list[2].owned == false);
|
||||
}
|
||||
|
||||
TEST_CASE("orders unpadded set numbers numerically") {
|
||||
PokemonSetCatalog west;
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = "base1";
|
||||
pack.setName = "Base";
|
||||
// Insert out of order / in lex-favoring order to prove we re-sort.
|
||||
pack.cards = {
|
||||
{"100", "Lightning Energy"},
|
||||
{"1", "Alakazam"},
|
||||
{"10", "Mewtwo"},
|
||||
{"2", "Blastoise"},
|
||||
};
|
||||
west.packs.push_back(std::move(pack));
|
||||
PokemonSetCatalog emptyAsia;
|
||||
|
||||
const auto list = pokemonChecklistForSet({}, west, emptyAsia,
|
||||
PokemonRegion::West, "base1");
|
||||
REQUIRE(list.size() == 4);
|
||||
CHECK(list[0].setNo == "1");
|
||||
CHECK(list[1].setNo == "2");
|
||||
CHECK(list[2].setNo == "10");
|
||||
CHECK(list[3].setNo == "100");
|
||||
}
|
||||
|
||||
TEST_CASE("asia card does not mark west checklist") {
|
||||
const auto west = westCatalog();
|
||||
const auto asia = asiaCatalog();
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
@@ -19,59 +22,93 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
class RoutingHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string listBody;
|
||||
std::unordered_map<std::string, std::string> byUrl;
|
||||
std::string lastUrl;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
if (lastUrl == PokemonSetSource::kListEndpoint) {
|
||||
return Result<std::string>::ok(listBody);
|
||||
}
|
||||
const auto it = byUrl.find(lastUrl);
|
||||
if (it == byUrl.end()) return Result<std::string>::err("missing route");
|
||||
return Result<std::string>::ok(it->second);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("PokemonSetSource::parseResponse") {
|
||||
TEST_CASE("happy path: maps id/name/releaseDate without rewriting separators") {
|
||||
// The Pokemon TCG API returns releaseDate already in YYYY/MM/DD form,
|
||||
// unlike Scryfall's released_at YYYY-MM-DD.
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"id":"base1","name":"Base","releaseDate":"1999/01/09"},
|
||||
{"id":"jungle","name":"Jungle","releaseDate":"1999/06/16"}
|
||||
]
|
||||
})";
|
||||
TEST_SUITE("PokemonSetSource::parseListResponse") {
|
||||
TEST_CASE("happy path: maps id/name from top-level array") {
|
||||
const std::string json = R"([
|
||||
{"id":"base1","name":"Base Set","cardCount":{"total":102,"official":102}},
|
||||
{"id":"base2","name":"Jungle","cardCount":{"total":64,"official":64}}
|
||||
])";
|
||||
|
||||
const auto out = PokemonSetSource::parseResponse(json);
|
||||
const auto out = PokemonSetSource::parseListResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].id == "base1");
|
||||
CHECK(out.value()[0].name == "Base");
|
||||
CHECK(out.value()[0].releaseDate == "1999/01/09");
|
||||
CHECK(out.value()[1].id == "jungle");
|
||||
CHECK(out.value()[1].releaseDate == "1999/06/16");
|
||||
CHECK(out.value()[0].name == "Base Set");
|
||||
CHECK(out.value()[0].releaseDate.empty());
|
||||
CHECK(out.value()[1].id == "base2");
|
||||
}
|
||||
|
||||
TEST_CASE("sorts by release date ascending") {
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"id":"newer","name":"N","releaseDate":"2024/01/01"},
|
||||
{"id":"older","name":"O","releaseDate":"2010/01/01"}
|
||||
]
|
||||
})";
|
||||
const auto out = PokemonSetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "older");
|
||||
CHECK(out.value().back().id == "newer");
|
||||
}
|
||||
|
||||
TEST_CASE("empty data array returns an empty list (not an error)") {
|
||||
const auto out = PokemonSetSource::parseResponse(R"({"data":[]})");
|
||||
TEST_CASE("empty array returns an empty list (not an error)") {
|
||||
const auto out = PokemonSetSource::parseListResponse("[]");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("missing data array returns an error") {
|
||||
const auto out = PokemonSetSource::parseResponse(R"({"meta":{}})");
|
||||
TEST_CASE("object shape returns an error") {
|
||||
const auto out = PokemonSetSource::parseListResponse(R"({"data":[]})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON returns an error") {
|
||||
const auto out = PokemonSetSource::parseResponse("{not json");
|
||||
const auto out = PokemonSetSource::parseListResponse("{not json");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonSetSource::parseReleaseDate") {
|
||||
TEST_CASE("rewrites YYYY-MM-DD to YYYY/MM/DD") {
|
||||
const auto out = PokemonSetSource::parseReleaseDate(
|
||||
R"({"id":"base1","releaseDate":"1999-01-09"})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "1999/01/09");
|
||||
}
|
||||
|
||||
TEST_CASE("missing releaseDate yields empty string") {
|
||||
const auto out = PokemonSetSource::parseReleaseDate(R"({"id":"base1"})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonSetSource::parseCatalogPackFromSetDetail") {
|
||||
TEST_CASE("builds checklist from cards localId/name and dedupes") {
|
||||
const Set set{"base1", "Base Set", "1999/01/09"};
|
||||
const std::string json = R"({
|
||||
"id":"base1",
|
||||
"name":"Base Set",
|
||||
"cards":[
|
||||
{"id":"base1-4","localId":"4","name":"Charizard"},
|
||||
{"id":"base1-4","localId":"4/102","name":"Charizard"},
|
||||
{"id":"base1-58","localId":"58","name":"Growlithe"}
|
||||
]
|
||||
})";
|
||||
const auto pack = PokemonSetSource::parseCatalogPackFromSetDetail(json, set);
|
||||
REQUIRE(pack.isOk());
|
||||
CHECK(pack.value().setId == "base1");
|
||||
REQUIRE(pack.value().cards.size() == 2);
|
||||
CHECK(pack.value().cards[0].setNo == "4");
|
||||
CHECK(pack.value().cards[1].setNo == "58");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonSetSource::fetchAll") {
|
||||
TEST_CASE("network error is surfaced as a Result error") {
|
||||
FixedHttpClient http;
|
||||
@@ -80,67 +117,42 @@ TEST_SUITE("PokemonSetSource::fetchAll") {
|
||||
CHECK(src.fetchAll().isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("network success is parsed end-to-end and hits the public endpoint") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"({"data":[{"id":"x","name":"X","releaseDate":"2020/01/01"}]})";
|
||||
TEST_CASE("list plus set detail fills release dates and hits EN endpoints") {
|
||||
RoutingHttpClient http;
|
||||
http.listBody = R"([{"id":"base1","name":"Base Set"}])";
|
||||
http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] =
|
||||
R"({"id":"base1","name":"Base Set","releaseDate":"1999-01-09","cards":[]})";
|
||||
PokemonSetSource src{http};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "x");
|
||||
CHECK(out.value().front().releaseDate == "2020/01/01");
|
||||
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets");
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value().front().id == "base1");
|
||||
CHECK(out.value().front().releaseDate == "1999/01/09");
|
||||
CHECK(http.lastUrl == PokemonSetSource::buildSetDetailUrl("base1"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonSetSource::parseCatalog") {
|
||||
TEST_CASE("groups cards by set.id and dedupes collector numbers") {
|
||||
const std::vector<Set> sets{
|
||||
Set{"base1", "Base", "1999/01/09"},
|
||||
Set{"jungle", "Jungle", "1999/06/16"},
|
||||
};
|
||||
const std::string json = R"({
|
||||
"data": [
|
||||
{"name":"Charizard","number":"4","set":{"id":"base1","name":"Base"}},
|
||||
{"name":"Charizard","number":"4/102","set":{"id":"base1","name":"Base"}},
|
||||
{"name":"Growlithe","number":"58","set":{"id":"base1","name":"Base"}},
|
||||
{"name":"Pikachu","number":"60","set":{"id":"jungle","name":"Jungle"}}
|
||||
],
|
||||
"page":1,"pageSize":250,"count":4,"totalCount":4
|
||||
TEST_SUITE("PokemonSetSource::fetchAllWithCatalog") {
|
||||
TEST_CASE("builds catalog packs from set detail cards") {
|
||||
RoutingHttpClient http;
|
||||
http.listBody = R"([{"id":"base1","name":"Base Set"}])";
|
||||
http.byUrl[PokemonSetSource::buildSetDetailUrl("base1")] = R"({
|
||||
"id":"base1",
|
||||
"name":"Base Set",
|
||||
"releaseDate":"1999-01-09",
|
||||
"cards":[
|
||||
{"localId":"4","name":"Charizard"},
|
||||
{"localId":"58","name":"Growlithe"}
|
||||
]
|
||||
})";
|
||||
const auto catalog = PokemonSetSource::parseCatalog(json, sets);
|
||||
REQUIRE(catalog.isOk());
|
||||
REQUIRE(catalog.value().packs.size() == 2);
|
||||
const auto* base = catalog.value().findPack("base1");
|
||||
REQUIRE(base != nullptr);
|
||||
REQUIRE(base->cards.size() == 2);
|
||||
CHECK(base->cards[0].setNo == "4");
|
||||
CHECK(base->cards[1].setNo == "58");
|
||||
const auto* jungle = catalog.value().findPack("jungle");
|
||||
REQUIRE(jungle != nullptr);
|
||||
REQUIRE(jungle->cards.size() == 1);
|
||||
CHECK(jungle->cards[0].setNo == "60");
|
||||
}
|
||||
|
||||
TEST_CASE("mergeCardsPage accumulates across pages") {
|
||||
const std::vector<Set> sets{Set{"base1", "Base", "1999/01/09"}};
|
||||
PokemonSetCatalog catalog;
|
||||
const std::string page1 = R"({
|
||||
"data":[{"name":"A","number":"1","set":{"id":"base1","name":"Base"}}],
|
||||
"page":1,"pageSize":1,"count":1,"totalCount":2
|
||||
})";
|
||||
const std::string page2 = R"({
|
||||
"data":[{"name":"B","number":"2","set":{"id":"base1","name":"Base"}}],
|
||||
"page":2,"pageSize":1,"count":1,"totalCount":2
|
||||
})";
|
||||
REQUIRE(PokemonSetSource::mergeCardsPage(page1, catalog, sets).isOk());
|
||||
REQUIRE(PokemonSetSource::mergeCardsPage(page2, catalog, sets).isOk());
|
||||
REQUIRE(catalog.packs.size() == 1);
|
||||
REQUIRE(catalog.packs[0].cards.size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("buildCardsPageUrl includes select and pagination") {
|
||||
CHECK(PokemonSetSource::buildCardsPageUrl(2) ==
|
||||
"https://api.pokemontcg.io/v2/cards?select=name,number,set&pageSize=250&page=2");
|
||||
PokemonSetSource src{http};
|
||||
const auto out = src.fetchAllWithCatalog();
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().sets.size() == 1);
|
||||
REQUIRE(out.value().catalog.packs.size() == 1);
|
||||
const auto* pack = out.value().catalog.findPack("base1");
|
||||
REQUIRE(pack != nullptr);
|
||||
REQUIRE(pack->cards.size() == 2);
|
||||
CHECK(pack->cards[0].setNo == "4");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
TEST_SUITE("canonicalizeWestSetId") {
|
||||
TEST_CASE("identity for ids already on TCGdex") {
|
||||
CHECK(canonicalizeWestSetId("base1") == "base1");
|
||||
CHECK(canonicalizeWestSetId("swsh3") == "swsh3");
|
||||
CHECK(canonicalizeWestSetId("sv01") == "sv01");
|
||||
CHECK(canonicalizeWestSetId("sv10") == "sv10");
|
||||
}
|
||||
|
||||
TEST_CASE("maps Scarlet & Violet divergences") {
|
||||
CHECK(canonicalizeWestSetId("sv1") == "sv01");
|
||||
CHECK(canonicalizeWestSetId("sv3") == "sv03");
|
||||
CHECK(canonicalizeWestSetId("sv3pt5") == "sv03.5");
|
||||
CHECK(canonicalizeWestSetId("sv8pt5") == "sv08.5");
|
||||
CHECK(canonicalizeWestSetId("zsv10pt5") == "sv10.5b");
|
||||
CHECK(canonicalizeWestSetId("rsv10pt5") == "sv10.5w");
|
||||
}
|
||||
|
||||
TEST_CASE("maps SWSH galleries and specials") {
|
||||
CHECK(canonicalizeWestSetId("pgo") == "swsh10.5");
|
||||
CHECK(canonicalizeWestSetId("swsh12tg") == "swsh12.5tg");
|
||||
CHECK(canonicalizeWestSetId("swsh12pt5") == "swsh12.5");
|
||||
CHECK(canonicalizeWestSetId("swsh45") == "swsh4.5");
|
||||
CHECK(canonicalizeWestSetId("cel25c") == "cel25cc");
|
||||
}
|
||||
|
||||
TEST_CASE("maps McDonald's year codes") {
|
||||
CHECK(canonicalizeWestSetId("mcd19") == "2019sm");
|
||||
CHECK(canonicalizeWestSetId("mcd22") == "2022swsh");
|
||||
}
|
||||
|
||||
TEST_CASE("unknown and empty pass through") {
|
||||
CHECK(canonicalizeWestSetId("fut20") == "fut20");
|
||||
CHECK(canonicalizeWestSetId("").empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using ccm::compareSetNoNatural;
|
||||
|
||||
TEST_SUITE("compareSetNoNatural") {
|
||||
TEST_CASE("orders pure digits numerically") {
|
||||
CHECK(compareSetNoNatural("1", "2") < 0);
|
||||
CHECK(compareSetNoNatural("2", "10") < 0);
|
||||
CHECK(compareSetNoNatural("10", "100") < 0);
|
||||
CHECK(compareSetNoNatural("2", "1") > 0);
|
||||
CHECK(compareSetNoNatural("10", "2") > 0);
|
||||
}
|
||||
|
||||
TEST_CASE("leading zeros tie numerically then lex") {
|
||||
CHECK(compareSetNoNatural("001", "1") != 0);
|
||||
CHECK(compareSetNoNatural("1", "001") > 0); // "001" < "1" lexicographically
|
||||
CHECK(compareSetNoNatural("001", "002") < 0);
|
||||
CHECK(compareSetNoNatural("001", "001") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("alpha prefix then numeric run") {
|
||||
CHECK(compareSetNoNatural("SWSH001", "SWSH002") < 0);
|
||||
CHECK(compareSetNoNatural("SWSH10", "SWSH2") > 0);
|
||||
CHECK(compareSetNoNatural("A10", "B2") < 0);
|
||||
}
|
||||
|
||||
TEST_CASE("sorts base-set style list into numeric order") {
|
||||
std::vector<std::string> nos{"1", "10", "100", "2", "20", "3"};
|
||||
std::sort(nos.begin(), nos.end(), [](const std::string& a, const std::string& b) {
|
||||
return compareSetNoNatural(a, b) < 0;
|
||||
});
|
||||
CHECK(nos == std::vector<std::string>{"1", "2", "3", "10", "20", "100"});
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public:
|
||||
if (gameId == Game::Pokemon) return "pokemon";
|
||||
if (gameId == Game::YuGiOh) return "yugioh";
|
||||
if (gameId == Game::DigiBattle99) return "digibattle99";
|
||||
if (gameId == Game::YuGiOhBandai) return "yugiohbandai";
|
||||
if (gameId == Game::JapanesePokemon) return "pokemon";
|
||||
return "yugioh";
|
||||
}
|
||||
@@ -182,6 +183,43 @@ TEST_SUITE("SetService") {
|
||||
CHECK(digi.source.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("YuGiOhBandai module routes independently when all games are registered") {
|
||||
InMemSetRepo repo;
|
||||
SetService svc{repo};
|
||||
|
||||
FakeGameModule magic{Game::Magic};
|
||||
magic.source.result = Result<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
|
||||
FakeGameModule pokemon{Game::Pokemon};
|
||||
pokemon.source.result = Result<std::vector<Set>>::ok({{"base1", "Base", "1999/01/09"}});
|
||||
FakeGameModule yugioh{Game::YuGiOh};
|
||||
yugioh.source.result = Result<std::vector<Set>>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}});
|
||||
FakeGameModule digi{Game::DigiBattle99};
|
||||
digi.source.result = Result<std::vector<Set>>::ok(
|
||||
{{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"}});
|
||||
FakeGameModule bandai{Game::YuGiOhBandai};
|
||||
bandai.source.result = Result<std::vector<Set>>::ok(
|
||||
{{"ban1", "1st Generation", "1998/09/01"}});
|
||||
|
||||
svc.registerModule(&magic);
|
||||
svc.registerModule(&pokemon);
|
||||
svc.registerModule(&yugioh);
|
||||
svc.registerModule(&digi);
|
||||
svc.registerModule(&bandai);
|
||||
|
||||
REQUIRE(svc.updateSets(Game::Magic).isOk());
|
||||
REQUIRE(svc.updateSets(Game::Pokemon).isOk());
|
||||
REQUIRE(svc.updateSets(Game::YuGiOh).isOk());
|
||||
REQUIRE(svc.updateSets(Game::DigiBattle99).isOk());
|
||||
const auto out = svc.updateSets(Game::YuGiOhBandai);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "ban1");
|
||||
CHECK(magic.source.calls == 1);
|
||||
CHECK(pokemon.source.calls == 1);
|
||||
CHECK(yugioh.source.calls == 1);
|
||||
CHECK(digi.source.calls == 1);
|
||||
CHECK(bandai.source.calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("JapanesePokemon module routes independently when all games are registered") {
|
||||
InMemSetRepo repo;
|
||||
SetService svc{repo};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
@@ -86,6 +87,15 @@ TEST_SUITE("ygoPrintingSlotsMatch") {
|
||||
CHECK(ygoPrintingSlotsMatch("RA04-001", "RA04-EN001"));
|
||||
}
|
||||
|
||||
TEST_CASE("matches collector numbers ignoring leading zeros") {
|
||||
CHECK(ygoPrintingSlotsMatch("LOB-5", "LOB-005"));
|
||||
CHECK(ygoPrintingSlotsMatch("LOB-EN005", "LOB-5"));
|
||||
CHECK(ygoCollectorDigitsEqual("005", "5"));
|
||||
CHECK(ygoCollectorDigitsEqual("LOB-EN005", "5"));
|
||||
CHECK(ygoCollectorDigitsFromInput("005") == "005");
|
||||
CHECK(ygoCollectorDigitsFromInput("LOB-EN005") == "005");
|
||||
}
|
||||
|
||||
TEST_CASE("detects European alternate numbering suffix E+digit vs EN/DE") {
|
||||
CHECK(ygoLikelyEuropeanRegionalSetCode("LOB-E003"));
|
||||
CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EN005"));
|
||||
@@ -935,3 +945,106 @@ TEST_SUITE("YuGiOhCardPreviewSource::detectFirstPrint") {
|
||||
CHECK(out.error() == "offline");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog") {
|
||||
TEST_CASE("matches digits within pack to card name") {
|
||||
YuGiOhSetCatalog catalog;
|
||||
YuGiOhSetCatalogPack pack;
|
||||
pack.setId = "LOB";
|
||||
pack.setName = "Legend of Blue Eyes White Dragon";
|
||||
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician", "Ultra Rare"});
|
||||
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN001", "Blue-Eyes White Dragon", "Ultra Rare"});
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
|
||||
const auto byDigits =
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "005");
|
||||
REQUIRE(byDigits.isOk());
|
||||
REQUIRE(byDigits.value().size() == 1);
|
||||
CHECK(byDigits.value()[0].name == "Dark Magician");
|
||||
CHECK(byDigits.value()[0].setNo == "LOB-005");
|
||||
CHECK(byDigits.value()[0].rarity == "Ultra Rare");
|
||||
|
||||
const auto byUnpadded =
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "5");
|
||||
REQUIRE(byUnpadded.isOk());
|
||||
REQUIRE(byUnpadded.value().size() == 1);
|
||||
CHECK(byUnpadded.value()[0].name == "Dark Magician");
|
||||
|
||||
const auto byRegionCode =
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "LOB-001");
|
||||
REQUIRE(byRegionCode.isOk());
|
||||
REQUIRE(byRegionCode.value().size() == 1);
|
||||
CHECK(byRegionCode.value()[0].name == "Blue-Eyes White Dragon");
|
||||
}
|
||||
|
||||
TEST_CASE("digits-only 1 does not match collector 011") {
|
||||
YuGiOhSetCatalog catalog;
|
||||
YuGiOhSetCatalogPack pack;
|
||||
pack.setId = "LOB";
|
||||
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
|
||||
pack.cards.push_back(YuGiOhCatalogCard{"LOB-011", "Hitotsu-Me Giant"});
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
|
||||
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "1")
|
||||
.isErr());
|
||||
|
||||
const auto byEleven =
|
||||
YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "11");
|
||||
REQUIRE(byEleven.isOk());
|
||||
REQUIRE(byEleven.value().size() == 1);
|
||||
CHECK(byEleven.value()[0].name == "Hitotsu-Me Giant");
|
||||
}
|
||||
|
||||
TEST_CASE("unknown pack or number returns error") {
|
||||
YuGiOhSetCatalog catalog;
|
||||
YuGiOhSetCatalogPack pack;
|
||||
pack.setId = "LOB";
|
||||
pack.cards.push_back(YuGiOhCatalogCard{"LOB-005", "Dark Magician"});
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
|
||||
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "SDK", "001")
|
||||
.isErr());
|
||||
CHECK(YuGiOhCardPreviewSource::detectVariantsBySetNoFromCatalog(catalog, "LOB", "999")
|
||||
.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("detectVariantsBySetNo falls back to cardset HTTP without catalog") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"({
|
||||
"data":[
|
||||
{"name":"Dark Magician",
|
||||
"card_sets":[
|
||||
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"}
|
||||
]}
|
||||
]
|
||||
})";
|
||||
YuGiOhCardPreviewSource src{http};
|
||||
const auto out = src.detectVariantsBySetNo("Legend of Blue Eyes White Dragon", "5");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].name == "Dark Magician");
|
||||
CHECK(out.value()[0].setNo == "LOB-EN005");
|
||||
CHECK(out.value()[0].rarity == "Ultra Rare");
|
||||
CHECK(http.lastUrl.find("cardset=") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("fname=") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("cardset reverse keeps distinct rarities for the same set code") {
|
||||
const std::string body = R"({
|
||||
"data":[
|
||||
{"name":"Elemental HERO Bubbleman",
|
||||
"card_sets":[
|
||||
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultra Rare"},
|
||||
{"set_name":"Soul of the Duelist","set_code":"SOD-EN015","set_rarity":"Ultimate Rare"}
|
||||
]}
|
||||
]
|
||||
})";
|
||||
const auto out = YuGiOhCardPreviewSource::detectVariantsBySetNoFromCardset(
|
||||
body, "Soul of the Duelist", "15");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].name == "Elemental HERO Bubbleman");
|
||||
CHECK(out.value()[0].rarity == "Ultra Rare");
|
||||
CHECK(out.value()[1].rarity == "Ultimate Rare");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,11 +213,16 @@ TEST_SUITE("YuGiOhSetSource::parseCatalog") {
|
||||
}
|
||||
CHECK(sawBe);
|
||||
CHECK(sawDm);
|
||||
for (const auto& c : lob->cards) {
|
||||
if (c.name == "Blue-Eyes White Dragon") CHECK(c.rarity == "Ultra Rare");
|
||||
if (c.name == "Dark Magician") CHECK(c.rarity == "Ultra Rare");
|
||||
}
|
||||
|
||||
const auto* mrd = out.value().findPack("MRD");
|
||||
REQUIRE(mrd != nullptr);
|
||||
REQUIRE(mrd->cards.size() == 1);
|
||||
CHECK(mrd->cards[0].setNo == "MRD-010");
|
||||
CHECK(mrd->cards[0].rarity == "Ultra Rare");
|
||||
}
|
||||
|
||||
TEST_CASE("missing data array returns error") {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string body;
|
||||
std::string lastUrl;
|
||||
bool fail{false};
|
||||
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
if (fail) return Result<std::string>::err("http fail");
|
||||
return Result<std::string>::ok(body);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") {
|
||||
TEST_CASE("preferredPageTitle picks Bandai / English / Sealdass") {
|
||||
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "ban1", "14") ==
|
||||
"Dark Magician (Bandai)");
|
||||
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Blue-Eyes White Dragon", "ban3",
|
||||
"118") ==
|
||||
"Blue-Eyes White Dragon (English Bandai)");
|
||||
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "bansealdass",
|
||||
"2") ==
|
||||
"Dark Magician (Bandai Sealdass)");
|
||||
}
|
||||
|
||||
TEST_CASE("buildPageImagesUrl encodes spaces as underscores then percent") {
|
||||
const auto url =
|
||||
YuGiOhBandaiCardPreviewSource::buildPageImagesUrl("Dark Magician (Bandai)");
|
||||
CHECK(url.find("titles=Dark_Magician_%28Bandai%29") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("buildAskByNameUrl includes English name constraint") {
|
||||
const auto url = YuGiOhBandaiCardPreviewSource::buildAskByNameUrl("Dark Magician");
|
||||
CHECK(url.find("action=ask") != std::string::npos);
|
||||
CHECK(url.find("query=") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("parsePageImagesResponse returns original source") {
|
||||
const std::string body = R"JSON({
|
||||
"query": {
|
||||
"pages": {
|
||||
"1": {
|
||||
"title": "Dark Magician (Bandai)",
|
||||
"original": {"source": "https://ms.yugipedia.com/d/d0/DarkMagician.png"}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body);
|
||||
REQUIRE(out);
|
||||
CHECK(out.value() == "https://ms.yugipedia.com/d/d0/DarkMagician.png");
|
||||
}
|
||||
|
||||
TEST_CASE("parsePageImagesResponse missing page is NotFound") {
|
||||
const std::string body = R"JSON({
|
||||
"query": { "pages": { "-1": { "missing": true, "title": "Nope" } } }
|
||||
})JSON";
|
||||
auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body);
|
||||
REQUIRE_FALSE(out);
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("parseAskResponse fills setNo rarity name and setId") {
|
||||
const std::string body = R"JSON({
|
||||
"query": {
|
||||
"results": {
|
||||
"Dark Magician (Bandai)": {
|
||||
"printouts": {
|
||||
"English name": ["Dark Magician"],
|
||||
"Bandai number": [14],
|
||||
"Rarity": [{"fulltext": "Rare"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1");
|
||||
REQUIRE(out);
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].name == "Dark Magician");
|
||||
CHECK(out.value()[0].setNo == "14");
|
||||
CHECK(out.value()[0].rarity == "Rare");
|
||||
CHECK(out.value()[0].setId == "ban1");
|
||||
CHECK(out.value()[0].language == "Japanese");
|
||||
}
|
||||
|
||||
TEST_CASE("parseAskResponse prefers Bandai over Sealdass when set is ban1") {
|
||||
const std::string body = R"JSON({
|
||||
"query": {
|
||||
"results": {
|
||||
"Dark Magician (Bandai Sealdass)": {
|
||||
"printouts": {
|
||||
"English name": ["Dark Magician"],
|
||||
"Bandai number": [2],
|
||||
"Rarity": [{"fulltext": "Common"}]
|
||||
}
|
||||
},
|
||||
"Dark Magician (Bandai)": {
|
||||
"printouts": {
|
||||
"English name": ["Dark Magician"],
|
||||
"Bandai number": [14],
|
||||
"Rarity": [{"fulltext": "Rare"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1");
|
||||
REQUIRE(out);
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].setNo == "14");
|
||||
CHECK(out.value()[0].setId == "ban1");
|
||||
}
|
||||
|
||||
TEST_CASE("parseAskResponse drops results whose Bandai number does not match") {
|
||||
const std::string body = R"JSON({
|
||||
"query": {
|
||||
"results": {
|
||||
"Card Eleven (Bandai)": {
|
||||
"printouts": {
|
||||
"English name": ["Card Eleven"],
|
||||
"Bandai number": [11],
|
||||
"Rarity": [{"fulltext": "Common"}]
|
||||
}
|
||||
},
|
||||
"Card One (Bandai)": {
|
||||
"printouts": {
|
||||
"English name": ["Card One"],
|
||||
"Bandai number": [1],
|
||||
"Rarity": [{"fulltext": "Common"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1", "1");
|
||||
REQUIRE(out);
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].name == "Card One");
|
||||
CHECK(out.value()[0].setNo == "1");
|
||||
}
|
||||
|
||||
TEST_CASE("fetchImageUrl uses pageimages URL") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"JSON({
|
||||
"query": {
|
||||
"pages": {
|
||||
"1": {
|
||||
"title": "Dark Magician (Bandai)",
|
||||
"original": {"source": "https://ms.yugipedia.com/x.png"}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
YuGiOhBandaiCardPreviewSource src(http);
|
||||
auto out = src.fetchImageUrl("Dark Magician", "ban1", "14");
|
||||
REQUIRE(out);
|
||||
CHECK(out.value() == "https://ms.yugipedia.com/x.png");
|
||||
CHECK(http.lastUrl.find("pageimages") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("detectFirstPrint uses ask response") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"JSON({
|
||||
"query": {
|
||||
"results": {
|
||||
"Dark Magician (Bandai)": {
|
||||
"printouts": {
|
||||
"English name": ["Dark Magician"],
|
||||
"Bandai number": [14],
|
||||
"Rarity": [{"fulltext": "Rare"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
YuGiOhBandaiCardPreviewSource src(http);
|
||||
auto out = src.detectFirstPrint("Dark Magician", "ban1");
|
||||
REQUIRE(out);
|
||||
CHECK(out.value().setNo == "14");
|
||||
CHECK(out.value().rarity == "Rare");
|
||||
CHECK(out.value().setId == "ban1");
|
||||
}
|
||||
|
||||
TEST_CASE("detectBySetNo uses ask-by-number URL") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"JSON({
|
||||
"query": {
|
||||
"results": {
|
||||
"Dark Magician (Bandai)": {
|
||||
"printouts": {
|
||||
"English name": ["Dark Magician"],
|
||||
"Bandai number": [14],
|
||||
"Rarity": [{"fulltext": "Rare"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
YuGiOhBandaiCardPreviewSource src(http);
|
||||
auto out = src.detectBySetNo("ban1", "014");
|
||||
REQUIRE(out);
|
||||
CHECK(out.value().name == "Dark Magician");
|
||||
CHECK(http.lastUrl.find("action=ask") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("detectBySetNo resolves promo TA2 from gallery parse") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"JSON({
|
||||
"parse": {
|
||||
"wikitext": "WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) {{Gallery card names|Wicked Chain|ja}}\nBlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
|
||||
}
|
||||
})JSON";
|
||||
YuGiOhBandaiCardPreviewSource src(http);
|
||||
auto out = src.detectBySetNo("banpromo-ta", "ta2");
|
||||
REQUIRE(out);
|
||||
CHECK(out.value().name == "Blue-Eyes White Dragon's 3-Body Connection");
|
||||
CHECK(out.value().setNo == "TA2");
|
||||
CHECK(out.value().setId == "banpromo-ta");
|
||||
CHECK(out.value().rarity == "Super Rare");
|
||||
CHECK(http.lastUrl.find("action=parse") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("Promotional") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("detectBySetNo filters out prints from other sets") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"JSON({
|
||||
"query": {
|
||||
"results": {
|
||||
"Dark Magician (Bandai)": {
|
||||
"printouts": {
|
||||
"English name": ["Dark Magician"],
|
||||
"Bandai number": [14],
|
||||
"Rarity": [{"fulltext": "Rare"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})JSON";
|
||||
YuGiOhBandaiCardPreviewSource src(http);
|
||||
auto out = src.detectBySetNo("bansealdass", "14");
|
||||
CHECK_FALSE(out);
|
||||
CHECK(out.error().find("selected set") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("isAlphanumericPromoNumber detects Jump and Toei codes") {
|
||||
CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("TA2"));
|
||||
CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("j1"));
|
||||
CHECK_FALSE(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("14"));
|
||||
}
|
||||
|
||||
TEST_CASE("preferredPageTitle omits Bandai suffix for promo sets") {
|
||||
CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle(
|
||||
"Blue-Eyes White Dragon's 3-Body Connection", "banpromo-ta", "TA2") ==
|
||||
"Blue-Eyes White Dragon's 3-Body Connection");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiCard.hpp"
|
||||
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
|
||||
#include "ccm/services/YuGiOhBandaiSetCompletion.hpp"
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
YuGiOhBandaiCard makeOwned(std::string setId, std::string setName, std::string setNo) {
|
||||
YuGiOhBandaiCard c;
|
||||
c.set = Set{std::move(setId), std::move(setName), "1998/09/01"};
|
||||
c.setNo = std::move(setNo);
|
||||
c.name = "Card";
|
||||
c.language = Language::Japanese;
|
||||
return c;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("YuGiOhBandaiSetCompletion") {
|
||||
TEST_CASE("unique setNo within a pack; amount does not inflate") {
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
YuGiOhBandaiSetCatalogPack pack;
|
||||
pack.setId = "ban1";
|
||||
pack.setName = "1st Generation";
|
||||
pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"});
|
||||
pack.cards.push_back({"14", "Dark Magician", "Rare"});
|
||||
catalog.packs.push_back(pack);
|
||||
|
||||
std::vector<YuGiOhBandaiCard> coll;
|
||||
auto a = makeOwned("ban1", "1st Generation", "14");
|
||||
a.amount = 5;
|
||||
coll.push_back(a);
|
||||
coll.push_back(makeOwned("ban1", "1st Generation", "014"));
|
||||
|
||||
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
|
||||
REQUIRE(progress.size() == 1);
|
||||
CHECK(progress[0].ownedUnique == 1);
|
||||
CHECK(progress[0].total == 2);
|
||||
CHECK(progress[0].percent() == 50);
|
||||
}
|
||||
|
||||
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
YuGiOhBandaiSetCatalogPack ban1;
|
||||
ban1.setId = "ban1";
|
||||
ban1.setName = "1st Generation";
|
||||
ban1.cards.push_back({"14", "Dark Magician", "Rare"});
|
||||
YuGiOhBandaiSetCatalogPack seal;
|
||||
seal.setId = "bansealdass";
|
||||
seal.setName = "Sealdass";
|
||||
seal.cards.push_back({"14", "Other", "Common"});
|
||||
catalog.packs.push_back(ban1);
|
||||
catalog.packs.push_back(seal);
|
||||
|
||||
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban1", "1st Generation", "14")};
|
||||
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
|
||||
REQUIRE(progress.size() == 1);
|
||||
CHECK(progress[0].setId == "ban1");
|
||||
}
|
||||
|
||||
TEST_CASE("checklist marks owned rows") {
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
YuGiOhBandaiSetCatalogPack pack;
|
||||
pack.setId = "ban1";
|
||||
pack.setName = "1st Generation";
|
||||
pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"});
|
||||
pack.cards.push_back({"14", "Dark Magician", "Rare"});
|
||||
catalog.packs.push_back(pack);
|
||||
|
||||
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban1", "1st Generation", "14")};
|
||||
auto list = yuGiOhBandaiChecklistForSet(coll, catalog, "ban1");
|
||||
REQUIRE(list.size() == 2);
|
||||
CHECK(list[0].setNo == "14");
|
||||
CHECK(list[0].owned);
|
||||
CHECK(list[1].setNo == "9");
|
||||
CHECK_FALSE(list[1].owned);
|
||||
}
|
||||
|
||||
TEST_CASE("empty setNo produces no progress rows") {
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
YuGiOhBandaiSetCatalogPack pack;
|
||||
pack.setId = "ban1";
|
||||
pack.setName = "1st Generation";
|
||||
pack.cards.push_back({"14", "Dark Magician", "Rare"});
|
||||
catalog.packs.push_back(pack);
|
||||
|
||||
YuGiOhBandaiCard missingNo = makeOwned("ban1", "1st Generation", "");
|
||||
YuGiOhBandaiCard whitespaceNo = makeOwned("ban1", "1st Generation", " ");
|
||||
std::vector<YuGiOhBandaiCard> coll{missingNo, whitespaceNo};
|
||||
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
|
||||
CHECK(progress.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("set.id plus setNo against catalog pack yields a tile") {
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
YuGiOhBandaiSetCatalogPack pack;
|
||||
pack.setId = "ban2";
|
||||
pack.setName = "2nd Generation";
|
||||
pack.cards.push_back({"47", "Time Wizard", "Super Rare"});
|
||||
pack.cards.push_back({"48", "Polymerization", "Super Rare"});
|
||||
catalog.packs.push_back(pack);
|
||||
|
||||
std::vector<YuGiOhBandaiCard> coll{makeOwned("ban2", "2nd Generation", "047")};
|
||||
auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog);
|
||||
REQUIRE(progress.size() == 1);
|
||||
CHECK(progress[0].setId == "ban2");
|
||||
CHECK(progress[0].setName == "2nd Generation");
|
||||
CHECK(progress[0].ownedUnique == 1);
|
||||
CHECK(progress[0].total == 2);
|
||||
CHECK(progress[0].percent() == 50);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("YuGiOhBandaiSetCatalogService") {
|
||||
TEST_CASE("catalog service round-trips against InMemoryFileSystem") {
|
||||
InMemoryFileSystem fs;
|
||||
auto config = makeConfig(fs, "/data");
|
||||
YuGiOhBandaiSetCatalogService svc(fs, config, [](Game) { return "yugiohbandai"; });
|
||||
|
||||
YuGiOhBandaiSetCatalog catalog;
|
||||
YuGiOhBandaiSetCatalogPack pack;
|
||||
pack.setId = "ban1";
|
||||
pack.setName = "1st Generation";
|
||||
pack.cards.push_back({"14", "Dark Magician", "Rare"});
|
||||
catalog.packs.push_back(pack);
|
||||
|
||||
REQUIRE(svc.save(catalog));
|
||||
CHECK(svc.exists());
|
||||
auto loaded = svc.load();
|
||||
REQUIRE(loaded);
|
||||
CHECK(loaded.value() == catalog);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
|
||||
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string body;
|
||||
std::string lastUrl;
|
||||
bool fail{false};
|
||||
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
if (fail) return Result<std::string>::err("http fail");
|
||||
return Result<std::string>::ok(body);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("YuGiOhBandaiSetSource") {
|
||||
TEST_CASE("parseResponse returns stable manifest ordered by release date") {
|
||||
auto sets = YuGiOhBandaiSetSource::parseResponse({});
|
||||
REQUIRE(sets);
|
||||
REQUIRE(sets.value().size() == 6);
|
||||
CHECK(sets.value()[0].id == "ban1");
|
||||
CHECK(sets.value()[0].name == "1st Generation");
|
||||
CHECK(sets.value()[0].releaseDate == "1998/09/01");
|
||||
CHECK(sets.value()[5].id == "bansealdass");
|
||||
}
|
||||
|
||||
TEST_CASE("normalizeCardNumber strips leading zeros and uppercases prefixes") {
|
||||
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("014") == "14");
|
||||
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("#9") == "9");
|
||||
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("j1") == "J1");
|
||||
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("ta2") == "TA2");
|
||||
CHECK(YuGiOhBandaiSetSource::normalizeCardNumber(" ") == "");
|
||||
}
|
||||
|
||||
TEST_CASE("expandRarityCode maps gallery abbreviations") {
|
||||
CHECK(YuGiOhBandaiSetSource::expandRarityCode("C") == "Common");
|
||||
CHECK(YuGiOhBandaiSetSource::expandRarityCode("R") == "Rare");
|
||||
CHECK(YuGiOhBandaiSetSource::expandRarityCode("SR") == "Super Rare");
|
||||
CHECK(YuGiOhBandaiSetSource::expandRarityCode("HFR") == "Holo Seal");
|
||||
}
|
||||
|
||||
TEST_CASE("setIdForNumber maps ranges and promo prefixes") {
|
||||
CHECK(YuGiOhBandaiSetSource::setIdForNumber("14") == "ban1");
|
||||
CHECK(YuGiOhBandaiSetSource::setIdForNumber("50") == "ban2");
|
||||
CHECK(YuGiOhBandaiSetSource::setIdForNumber("118") == "ban3");
|
||||
CHECK(YuGiOhBandaiSetSource::setIdForNumber("J1") == "banpromo-j");
|
||||
CHECK(YuGiOhBandaiSetSource::setIdForNumber("TA2") == "banpromo-ta");
|
||||
}
|
||||
|
||||
TEST_CASE("parseGalleryWikitext extracts number rarity and English name") {
|
||||
const std::string wiki =
|
||||
"DarkMagician-BAN1-JP-R.png | {{pound}}014 ([[R]]) "
|
||||
"{{Gallery card names|Dark Magician (Bandai)|ja}}\n"
|
||||
"BlueEyesWhiteDragon-BAN1-JP-SR.png | {{pound}}009 ([[SR]]) "
|
||||
"{{Gallery card names|Blue-Eyes White Dragon (Bandai)|ja}}\n";
|
||||
|
||||
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
|
||||
REQUIRE(cards);
|
||||
REQUIRE(cards.value().size() == 2);
|
||||
CHECK(cards.value()[0].setNo == "14");
|
||||
CHECK(cards.value()[0].name == "Dark Magician");
|
||||
CHECK(cards.value()[0].rarity == "Rare");
|
||||
CHECK(cards.value()[1].setNo == "9");
|
||||
CHECK(cards.value()[1].rarity == "Super Rare");
|
||||
}
|
||||
|
||||
TEST_CASE("parseGalleryWikitext accepts promo [[TA2]] number format") {
|
||||
const std::string wiki =
|
||||
"WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) "
|
||||
"{{Gallery card names|Wicked Chain|ja}}\n"
|
||||
"BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]]) "
|
||||
"{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
|
||||
"MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]]) "
|
||||
"{{Gallery card names|Mirror Force (Bandai)|ja}}\n";
|
||||
|
||||
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
|
||||
REQUIRE(cards);
|
||||
REQUIRE(cards.value().size() == 3);
|
||||
CHECK(cards.value()[0].setNo == "TA1");
|
||||
CHECK(cards.value()[0].name == "Wicked Chain");
|
||||
CHECK(cards.value()[0].rarity == "Super Rare");
|
||||
CHECK(cards.value()[1].setNo == "TA2");
|
||||
CHECK(cards.value()[1].name == "Blue-Eyes White Dragon's 3-Body Connection");
|
||||
CHECK(cards.value()[2].setNo == "J1");
|
||||
CHECK(cards.value()[2].name == "Mirror Force");
|
||||
}
|
||||
|
||||
TEST_CASE("parseGalleryWikitext tolerates <br /> between rarity and name template") {
|
||||
// Live Yugipedia promo gallery captions insert <br /> after expansion.
|
||||
const std::string wiki =
|
||||
"<gallery mode=\"packed\">\n"
|
||||
"BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])<br />"
|
||||
"{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n"
|
||||
"MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]])<br />"
|
||||
"{{Gallery card names|Mirror Force (Bandai)|ja}}\n"
|
||||
"</gallery>\n";
|
||||
|
||||
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki);
|
||||
REQUIRE(cards);
|
||||
REQUIRE(cards.value().size() == 2);
|
||||
CHECK(cards.value()[0].setNo == "TA2");
|
||||
CHECK(cards.value()[0].name == "Blue-Eyes White Dragon's 3-Body Connection");
|
||||
CHECK(cards.value()[0].rarity == "Super Rare");
|
||||
CHECK(cards.value()[1].setNo == "J1");
|
||||
}
|
||||
|
||||
TEST_CASE("parseGalleryWikitext empty body yields empty ok") {
|
||||
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext("");
|
||||
REQUIRE(cards);
|
||||
CHECK(cards.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("fetchAll returns manifest without HTTP") {
|
||||
FixedHttpClient http;
|
||||
YuGiOhBandaiSetSource src(http);
|
||||
auto sets = src.fetchAll();
|
||||
REQUIRE(sets);
|
||||
CHECK(sets.value().size() == 6);
|
||||
CHECK(http.lastUrl.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("buildGalleryParseUrl percent-encodes page title") {
|
||||
const auto url = YuGiOhBandaiSetSource::buildGalleryParseUrl(
|
||||
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation");
|
||||
CHECK(url.find("action=parse") != std::string::npos);
|
||||
CHECK(url.find("prop=wikitext") != std::string::npos);
|
||||
CHECK(url.find("page=") != std::string::npos);
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -5,9 +5,9 @@
|
||||
## Layer pointers
|
||||
|
||||
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
|
||||
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magic’s splitter mounting.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
|
||||
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magic’s splitter mounting. `attachSharedToolbarEdit` lets Magic wire MainFrame’s Edit button for multi-select hide/show; hostsOwnLayout games manage their own Edit button via `setToolbarEditVisible`.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. Edit is hidden when more than one list row is selected. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. **Multi-select** is enabled (no `wxLC_SINGLE_SEL`): Ctrl toggles rows, Shift selects a range; `selected()` / `selectedCount()` / `selectedCards()` expose the selection; rebuild/sort/filter preserve all selected ids; Ctrl+C copies every selected row as TSV (one line per card). Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
|
||||
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
|
||||
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)` → `Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
|
||||
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` — and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
|
||||
@@ -16,11 +16,12 @@
|
||||
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`, `PokemonSetCompletionPanel`. Same Add/Edit shape as Magic for the card form; the game view hosts **Single Cards | Set Completion** via `contentPanel` / `hostsOwnLayout` (like Digimon/Yu-Gi-Oh!). Catalog from `PokemonSetCatalogService` (`set-catalog-west.json` / `set-catalog-asia.json`), filled on Update Pokemon. The Add/Edit/Delete + filter toolbar lives inside the Single Cards tab; MainFrame hides its shared toolbar while Pokemon is active.
|
||||
- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`).
|
||||
- `include/ccm/ui/YuGiOh*.hpp` + `src/YuGiOh*.cpp` — Yu-Gi-Oh!: list/selected/edit plus `YuGiOhGameView` notebook (**Single Cards** | **Set Completion**) via the same `hostsOwnLayout` / `contentPanel` pattern as Digimon, and `YuGiOhSetCompletionPanel`. Catalog from `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), filled on Update Sets from YGOPRODeck `cardinfo.php`.
|
||||
- `include/ccm/ui/YuGiOhBandai*.hpp` + `src/YuGiOhBandai*.cpp` — Yu-Gi-Oh! (Bandai): same notebook layout as Digimon/YGO; dual auto-detect (name or Bandai number) via Yugipedia SMW ask; catalog from `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`).
|
||||
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
|
||||
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
|
||||
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
|
||||
- `include/ccm/ui/ImageViewerDialog.hpp` + `src/ImageViewerDialog.cpp` — full-size viewer with prev/next.
|
||||
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`).
|
||||
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`, `setToolbarEditVisible`, `deleteCardsConfirmMessage`) for consistent dark/light dialogs and multi-select toolbar/delete UX. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`).
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -61,7 +62,8 @@
|
||||
- Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window.
|
||||
- Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background.
|
||||
- Do not call `applyNativeClassTheme(..., "DarkMode_Explorer", "Explorer")` for `wxTextCtrl`; on some Windows builds this causes black typed text in dark mode. Keep text inputs palette-driven (`SetThemeEnabled(false)` in dark/high-contrast as needed).
|
||||
- If a specific text field still renders wrong while typing (notably `MainFrame`'s filter box), enforce text/background in `MainFrame::MSWWindowProc` via `WM_CTLCOLOREDIT` for that control handle.
|
||||
- The collection **filter** boxes use `wxTE_RICH2` so typed text can take palette colours on MSW. Do **not** call `SetHint()` on those controls: RichEdit has no cue banner, and wx's fallback writes the hint into `GetValue()`. Use `installTextCtrlPlaceholder` (paints the cue only while empty).
|
||||
- Text inputs are hardened in `Theme.cpp` via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme`: opt the EDIT HWND out of immersive dark mode, clear its visual style, and subclass the **parent** to answer `WM_CTLCOLOREDIT` (that message goes to the parent, not the frame — an earlier frame-level handler never ran for the toolbar filter).
|
||||
- Keep toolbar button behavior stable under dark/high-contrast: avoid changes that break click/tooltip affordances while experimenting with hover contrast fixes.
|
||||
- For dark/high-contrast button readability, do not trust native hover/pressed rendering on Windows; custom state painting in `Theme.cpp` is allowed when native visuals ignore configured colors.
|
||||
- Button event handlers must use per-button state that is refreshed when theme changes. Avoid one-time captures of theme colors/mode in lambdas; these can leak dark-mode behavior into light mode.
|
||||
@@ -75,6 +77,7 @@
|
||||
- Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game.
|
||||
- Keep shared templates game-agnostic: put buttons and detection behavior in `<Name>CardEditDialog`, not in `BaseCardEditDialog`. Yu-Gi-Oh!'s **Set code** entry (`SwitchCtrl` + text + **Auto detect** against cached sets) is wired through the template hook `customizeSetPickerRow` so Magic/Pokemon keep the default single-combo row unchanged.
|
||||
- For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input.
|
||||
- Bidirectional identify (Yu-Gi-Oh!, Bandai, Pokémon West/Asia, Digi-Battle): Set is always required. Set # **Auto detect** fills set number from Name, or fills Name from Set #. When **both** fields are filled, the field the user last typed is the lookup key (`CardLookupEditField` / `preferDetectBySetNo` in `ccm/util/CardLookupDetect.hpp`, tracked by `BaseCardEditDialog::markNameLookupEdited` / `markSetNoLookupEdited`). Programmatic `ChangeValue` from a detect result does not flip the key.
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
@@ -90,7 +93,7 @@
|
||||
1. Implement three derived classes under `include/ccm/ui/` mirroring the Magic / Pokemon trio:
|
||||
- `<Name>CardListPanel : public BaseCardListPanel<<Name>Card, <Name>SortColumn>` — override `declareTextColumns()`, `declareIconColumns()`, `renderTextCell()`, `isIconColumnSet()`, `sortBy()`, `matchesFilter()`.
|
||||
- `<Name>SelectedCardPanel : public BaseSelectedCardPanel<<Name>Card>` — override `declareDetailRows()`, `declareFlagIcons()`, `detailValueFor()`, `isFlagSet()`, `previewKey()`, `gameId()`. Define a local `enum` of `DetailKey` constants for clarity.
|
||||
- `<Name>CardEditDialog : public BaseCardEditDialog<<Name>Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`.
|
||||
- `<Name>CardEditDialog : public BaseCardEditDialog<<Name>Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`, and optionally `validateExtraFields()` (Bandai requires set number).
|
||||
2. Add a `<Name>GameView : public IGameView` that owns those panels and the typed `CollectionService<<Name>Card>&`. Bind `EVT_CARD_SELECTED` on the list panel inside `listPanel(parent)` to push the typed selection into the selected panel. The `MagicGameView` / `PokemonGameView` pair is the canonical reference.
|
||||
3. Re-add the new view to `AppContext::gameViews` in the composition root (`app/main.cpp`). The `Game` and `Sets` menus pick it up automatically.
|
||||
4. Add SVG glyphs for any new flag columns to `SvgIcons.{hpp,cpp}` (with the `@FILL@` placeholder).
|
||||
|
||||
@@ -26,6 +26,11 @@ add_library(ccm_ui_wx STATIC
|
||||
src/DigiBattle99CardEditDialog.cpp
|
||||
src/DigiBattle99GameView.cpp
|
||||
src/DigiBattle99SetCompletionPanel.cpp
|
||||
src/YuGiOhBandaiCardListPanel.cpp
|
||||
src/YuGiOhBandaiSelectedCardPanel.cpp
|
||||
src/YuGiOhBandaiCardEditDialog.cpp
|
||||
src/YuGiOhBandaiGameView.cpp
|
||||
src/YuGiOhBandaiSetCompletionPanel.cpp
|
||||
|
||||
src/SettingsDialog.cpp
|
||||
src/SwitchCtrl.cpp
|
||||
@@ -61,8 +66,10 @@ target_link_libraries(ccm_ui_wx
|
||||
# the per-row flag glyphs with proper transparency. Without msimg32 linked
|
||||
# explicitly the linker fails on `AlphaBlend@44` even though gdi32 is pulled
|
||||
# in transitively by wxWidgets.
|
||||
# Theme.cpp subclasses EDIT parents via SetWindowSubclass / DefSubclassProc /
|
||||
# RemoveWindowSubclass (comctl32); those symbols are not pulled in by wx alone.
|
||||
if (WIN32)
|
||||
target_link_libraries(ccm_ui_wx PRIVATE msimg32)
|
||||
target_link_libraries(ccm_ui_wx PRIVATE msimg32 comctl32)
|
||||
endif()
|
||||
|
||||
target_compile_features(ccm_ui_wx PUBLIC cxx_std_20)
|
||||
|
||||
@@ -27,6 +27,7 @@ struct AppContext {
|
||||
IGameModule& pokemonModule;
|
||||
IGameModule& yuGiOhModule;
|
||||
IGameModule& digiBattle99Module;
|
||||
IGameModule& yuGiOhBandaiModule;
|
||||
// Asia Pokemon sets/preview backend (not a separate Game menu entry).
|
||||
IGameModule& japanesePokemonModule;
|
||||
// Active per-game UI bundles. The order is the order shown in the
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include "ccm/services/SetService.hpp"
|
||||
#include "ccm/ui/ImageViewerDialog.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
#include "ccm/util/CardLookupDetect.hpp"
|
||||
|
||||
#include <wx/arrstr.h>
|
||||
#include <wx/button.h>
|
||||
@@ -136,6 +137,30 @@ protected:
|
||||
// controls cannot outlive the lookup identity.
|
||||
virtual void onCardLookupContextChanged() {}
|
||||
|
||||
// Bidirectional Set # Auto detect: track which of Name / Set # the user
|
||||
// last typed so a second detect uses that field as the lookup key.
|
||||
void markNameLookupEdited() { lastLookupEditField_ = CardLookupEditField::Name; }
|
||||
|
||||
// Subclasses bind Set # `wxEVT_TEXT` to this (or call it from their handler).
|
||||
// Also clears print-variant caches via `onCardLookupContextChanged`.
|
||||
void markSetNoLookupEdited() {
|
||||
lastLookupEditField_ = CardLookupEditField::SetNo;
|
||||
onCardLookupContextChanged();
|
||||
}
|
||||
|
||||
[[nodiscard]] CardLookupEditField lastLookupEditField() const noexcept {
|
||||
return lastLookupEditField_;
|
||||
}
|
||||
|
||||
// `nameEmpty` / `setNoEmpty` must already be trimmed/normalized by the caller.
|
||||
[[nodiscard]] bool shouldDetectBySetNo(bool nameEmpty, bool setNoEmpty) const noexcept {
|
||||
return preferDetectBySetNo(nameEmpty, setNoEmpty, lastLookupEditField_);
|
||||
}
|
||||
|
||||
// Extra validation after name/set checks and writeFromControls(). Return
|
||||
// false to block OK (subclass should show its own themed dialog).
|
||||
[[nodiscard]] virtual bool validateExtraFields() { return true; }
|
||||
|
||||
// Common helpers ----------------------------------------------------------
|
||||
|
||||
void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) {
|
||||
@@ -148,6 +173,8 @@ protected:
|
||||
[[nodiscard]] const TCard& constCard() const noexcept { return card_; }
|
||||
void syncCardFromControls() { writeFromControls(); }
|
||||
[[nodiscard]] wxComboBox* setComboControl() const noexcept { return setCombo_; }
|
||||
[[nodiscard]] wxTextCtrl* nameControl() const noexcept { return nameCtrl_; }
|
||||
[[nodiscard]] wxChoice* languageChoiceControl() const noexcept { return languageChoice_; }
|
||||
|
||||
[[nodiscard]] const Set* selectedSetFromControls() const {
|
||||
const auto& available = availableSets();
|
||||
@@ -204,6 +231,7 @@ private:
|
||||
|
||||
nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str()));
|
||||
nameCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent& ev) {
|
||||
markNameLookupEdited();
|
||||
onCardLookupContextChanged();
|
||||
ev.Skip();
|
||||
});
|
||||
@@ -471,6 +499,7 @@ private:
|
||||
"Add card", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (!validateExtraFields()) return;
|
||||
if (mode_ == EditMode::Edit && !(card_ == openingSnapshot_)) {
|
||||
if (showThemedConfirmDialog(
|
||||
this,
|
||||
@@ -619,6 +648,7 @@ private:
|
||||
const std::vector<Set>* preloadedSets_{nullptr};
|
||||
|
||||
wxTextCtrl* nameCtrl_{nullptr};
|
||||
CardLookupEditField lastLookupEditField_{CardLookupEditField::None};
|
||||
wxComboBox* setCombo_{nullptr};
|
||||
wxSpinCtrl* amountCtrl_{nullptr};
|
||||
wxChoice* languageChoice_{nullptr};
|
||||
|
||||
@@ -40,13 +40,16 @@
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/bitmap.h>
|
||||
#include <wx/clipbrd.h>
|
||||
#include <wx/colour.h>
|
||||
#include <wx/cursor.h>
|
||||
#include <wx/dataobj.h>
|
||||
#include <wx/event.h>
|
||||
#include <wx/image.h>
|
||||
#include <wx/listctrl.h>
|
||||
#include <wx/panel.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/textctrl.h>
|
||||
#include <wx/statbmp.h>
|
||||
#include <wx/stattext.h>
|
||||
#include <wx/utils.h>
|
||||
@@ -59,6 +62,7 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
@@ -74,22 +78,37 @@ wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
|
||||
// `IGameView` implementations bind this to open Edit for `selected()`.
|
||||
wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent);
|
||||
|
||||
// Raised when the list wants a short status-bar note (e.g. clipboard copy).
|
||||
// `event.GetString()` is the message; MainFrame shows it in the bottom strip.
|
||||
wxDECLARE_EVENT(EVT_UI_STATUS, wxCommandEvent);
|
||||
|
||||
template <typename TCard, typename TSortColumn>
|
||||
class BaseCardListPanel : public wxPanel {
|
||||
public:
|
||||
using card_type = TCard;
|
||||
using sort_column_type = TSortColumn;
|
||||
|
||||
// Replace the displayed rows. Selection is reset (the panel will pick
|
||||
// the first row on the next idle turn — see rebuildRows()).
|
||||
void setCards(std::vector<TCard> cards) {
|
||||
// Replace the displayed rows. When preferSelectId is set, selects that
|
||||
// card exclusively if present (used after Add). Otherwise preserves the
|
||||
// previously selected card ids when still present; the first-row CallAfter
|
||||
// path in rebuildRows() runs only when there was no prior selection
|
||||
// (startup).
|
||||
void setCards(std::vector<TCard> cards,
|
||||
std::optional<std::uint32_t> preferSelectId = std::nullopt) {
|
||||
std::optional<std::vector<std::uint32_t>> keepIds;
|
||||
if (preferSelectId) {
|
||||
keepIds = std::vector<std::uint32_t>{*preferSelectId};
|
||||
} else {
|
||||
auto ids = selectedIds();
|
||||
if (!ids.empty()) keepIds = std::move(ids);
|
||||
}
|
||||
cards_ = std::move(cards);
|
||||
// Drop sort state when the underlying data is replaced - the indicator
|
||||
// shown in the header should match the order actually rendered, and
|
||||
// wxListCtrl keeps the indicator across DeleteAllItems().
|
||||
nextDirByCol_.clear();
|
||||
list_->RemoveSortIndicator();
|
||||
rebuildRows();
|
||||
rebuildRows(keepIds);
|
||||
if (!autoSizedOnce_ && !cards_.empty()) {
|
||||
autoSizeAllColumns();
|
||||
autoSizedOnce_ = true;
|
||||
@@ -97,16 +116,19 @@ public:
|
||||
}
|
||||
|
||||
// Update the filter string and rebuild the visible rows in place. The
|
||||
// panel preserves the previously-selected card across the rebuild when
|
||||
// it still matches the new filter; otherwise the first remaining row is
|
||||
// panel preserves previously-selected cards across the rebuild when they
|
||||
// still match the new filter; otherwise the first remaining row is
|
||||
// selected, or none if the filter excluded everything. A single
|
||||
// EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs.
|
||||
void setFilter(std::string_view filter) {
|
||||
if (filter_ == filter) return;
|
||||
filter_.assign(filter);
|
||||
std::optional<std::uint32_t> keepId;
|
||||
if (auto sel = selected()) keepId = sel->id;
|
||||
rebuildRows(keepId);
|
||||
auto ids = selectedIds();
|
||||
std::optional<std::vector<std::uint32_t>> keepIds;
|
||||
if (!ids.empty()) keepIds = std::move(ids);
|
||||
suppressListFocus_ = true;
|
||||
rebuildRows(keepIds);
|
||||
suppressListFocus_ = false;
|
||||
}
|
||||
|
||||
void applyTheme(const ThemePalette& palette) {
|
||||
@@ -116,22 +138,52 @@ public:
|
||||
SetForegroundColour(palette.text);
|
||||
rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
|
||||
refreshHeaderTheme(palette);
|
||||
std::optional<std::uint32_t> keepId;
|
||||
if (auto sel = selected()) keepId = sel->id;
|
||||
rebuildRows(keepId);
|
||||
auto ids = selectedIds();
|
||||
std::optional<std::vector<std::uint32_t>> keepIds;
|
||||
if (!ids.empty()) keepIds = std::move(ids);
|
||||
rebuildRows(keepIds);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::vector<TCard>& cards() const noexcept { return cards_; }
|
||||
[[nodiscard]] const std::string& filter() const noexcept { return filter_; }
|
||||
// First selected card (detail panel / single-edit primary).
|
||||
[[nodiscard]] std::optional<TCard> selected() const {
|
||||
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
|
||||
if (const TCard* c = cardForRow(sel)) return *c;
|
||||
return std::nullopt;
|
||||
}
|
||||
[[nodiscard]] std::size_t selectedCount() const {
|
||||
if (list_ == nullptr) return 0;
|
||||
std::size_t n = 0;
|
||||
long row = -1;
|
||||
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
[[nodiscard]] std::vector<TCard> selectedCards() const {
|
||||
std::vector<TCard> out;
|
||||
if (list_ == nullptr) return out;
|
||||
long row = -1;
|
||||
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
|
||||
if (const TCard* c = cardForRow(row)) out.push_back(*c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
[[nodiscard]] std::vector<std::uint32_t> selectedIds() const {
|
||||
std::vector<std::uint32_t> out;
|
||||
if (list_ == nullptr) return out;
|
||||
long row = -1;
|
||||
while ((row = list_->GetNextItem(row, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
|
||||
if (const TCard* c = cardForRow(row)) out.push_back(c->id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ensure the selected row is actively focused so Windows uses the active
|
||||
// highlight color (blue in light mode), keeping selected-row icons legible.
|
||||
// Ensure the first selected row is actively focused so Windows uses the
|
||||
// active highlight color (blue in light mode), keeping selected-row icons
|
||||
// legible. Does not clear a multi-selection.
|
||||
void activateSelection() {
|
||||
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
|
||||
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
|
||||
@@ -140,7 +192,33 @@ public:
|
||||
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
|
||||
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
|
||||
list_->EnsureVisible(row);
|
||||
list_->SetFocus();
|
||||
if (dynamic_cast<wxTextCtrl*>(wxWindow::FindFocus()) == nullptr)
|
||||
list_->SetFocus();
|
||||
}
|
||||
|
||||
// Move the selection by `delta` rows (+1 / -1). Used when Up/Down are
|
||||
// pressed while focus is on the filter box. Collapses any multi-selection
|
||||
// to a single row. Clamps to the visible range; leaves list HWND focus
|
||||
// alone so the caret can stay in the filter.
|
||||
void nudgeSelection(int delta) {
|
||||
if (list_ == nullptr || list_->GetItemCount() <= 0 || delta == 0) return;
|
||||
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
|
||||
if (row < 0) row = 0;
|
||||
const long count = list_->GetItemCount();
|
||||
long next = row + delta;
|
||||
if (next < 0) next = 0;
|
||||
if (next >= count) next = count - 1;
|
||||
suppressListFocus_ = true;
|
||||
// Clear every selected row so filter nudge is always single-select.
|
||||
long sel = -1;
|
||||
while ((sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED)) >= 0) {
|
||||
list_->SetItemState(sel, 0, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
|
||||
}
|
||||
list_->SetItemState(next,
|
||||
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
|
||||
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
|
||||
list_->EnsureVisible(next);
|
||||
suppressListFocus_ = false;
|
||||
}
|
||||
|
||||
protected:
|
||||
@@ -184,8 +262,10 @@ protected:
|
||||
// Subclass calls this once from its constructor body (after virtual hooks
|
||||
// are reachable) to wire up columns + the header row + custom-draw hooks.
|
||||
void buildLayout() {
|
||||
// Multi-select: native Ctrl (toggle) and Shift (range) without
|
||||
// wxLC_SINGLE_SEL. Set-completion tables keep single-select separately.
|
||||
list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize,
|
||||
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
|
||||
wxLC_REPORT | wxLC_NO_HEADER);
|
||||
|
||||
textCols_ = declareTextColumns();
|
||||
iconCols_ = declareIconColumns();
|
||||
@@ -243,6 +323,7 @@ protected:
|
||||
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
|
||||
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
|
||||
list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this);
|
||||
list_->Bind(wxEVT_KEY_DOWN, &BaseCardListPanel::onListKeyDown, this);
|
||||
}
|
||||
|
||||
// Forwarded helpers ------------------------------------------------------
|
||||
@@ -330,6 +411,17 @@ private:
|
||||
addText(textCols_.back().label, textCols_.back().width, noteCol);
|
||||
|
||||
headerRow_->SetSizer(s);
|
||||
|
||||
// Header is mouse-only (sort / resize). Keep it out of the tab order so
|
||||
// Up/Down after a header click still drive the list, not wx focus travel.
|
||||
headerRow_->SetCanFocus(false);
|
||||
for (wxWindow* cell : headerCells_) {
|
||||
if (cell == nullptr) continue;
|
||||
cell->SetCanFocus(false);
|
||||
for (wxWindow* child : cell->GetChildren()) {
|
||||
if (child != nullptr) child->SetCanFocus(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- header drag-resize / sort hit-test ---------------------------------
|
||||
@@ -481,11 +573,13 @@ private:
|
||||
const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
|
||||
nextDirByCol_[*sortCol] = !ascending;
|
||||
|
||||
std::optional<std::uint32_t> keepId;
|
||||
if (auto sel = selected()) keepId = sel->id;
|
||||
auto ids = selectedIds();
|
||||
std::optional<std::vector<std::uint32_t>> keepIds;
|
||||
if (!ids.empty()) keepIds = std::move(ids);
|
||||
|
||||
sortBy(*sortCol, ascending);
|
||||
rebuildRows(keepId);
|
||||
rebuildRows(keepIds);
|
||||
if (list_ != nullptr) list_->SetFocus();
|
||||
}
|
||||
|
||||
// ----- cached icon bitmaps for NM_CUSTOMDRAW -----------------------------
|
||||
@@ -547,7 +641,9 @@ private:
|
||||
|
||||
// ----- row rendering -----------------------------------------------------
|
||||
|
||||
void rebuildRows(std::optional<std::uint32_t> keepId = std::nullopt) {
|
||||
// nullopt keepIds → no prior selection (startup / empty): defer first-row
|
||||
// select. Otherwise restore every id that is still visible after filter.
|
||||
void rebuildRows(std::optional<std::vector<std::uint32_t>> keepIds = std::nullopt) {
|
||||
// Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and
|
||||
// SELECTED (from the SetItemState below) events while we churn through
|
||||
// the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale.
|
||||
@@ -562,10 +658,16 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_set<std::uint32_t> keepSet;
|
||||
if (keepIds) {
|
||||
keepSet.insert(keepIds->begin(), keepIds->end());
|
||||
}
|
||||
|
||||
long row = 0;
|
||||
long rowToSelect = -1;
|
||||
long firstRestored = -1;
|
||||
const int firstText = firstTextColIdx();
|
||||
const int noteCol = noteColIdx();
|
||||
std::vector<long> rowsToSelect;
|
||||
for (std::size_t srcIdx : filteredIndices_) {
|
||||
const auto& c = cards_[srcIdx];
|
||||
// Insert via the hidden column-0 spacer. We never set sub-item
|
||||
@@ -589,16 +691,22 @@ private:
|
||||
const std::string note = renderTextCell(c, textCols_.size() - 1);
|
||||
list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));
|
||||
|
||||
if (keepId && c.id == *keepId) rowToSelect = idx;
|
||||
if (!keepSet.empty() && keepSet.count(c.id) != 0) {
|
||||
rowsToSelect.push_back(idx);
|
||||
if (firstRestored < 0) firstRestored = idx;
|
||||
}
|
||||
++row;
|
||||
}
|
||||
bool deferredInitialSelect = false;
|
||||
if (!filteredIndices_.empty() && rowToSelect >= 0) {
|
||||
list_->SetItemState(rowToSelect,
|
||||
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
|
||||
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
|
||||
list_->EnsureVisible(rowToSelect);
|
||||
} else if (!filteredIndices_.empty() && !keepId.has_value()) {
|
||||
if (!filteredIndices_.empty() && !rowsToSelect.empty()) {
|
||||
for (long r : rowsToSelect) {
|
||||
const long flags = (r == firstRestored)
|
||||
? (wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED)
|
||||
: wxLIST_STATE_SELECTED;
|
||||
list_->SetItemState(r, flags, wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
|
||||
}
|
||||
list_->EnsureVisible(firstRestored);
|
||||
} else if (!filteredIndices_.empty() && !keepIds.has_value()) {
|
||||
// Defer the initial selection to the next event turn so first
|
||||
// paint stays responsive.
|
||||
deferredInitialSelect = true;
|
||||
@@ -644,15 +752,94 @@ private:
|
||||
// no per-row icon swap is required here.
|
||||
(void)event;
|
||||
if (inRebuild_) return;
|
||||
// Row click / native arrow keys: keep HWND focus on the list. Filter
|
||||
// nudge sets suppressListFocus_ so the caret stays in the text box.
|
||||
// Also skip the focus grab when any wxTextCtrl already has focus
|
||||
// (covers the deferred CallAfter select that fires after setFilter
|
||||
// has reset suppressListFocus_).
|
||||
if (!suppressListFocus_ && list_ != nullptr) {
|
||||
if (dynamic_cast<wxTextCtrl*>(wxWindow::FindFocus()) == nullptr)
|
||||
list_->SetFocus();
|
||||
}
|
||||
notifySelectionChanged();
|
||||
}
|
||||
|
||||
void onListItemActivated(wxListEvent& event) {
|
||||
(void)event;
|
||||
if (inRebuild_) return;
|
||||
wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId());
|
||||
// Defer so ShowModal (Edit) does not run inside the list notify path.
|
||||
CallAfter([this]() {
|
||||
if (inRebuild_) return;
|
||||
wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId());
|
||||
ev.SetEventObject(this);
|
||||
ProcessWindowEvent(ev);
|
||||
});
|
||||
}
|
||||
|
||||
void onListKeyDown(wxKeyEvent& event) {
|
||||
const int key = event.GetKeyCode();
|
||||
const bool copyChord =
|
||||
(event.ControlDown() || event.CmdDown()) && (key == 'C' || key == 'c');
|
||||
if (!copyChord) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
copySelectedRowsToClipboard();
|
||||
}
|
||||
|
||||
void copySelectedRowsToClipboard() {
|
||||
const auto cards = selectedCards();
|
||||
if (cards.empty() || textCols_.empty()) return;
|
||||
|
||||
auto formatRow = [&](const TCard& card) {
|
||||
std::string line;
|
||||
auto appendCell = [&](std::string_view cell) {
|
||||
if (!line.empty()) line.push_back('\t');
|
||||
line.append(cell);
|
||||
};
|
||||
// Leading text columns (everything except trailing Note).
|
||||
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
|
||||
appendCell(renderTextCell(card, i));
|
||||
}
|
||||
// Icon/flag columns — no list text; export as true/false.
|
||||
for (std::size_t i = 0; i < iconCols_.size(); ++i) {
|
||||
appendCell(isIconColumnSet(card, i) ? "true" : "false");
|
||||
}
|
||||
// Trailing Note.
|
||||
appendCell(renderTextCell(card, textCols_.size() - 1));
|
||||
return line;
|
||||
};
|
||||
|
||||
std::string payload = formatRow(cards.front());
|
||||
for (std::size_t i = 1; i < cards.size(); ++i) {
|
||||
payload.push_back('\n');
|
||||
payload.append(formatRow(cards[i]));
|
||||
}
|
||||
|
||||
wxClipboardLocker lock;
|
||||
if (!lock) return;
|
||||
if (!wxTheClipboard->SetData(
|
||||
new wxTextDataObject(wxString::FromUTF8(payload.c_str())))) {
|
||||
return;
|
||||
}
|
||||
if (cards.size() == 1) {
|
||||
emitUiStatus("Saved entry to clipboard");
|
||||
} else {
|
||||
emitUiStatus(wxString::Format("Saved %zu entries to clipboard", cards.size()));
|
||||
}
|
||||
}
|
||||
|
||||
void emitUiStatus(const wxString& message) {
|
||||
wxCommandEvent ev(EVT_UI_STATUS, GetId());
|
||||
ev.SetEventObject(this);
|
||||
ProcessWindowEvent(ev);
|
||||
ev.SetString(message);
|
||||
// Same parent-hop as BaseSelectedCardPanel::emitPreviewStatus so the
|
||||
// command event can propagate up to MainFrame's status strip.
|
||||
if (auto* parent = GetParent()) {
|
||||
parent->GetEventHandler()->ProcessEvent(ev);
|
||||
} else {
|
||||
ProcessWindowEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- members ----------------------------------------------------------
|
||||
@@ -682,6 +869,8 @@ private:
|
||||
|
||||
// Rebuild guard - see ui_wx/AGENTS.md for the burst-suppression rationale.
|
||||
bool inRebuild_{false};
|
||||
// When true, onSelectionChanged skips list_->SetFocus (filter Up/Down nudge).
|
||||
bool suppressListFocus_{false};
|
||||
|
||||
std::map<TSortColumn, bool> nextDirByCol_;
|
||||
|
||||
|
||||
@@ -307,6 +307,8 @@ private:
|
||||
case Game::DigiBattle99:
|
||||
// No stable public Digi-Battle back URL; UI uses bundled PNG.
|
||||
return {};
|
||||
case Game::YuGiOhBandai:
|
||||
return "https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -49,9 +49,14 @@ private:
|
||||
std::string setName,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool showFailureDialog);
|
||||
void requestBySetNoAsync(unsigned capturedEpoch,
|
||||
std::string setName,
|
||||
std::string setNo,
|
||||
bool showFailureDialog);
|
||||
void applyDetectedVariants(unsigned capturedEpoch,
|
||||
Result<std::vector<AutoDetectedPrint>> detected,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool fillNameOnSuccess,
|
||||
bool showFailureDialog);
|
||||
void rebuildVariantRingFromCache();
|
||||
void syncRingPositionToControls();
|
||||
|
||||
@@ -50,18 +50,20 @@ public:
|
||||
}
|
||||
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
|
||||
|
||||
void refreshCollection() override;
|
||||
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) override;
|
||||
void onAddCard(wxWindow* parentWindow) override;
|
||||
void onEditCard(wxWindow* parentWindow) override;
|
||||
void onDeleteCard(wxWindow* parentWindow) override;
|
||||
std::string onUpdateSets(wxWindow* parentWindow) override;
|
||||
void setFilter(std::string_view filter) override;
|
||||
void nudgeSelection(int delta) override;
|
||||
void applyTheme(const ThemePalette& palette) override;
|
||||
[[nodiscard]] std::string updateSetsMenuLabel() const override {
|
||||
return "Update Digimon (Digi-Battle)";
|
||||
}
|
||||
|
||||
private:
|
||||
void syncEditToolbarVisibility();
|
||||
void ensureSetsLoaded();
|
||||
const std::vector<Set>& setsForDialog();
|
||||
void ensureSingleCardsMounted(wxWindow* splitterParent);
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
class wxBitmapButton;
|
||||
class wxPanel;
|
||||
class wxWindow;
|
||||
|
||||
@@ -54,9 +57,10 @@ public:
|
||||
// list/selected panels parented onto MainFrame's shared splitter.
|
||||
[[nodiscard]] virtual bool hostsOwnLayout() const noexcept { return false; }
|
||||
|
||||
// Reload the active collection from disk and refresh the panels. The
|
||||
// selected card is preserved when possible.
|
||||
virtual void refreshCollection() = 0;
|
||||
// Reload the active collection from disk and refresh the panels. When
|
||||
// selectId is set, that card is selected if present (e.g. after Add);
|
||||
// otherwise the previously selected card is preserved when possible.
|
||||
virtual void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) = 0;
|
||||
|
||||
// Toolbar actions. `parentWindow` is the dialog owner for any modal we
|
||||
// open (typically the `MainFrame`).
|
||||
@@ -64,6 +68,10 @@ public:
|
||||
virtual void onEditCard(wxWindow* parentWindow) = 0;
|
||||
virtual void onDeleteCard(wxWindow* parentWindow) = 0;
|
||||
|
||||
// Magic uses MainFrame's shared Edit button; hostsOwnLayout games ignore
|
||||
// this and manage their own toolbar. Default no-op.
|
||||
virtual void attachSharedToolbarEdit(wxBitmapButton* edit) { (void)edit; }
|
||||
|
||||
// Sets menu action ("Update Magic" / "Update Pokemon"). Returns the
|
||||
// user-visible status string for the parent's status bar.
|
||||
virtual std::string onUpdateSets(wxWindow* parentWindow) = 0;
|
||||
@@ -71,6 +79,10 @@ public:
|
||||
// Forwarded by `MainFrame` whenever the filter input changes.
|
||||
virtual void setFilter(std::string_view filter) = 0;
|
||||
|
||||
// Move the card-list selection by `delta` rows (+1 / -1). Used when Up/Down
|
||||
// are pressed while the filter text box has focus.
|
||||
virtual void nudgeSelection(int delta) = 0;
|
||||
|
||||
// Apply the active palette to all panels owned by this view.
|
||||
virtual void applyTheme(const ThemePalette& palette) = 0;
|
||||
|
||||
|
||||
@@ -38,18 +38,21 @@ public:
|
||||
wxPanel* listPanel(wxWindow* parent) override;
|
||||
wxPanel* selectedPanel(wxWindow* parent) override;
|
||||
|
||||
void refreshCollection() override;
|
||||
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) override;
|
||||
void onAddCard(wxWindow* parentWindow) override;
|
||||
void onEditCard(wxWindow* parentWindow) override;
|
||||
void onDeleteCard(wxWindow* parentWindow) override;
|
||||
void attachSharedToolbarEdit(wxBitmapButton* edit) override;
|
||||
std::string onUpdateSets(wxWindow* parentWindow) override;
|
||||
void setFilter(std::string_view filter) override;
|
||||
void nudgeSelection(int delta) override;
|
||||
void applyTheme(const ThemePalette& palette) override;
|
||||
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Magic"; }
|
||||
|
||||
private:
|
||||
void ensureSetsLoaded();
|
||||
const std::vector<Set>& setsForDialog();
|
||||
void syncEditToolbarVisibility();
|
||||
|
||||
ConfigService& config_;
|
||||
CollectionService<MagicCard>& collection_;
|
||||
@@ -60,6 +63,7 @@ private:
|
||||
|
||||
MagicCardListPanel* listPanel_{nullptr};
|
||||
MagicSelectedCardPanel* selectedPanel_{nullptr};
|
||||
wxBitmapButton* sharedEditButton_{nullptr};
|
||||
std::vector<Set> setsCache_;
|
||||
bool attemptedInitialSetLoad_{false};
|
||||
};
|
||||
|
||||
@@ -51,10 +51,6 @@ private:
|
||||
|
||||
[[nodiscard]] IGameView* activeView();
|
||||
|
||||
#ifdef __WXMSW__
|
||||
WXLRESULT MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) override;
|
||||
#endif
|
||||
|
||||
AppContext& ctx_;
|
||||
Game activeGame_{Game::Magic};
|
||||
|
||||
|
||||
@@ -62,9 +62,14 @@ private:
|
||||
std::string setId,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool showFailureDialog);
|
||||
void requestBySetNoAsync(unsigned capturedEpoch,
|
||||
std::string setId,
|
||||
std::string setNo,
|
||||
bool showFailureDialog);
|
||||
void applyDetectedVariants(unsigned capturedEpoch,
|
||||
Result<std::vector<AutoDetectedPrint>> detected,
|
||||
bool fillSetNoOnSuccess,
|
||||
bool fillNameOnSuccess,
|
||||
bool showFailureDialog);
|
||||
void rebuildVariantRingFromCache();
|
||||
void syncRingPositionToControls();
|
||||
|
||||
@@ -55,16 +55,18 @@ public:
|
||||
}
|
||||
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
|
||||
|
||||
void refreshCollection() override;
|
||||
void refreshCollection(std::optional<std::uint32_t> selectId = std::nullopt) override;
|
||||
void onAddCard(wxWindow* parentWindow) override;
|
||||
void onEditCard(wxWindow* parentWindow) override;
|
||||
void onDeleteCard(wxWindow* parentWindow) override;
|
||||
std::string onUpdateSets(wxWindow* parentWindow) override;
|
||||
void setFilter(std::string_view filter) override;
|
||||
void nudgeSelection(int delta) override;
|
||||
void applyTheme(const ThemePalette& palette) override;
|
||||
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Pokemon"; }
|
||||
|
||||
private:
|
||||
void syncEditToolbarVisibility();
|
||||
void ensureSetsLoaded();
|
||||
const std::vector<Set>& setsForDialog(PokemonRegion region);
|
||||
void ensureSingleCardsMounted(wxWindow* splitterParent);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user