mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 19:01:13 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cf25d671f | |||
| ab0e3c5ae2 | |||
| 9917e364c1 | |||
| c9e6bc2b6b | |||
| e5c830e945 |
@@ -58,7 +58,7 @@ jobs:
|
||||
--exclude "^tests/"
|
||||
|
||||
- name: SonarQube Cloud scan
|
||||
uses: SonarSource/sonarqube-scan-action@v5
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
--exclude "^tests/"
|
||||
|
||||
- name: SonarQube Cloud scan
|
||||
uses: SonarSource/sonarqube-scan-action@v5
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -40,3 +40,6 @@ config.json
|
||||
configure.log
|
||||
build.log
|
||||
test.log
|
||||
|
||||
# Offline ETL caches (large third-party extracts)
|
||||
tools/pokemon_jp/_tcgdex_cards_database/
|
||||
|
||||
@@ -5,7 +5,7 @@ C++ desktop implementation (originally based on a Tauri Rust+TS version) — sin
|
||||
## Project structure
|
||||
|
||||
- `core/` — `ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` (Yu-Gi-Oh! offline preview fallback); `app/CMakeLists.txt` copies it to `<exeDir>/assets/` when linking `ccm`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` (offline preview fallbacks); `app/CMakeLists.txt` copies them to `<exeDir>/assets/` when linking `ccm`.
|
||||
- `app/` — `ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`.
|
||||
- `tests/` — `ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`.
|
||||
- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`.
|
||||
@@ -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`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) 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
|
||||
|
||||
|
||||
@@ -9,21 +9,38 @@ Currently, the application supports the following TCGs:
|
||||
- Magic the Gathering
|
||||
- Pokemon TCG
|
||||
- Yu-Gi-Oh!
|
||||
- Digimon (Digi-Battle)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Magic The Gathering
|
||||
<details open>
|
||||
<summary>Magic The Gathering</summary>
|
||||
|
||||

|
||||
|
||||
### Pokemon TCG
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Pokemon TCG</summary>
|
||||
|
||||

|
||||
|
||||
### Yu-Gi-Oh!
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Yu-Gi-Oh!</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Digimon (Digi-Battle)</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Migrating From CCM1 And CCM2
|
||||
|
||||
|
||||
+5
-4
@@ -5,14 +5,14 @@ The `ccm` executable — composition root only. The single place where concrete
|
||||
## File pointers
|
||||
|
||||
- `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`.
|
||||
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` there so Yu-Gi-Oh! preview fallbacks work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
|
||||
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png`, `ui_wx/assets/digibattle99_card_back.png`, and `ui_wx/assets/pokemon_jp_en_catalog.json` there so Yu-Gi-Oh! / Digi-Battle preview fallbacks and Japanese Pokémon EN names work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, 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>`, `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.
|
||||
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`).
|
||||
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`).
|
||||
5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location.
|
||||
6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way.
|
||||
7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped).
|
||||
@@ -21,7 +21,8 @@ The `ccm` executable — composition root only. The single place where concrete
|
||||
|
||||
## Required follow-ups
|
||||
|
||||
- The **`POST_BUILD` copy of `ygo_card_back.png`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`.
|
||||
- The **`POST_BUILD` copy of `ygo_card_back.png` / `digibattle99_card_back.png` / `pokemon_jp_en_catalog.json`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`.
|
||||
- On MinGW-w64 Windows, POST_BUILD also copies `libstdc++-6.dll` / `libgcc_s_seh-1.dll` / `libwinpthread-1.dll` from the compiler directory into `$<TARGET_FILE_DIR:ccm>` so the exe does not load a mismatched runtime from `PATH`.
|
||||
- After adding a new game module you **must**: (1) add a `unique_ptr<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`).
|
||||
- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`.
|
||||
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
|
||||
|
||||
+30
-2
@@ -19,9 +19,37 @@ target_link_libraries(ccm
|
||||
ccm_warnings
|
||||
)
|
||||
|
||||
# Yu-Gi-Oh! preview fallback image (used when network card-back URLs fail).
|
||||
# Yu-Gi-Oh! / Digi-Battle preview fallback images and the Japanese Pokémon
|
||||
# EN name catalog (used when network card-back URLs fail or no public URL
|
||||
# exists / for JP English display names).
|
||||
add_custom_command(TARGET ccm POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets"
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_classic"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png")
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/digibattle99_card_back.png"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_en_catalog.json"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_en_catalog.json"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_classic"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_classic")
|
||||
|
||||
# MinGW-w64: ship the toolchain runtime next to ccm3.exe so Explorer / IDE
|
||||
# launches do not pick a mismatched libstdc++ off PATH (symptoms: Entry Point
|
||||
# Not Found for __emutls_v._ZSt11__once_call in libcpr.dll).
|
||||
if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
get_filename_component(_ccm_mingw_bin "${CMAKE_CXX_COMPILER}" DIRECTORY)
|
||||
foreach(_ccm_rt_dll IN ITEMS libstdc++-6.dll libgcc_s_seh-1.dll libwinpthread-1.dll)
|
||||
if(EXISTS "${_ccm_mingw_bin}/${_ccm_rt_dll}")
|
||||
add_custom_command(TARGET ccm POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${_ccm_mingw_bin}/${_ccm_rt_dll}"
|
||||
"$<TARGET_FILE_DIR:ccm>/${_ccm_rt_dll}"
|
||||
VERBATIM)
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
+64
-7
@@ -2,11 +2,15 @@
|
||||
// it to the wxWidgets UI layer. This is the only place where concrete adapter
|
||||
// types are mentioned - everything downstream depends on interfaces.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||
#include "ccm/infra/CprHttpClient.hpp"
|
||||
#include "ccm/infra/JsonCollectionRepository.hpp"
|
||||
@@ -17,9 +21,13 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/services/CollectionService.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
#include "ccm/services/PokemonSetCatalogService.hpp"
|
||||
#include "ccm/services/YuGiOhSetCatalogService.hpp"
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
#include "ccm/services/SetService.hpp"
|
||||
#include "ccm/ui/AppContext.hpp"
|
||||
#include "ccm/ui/DigiBattle99GameView.hpp"
|
||||
#include "ccm/ui/MagicGameView.hpp"
|
||||
#include "ccm/ui/MainFrame.hpp"
|
||||
#include "ccm/ui/PokemonGameView.hpp"
|
||||
@@ -42,9 +50,11 @@ namespace {
|
||||
// need for the repositories to know about concrete game module classes.
|
||||
std::string dirNameForGame(ccm::Game g) {
|
||||
switch (g) {
|
||||
case ccm::Game::Magic: return "magic";
|
||||
case ccm::Game::Pokemon: return "pokemon";
|
||||
case ccm::Game::YuGiOh: return "yugioh";
|
||||
case ccm::Game::Magic: return "magic";
|
||||
case ccm::Game::Pokemon: return "pokemon";
|
||||
case ccm::Game::YuGiOh: return "yugioh";
|
||||
case ccm::Game::DigiBattle99: return "digibattle99";
|
||||
case ccm::Game::JapanesePokemon: return "pokemon";
|
||||
}
|
||||
return "magic";
|
||||
}
|
||||
@@ -80,6 +90,18 @@ public:
|
||||
magicMod_ = std::make_unique<ccm::MagicGameModule>(*http_);
|
||||
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
|
||||
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
|
||||
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
|
||||
|
||||
ccm::JapanesePokemonEnCatalog jpCatalog;
|
||||
{
|
||||
const auto catalogPath = exeDir / "assets" / "pokemon_jp_en_catalog.json";
|
||||
if (auto text = fs_->readText(catalogPath); text) {
|
||||
if (auto parsed = ccm::JapanesePokemonEnCatalog::parse(text.value()); parsed) {
|
||||
jpCatalog = std::move(parsed).value();
|
||||
}
|
||||
}
|
||||
}
|
||||
jpPokeMod_ = std::make_unique<ccm::JapanesePokemonGameModule>(*http_, std::move(jpCatalog));
|
||||
|
||||
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
@@ -87,7 +109,16 @@ public:
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
ygoRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
digiBattle99Repo_ =
|
||||
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
|
||||
*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);
|
||||
pokeCatalogStore_ =
|
||||
std::make_unique<ccm::PokemonSetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
|
||||
|
||||
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
|
||||
@@ -97,10 +128,15 @@ public:
|
||||
*pokeRepo_, *imgStore_);
|
||||
ygoCollSvc_ = std::make_unique<ccm::CollectionService<ccm::YuGiOhCard>>(
|
||||
*ygoRepo_, *imgStore_);
|
||||
digiBattle99CollSvc_ =
|
||||
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
|
||||
*digiBattle99Repo_, *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(jpPokeMod_.get());
|
||||
|
||||
// Disk-backed preview cache lives next to the executable, in the same
|
||||
// location scope as config.json - NOT inside the user's data-storage
|
||||
@@ -116,18 +152,29 @@ public:
|
||||
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
|
||||
*fs_,
|
||||
exeDir / ".cache" / "preview-cache");
|
||||
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_, previewCache_.get());
|
||||
previewSvc_ = std::make_unique<ccm::CardPreviewService>(
|
||||
*http_,
|
||||
previewCache_.get(),
|
||||
fs_.get(),
|
||||
exeDir / "assets");
|
||||
previewSvc_->registerModule(*magicMod_);
|
||||
previewSvc_->registerModule(*pokeMod_);
|
||||
previewSvc_->registerModule(*ygoMod_);
|
||||
previewSvc_->registerModule(*digiBattle99Mod_);
|
||||
previewSvc_->registerModule(*jpPokeMod_);
|
||||
|
||||
// Per-game UI bundles. Order here is the order shown in the Game menu.
|
||||
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
|
||||
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
|
||||
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_, *jpPokeMod_,
|
||||
*pokeCatalogStore_);
|
||||
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
|
||||
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
|
||||
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
|
||||
*ygoCatalogStore_);
|
||||
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
|
||||
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*digiBattle99Mod_, *digiBattle99CatalogStore_);
|
||||
|
||||
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
|
||||
*config_,
|
||||
@@ -137,7 +184,9 @@ public:
|
||||
*magicMod_,
|
||||
*pokeMod_,
|
||||
*ygoMod_,
|
||||
{ magicView_.get(), pokeView_.get(), ygoView_.get() },
|
||||
*digiBattle99Mod_,
|
||||
*jpPokeMod_,
|
||||
{ magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() },
|
||||
});
|
||||
|
||||
auto* frame = new ccm::ui::MainFrame(*ctx_);
|
||||
@@ -158,21 +207,29 @@ private:
|
||||
std::unique_ptr<ccm::MagicGameModule> magicMod_;
|
||||
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
|
||||
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
|
||||
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
|
||||
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::JsonSetRepository> setRepo_;
|
||||
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
|
||||
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
|
||||
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
|
||||
std::unique_ptr<ccm::LocalImageStore> imgStore_;
|
||||
std::unique_ptr<ccm::ImageService> imgSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
|
||||
std::unique_ptr<ccm::SetService> setSvc_;
|
||||
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
|
||||
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
|
||||
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
|
||||
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::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`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `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), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source.
|
||||
- `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 `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`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) 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,11 @@ add_library(ccm_core STATIC
|
||||
src/domain/MagicCard.cpp
|
||||
src/domain/PokemonCard.cpp
|
||||
src/domain/YuGiOhCard.cpp
|
||||
src/domain/DigiBattle99Card.cpp
|
||||
src/domain/DigiBattle99SetCatalog.cpp
|
||||
src/domain/YuGiOhSetCatalog.cpp
|
||||
src/domain/PokemonSetCatalog.cpp
|
||||
src/domain/JapanesePokemonCard.cpp
|
||||
src/domain/Configuration.cpp
|
||||
|
||||
src/services/ConfigService.cpp
|
||||
@@ -15,6 +20,12 @@ add_library(ccm_core STATIC
|
||||
src/services/CardPreviewService.cpp
|
||||
src/services/CardSorter.cpp
|
||||
src/services/CardFilter.cpp
|
||||
src/services/DigiBattle99SetCompletion.cpp
|
||||
src/services/DigiBattle99SetCatalogService.cpp
|
||||
src/services/YuGiOhSetCompletion.cpp
|
||||
src/services/YuGiOhSetCatalogService.cpp
|
||||
src/services/PokemonSetCompletion.cpp
|
||||
src/services/PokemonSetCatalogService.cpp
|
||||
|
||||
src/infra/CprHttpClient.cpp
|
||||
src/infra/StdFileSystem.cpp
|
||||
@@ -25,14 +36,24 @@ 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
|
||||
src/games/yugioh/YuGiOhSetSource.cpp
|
||||
src/games/yugioh/YuGiOhCardPreviewSource.cpp
|
||||
src/games/yugioh/YuGiOhGameModule.cpp
|
||||
src/games/digibattle99/DigiBattle99SetSource.cpp
|
||||
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
|
||||
src/games/digibattle99/DigiBattle99GameModule.cpp
|
||||
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
|
||||
src/games/pokemonjp/JapanesePokemonSetSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
|
||||
src/games/pokemonjp/JapanesePokemonGameModule.cpp
|
||||
|
||||
src/util/FsNames.cpp
|
||||
src/util/SetNoNatural.cpp
|
||||
)
|
||||
|
||||
target_include_directories(ccm_core
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99Card - Digimon Digi-Battle (1999 English) card model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99Card {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const DigiBattle99Card&, const DigiBattle99Card&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99Card& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99Card& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
|
||||
// completion. Filled from digimoncard.io bulk search.php (same payload as the
|
||||
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99CatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const DigiBattle99CatalogCard&,
|
||||
const DigiBattle99CatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalogPack&,
|
||||
const DigiBattle99SetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalog {
|
||||
std::vector<DigiBattle99SetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalog&,
|
||||
const DigiBattle99SetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -19,6 +20,13 @@ enum class Game {
|
||||
Magic,
|
||||
Pokemon,
|
||||
YuGiOh,
|
||||
DigiBattle99,
|
||||
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
|
||||
};
|
||||
|
||||
enum class PokemonRegion {
|
||||
West,
|
||||
Asia,
|
||||
};
|
||||
|
||||
enum class Language {
|
||||
@@ -27,8 +35,10 @@ enum class Language {
|
||||
French,
|
||||
Spanish,
|
||||
Italian,
|
||||
Chinese,
|
||||
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
|
||||
TraditionalChinese, // JSON / display: "T-Chinese"
|
||||
Japanese,
|
||||
Korean,
|
||||
Russian,
|
||||
};
|
||||
|
||||
@@ -48,24 +58,34 @@ enum class Theme {
|
||||
};
|
||||
|
||||
std::string_view to_string(Game g) noexcept;
|
||||
std::string_view to_string(PokemonRegion r) noexcept;
|
||||
std::string_view to_string(Language l) noexcept;
|
||||
std::string_view to_string(Condition c) noexcept;
|
||||
std::string_view to_string(Theme t) noexcept;
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept;
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept;
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
|
||||
const std::array<Game, 3>& allGames() noexcept;
|
||||
const std::array<Language, 8>& allLanguages() noexcept;
|
||||
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
|
||||
const std::array<Game, 4>& allGames() noexcept;
|
||||
const std::array<Language, 10>& allLanguages() noexcept;
|
||||
const std::array<Condition, 7>& allConditions() noexcept;
|
||||
const std::array<Theme, 2>& allThemes() noexcept;
|
||||
|
||||
[[nodiscard]] std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Game pokemonBackendGame(PokemonRegion r) noexcept;
|
||||
[[nodiscard]] Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept;
|
||||
|
||||
// nlohmann/json hooks - serialize as plain strings, matching Rust serde.
|
||||
void to_json(nlohmann::json& j, Game v);
|
||||
void from_json(const nlohmann::json& j, Game& v);
|
||||
|
||||
void to_json(nlohmann::json& j, PokemonRegion v);
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v);
|
||||
|
||||
void to_json(nlohmann::json& j, Language v);
|
||||
void from_json(const nlohmann::json& j, Language& v);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCard - Japanese Pokémon TCG collection model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonCard {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::Japanese};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const JapanesePokemonCard&, const JapanesePokemonCard&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c);
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,7 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCard - faithful port of pokemon/card_services.rs::Card.
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases).
|
||||
// Same established JSON shape (with `setNo` and `firstEdition` aliases),
|
||||
// plus `region` (West/Asia) for unified West+Asia collections.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
@@ -28,6 +29,7 @@ struct PokemonCard {
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
PokemonRegion region{PokemonRegion::West};
|
||||
|
||||
friend bool operator==(const PokemonCard&, const PokemonCard&) = default;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetCatalog: offline pack → card checklist for Pokemon set
|
||||
// completion. West and Asia each persist their own file under
|
||||
// `<dataStorage>/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`).
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct PokemonCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const PokemonCatalogCard&,
|
||||
const PokemonCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct PokemonSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<PokemonCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const PokemonSetCatalogPack&,
|
||||
const PokemonSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct PokemonSetCatalog {
|
||||
std::vector<PokemonSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const PokemonSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const PokemonSetCatalog&,
|
||||
const PokemonSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, PokemonCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
|
||||
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
|
||||
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhCatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const YuGiOhCatalogCard&,
|
||||
const YuGiOhCatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhSetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<YuGiOhCatalogCard> cards;
|
||||
|
||||
friend bool operator==(const YuGiOhSetCatalogPack&,
|
||||
const YuGiOhSetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct YuGiOhSetCatalog {
|
||||
std::vector<YuGiOhSetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const YuGiOhSetCatalog&,
|
||||
const YuGiOhSetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -23,6 +23,10 @@ public:
|
||||
// Implementations return a vector that has already been filtered
|
||||
// (e.g. no digital-only sets) and sorted by release date ascending.
|
||||
virtual Result<std::vector<Set>> fetchAll() = 0;
|
||||
|
||||
// Optional post-process for locally cached set lists (e.g. inject products
|
||||
// the upstream API omits). Default is a no-op. Called by SetService::getSets.
|
||||
virtual void augmentCachedSets(std::vector<Set>& /*sets*/) const {}
|
||||
};
|
||||
|
||||
class IGameModule {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99CardPreviewSource: digimoncard.io search + CDN card images for
|
||||
// Digimon Digi-Battle (1999 English).
|
||||
//
|
||||
// Preview key middle slot is Set.name (pack display name) so search.php?pack=
|
||||
// works without a reverse slug map. When setNo is present, the CDN URL is
|
||||
// built directly — no search round-trip.
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99CardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
static constexpr const char* kImageBase =
|
||||
"https://images.digimoncard.io/images/cards/";
|
||||
|
||||
explicit DigiBattle99CardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setName) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setName) 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);
|
||||
|
||||
// CDN preview URL for a normalized card id (.jpg — wxImage registers
|
||||
// JPEG/PNG only; digimoncard.io also serves .webp but we cannot decode it).
|
||||
static std::string buildImageUrl(std::string_view setNo);
|
||||
|
||||
// digimoncard.io search URL: n= / pack= / series= / optional card=.
|
||||
// setName is the pack display name (Set.name), not the slug id.
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo);
|
||||
|
||||
// Parse a digimoncard.io search.php body into a CDN image URL for the
|
||||
// first exact name match (optional pack filter applied by the request).
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99GameModule: Digimon Digi-Battle (1999 English) via digimoncard.io.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99GameModule final : public IGameModule {
|
||||
public:
|
||||
explicit DigiBattle99GameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::DigiBattle99; }
|
||||
[[nodiscard]] std::string dirName() const override { return "digibattle99"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
DigiBattle99SetSource setSource_;
|
||||
DigiBattle99CardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
|
||||
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
|
||||
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
|
||||
// The same payload also builds the set-completion catalog (parseCatalog).
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99SetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint =
|
||||
"https://digimoncard.io/api-public/search.php?"
|
||||
"series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc";
|
||||
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
DigiBattle99SetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit DigiBattle99SetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// One HTTP round-trip producing both the set list and the pack catalog.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parsers exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
|
||||
|
||||
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
|
||||
static std::string slugifyPackName(std::string_view packName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,11 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
|
||||
// TCG. Calls the Pokemon TCG search endpoint at
|
||||
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
|
||||
// and returns `data[0].images.large` (with `images.small` as a graceful
|
||||
// fallback). Mirrors the established `getImage` flow in
|
||||
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
|
||||
// PokemonCardPreviewSource: West Pokemon previews via TCGdex EN.
|
||||
// Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then
|
||||
// set-detail name match for auto-detect. Image URLs append /high.png (wxImage
|
||||
// decodes PNG, not webp).
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
@@ -31,27 +29,33 @@ public:
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) override;
|
||||
|
||||
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
|
||||
// Exposed for unit testing and to keep encoding rules in one place.
|
||||
// Strip everything after the first '/' (e.g. "4/102" -> "4").
|
||||
static std::string normalizeCollectorNumber(std::string_view setNo);
|
||||
|
||||
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo);
|
||||
static std::string imageUrlFromBase(std::string_view imageBase);
|
||||
|
||||
// Slimmer search URL for auto-detect: omits the number clause and asks the
|
||||
// API for only the fields the print-variant parser needs.
|
||||
static std::string buildDetectSearchUrl(std::string_view name,
|
||||
std::string_view setId);
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string name;
|
||||
std::string imageBase;
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
|
||||
// for the first matching card. Prefers `images.large`, falls back to
|
||||
// `images.small`. Errors are classified:
|
||||
// - JSON parse failure or missing/non-array `data` => Transient.
|
||||
// - Empty `data` array or missing image variants => NotFound.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseResponse(const std::string& body);
|
||||
parseCardByIdResponse(const std::string& body);
|
||||
|
||||
// Parse a slim TCGdex cards-array search response; prefer first hit with image.
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseSearchResponse(const std::string& body);
|
||||
|
||||
// Enumerate distinct collector numbers (and rarities) for an exact card
|
||||
// name inside the chosen set. Exposed for unit testing without HTTP.
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
// Sync Pokemon collection cards against freshly fetched set lists:
|
||||
// - West: canonicalize legacy pokemontcg set ids, then refresh name/date
|
||||
// - Asia: refresh name/date when the set id is present in the Asia list
|
||||
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Mutates cards in place. Returns how many cards changed at least one set field.
|
||||
[[nodiscard]] std::size_t syncPokemonCollectionSets(
|
||||
std::vector<PokemonCard>& cards,
|
||||
const std::vector<Set>& westSets,
|
||||
const std::vector<Set>& asiaSets);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,7 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
|
||||
// and card preview source, both backed by api.pokemontcg.io/v2.
|
||||
// and card preview source, both backed by TCGdex EN (api.tcgdex.net/v2/en).
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
|
||||
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
|
||||
// response into our `Set` domain type, and sorts by release date ascending.
|
||||
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
|
||||
// so no rewriting is needed (unlike Scryfall's `released_at`).
|
||||
// Behavior matches `pokemon/set_services.rs::update_sets`.
|
||||
// PokemonSetSource: ISetSource for West Pokemon via TCGdex EN
|
||||
// (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release
|
||||
// dates and set-completion checklists come from per-set detail GETs.
|
||||
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
PokemonSetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit PokemonSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
// List + per-set detail (cards + release date) for the offline checklist.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parsers exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseListResponse(const std::string& body);
|
||||
static Result<std::string> parseReleaseDate(const std::string& detailBody);
|
||||
static std::string rewriteReleaseDate(std::string_view isoDate);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
|
||||
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
// Canonicalize legacy pokemontcg.io West set ids to TCGdex EN ids.
|
||||
// Identity when the id is already TCGdex (or unknown). Asia set ids must not
|
||||
// be passed through this helper.
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
// Returns the TCGdex EN set id for a West Pokemon card.set.id. Unknown ids
|
||||
// and ids that already match TCGdex are returned unchanged.
|
||||
[[nodiscard]] std::string canonicalizeWestSetId(std::string_view setId);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonCardPreviewSource: TCGdex ja localId-based preview + variants.
|
||||
// Image URLs use /high.png (wxImage decodes PNG/JPEG, not webp).
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonCardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
JapanesePokemonCardPreviewSource(IHttpClient& http,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
[[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;
|
||||
|
||||
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);
|
||||
static std::string imageUrlFromBase(std::string_view imageBase);
|
||||
|
||||
// Parse set-detail body; optionally filter by name (EN catalog / JA) and/or localId.
|
||||
struct SetCardRow {
|
||||
std::string localId;
|
||||
std::string nameJa;
|
||||
std::string imageBase; // empty when TCGdex has no scan
|
||||
std::string rarity;
|
||||
};
|
||||
|
||||
static Result<std::vector<SetCardRow>, PreviewLookupError>
|
||||
parseSetCards(const std::string& body);
|
||||
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseCardImageUrl(const std::string& body);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
// Catalog-only Auto-detect when TCGdex has no set detail (theme decks, etc.).
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
detectPrintVariantsFromCatalog(std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonEnCatalog - bundled English name layer for Japanese Pokémon.
|
||||
// Loaded from assets/pokemon_jp_en_catalog.json (generated offline). Missing
|
||||
// entries fall through to TCGdex Japanese names at runtime.
|
||||
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct JapanesePokemonSetEnInfo {
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string releaseDate; // YYYY/MM/DD when known; may be empty
|
||||
};
|
||||
|
||||
struct JapanesePokemonPrintEnInfo {
|
||||
std::string setId;
|
||||
std::string localId;
|
||||
std::string nameEn;
|
||||
std::string nameJa;
|
||||
std::string nameEnSource; // bulbapedia | species-table | manual
|
||||
// Classic JA gap-fill when TCGdex has no CDN scan (optional).
|
||||
std::string imageUrl; // explicit HTTPS URL, preferred when set
|
||||
std::string tcgplayerId; // TCGPlayer product id → product-images CDN
|
||||
};
|
||||
|
||||
class JapanesePokemonEnCatalog {
|
||||
public:
|
||||
[[nodiscard]] static Result<JapanesePokemonEnCatalog>
|
||||
parse(const std::string& jsonBody);
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept {
|
||||
return sets_.empty() && printsByKey_.empty();
|
||||
}
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonSetEnInfo>
|
||||
findSet(std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] std::optional<JapanesePokemonPrintEnInfo>
|
||||
findPrint(std::string_view setId, std::string_view localId) const;
|
||||
|
||||
// Case-insensitive match of nameEn or nameJa within a set.
|
||||
// Also matches qualified English titles: wanted "Mewtwo" hits
|
||||
// "Mewtwo (CoroCoro promo)" (prefix + " (").
|
||||
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
|
||||
findPrintsByName(std::string_view setId, std::string_view cardName) const;
|
||||
|
||||
[[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept;
|
||||
|
||||
// All prints for a set (catalog gap-fill / set-completion checklists).
|
||||
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
|
||||
printsForSet(std::string_view setId) const;
|
||||
|
||||
// TCGPlayer product-image CDN URL for classic JA gap-fill.
|
||||
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
|
||||
|
||||
// Prefer imageUrl; else build from tcgplayerId; else empty.
|
||||
[[nodiscard]] static std::string previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print);
|
||||
|
||||
private:
|
||||
std::unordered_map<std::string, JapanesePokemonSetEnInfo> sets_;
|
||||
std::unordered_map<std::string, JapanesePokemonPrintEnInfo> printsByKey_;
|
||||
// setId -> print keys for name scans
|
||||
std::unordered_map<std::string, std::vector<std::string>> printKeysBySet_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonGameModule: Japanese Pokémon TCG via TCGdex ja + EN catalog.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonGameModule final : public IGameModule {
|
||||
public:
|
||||
explicit JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog = {});
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::JapanesePokemon; }
|
||||
[[nodiscard]] std::string dirName() const override { return "pokemon"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Pokemon (Japan)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
[[nodiscard]] const JapanesePokemonEnCatalog& catalog() const noexcept {
|
||||
return catalog_;
|
||||
}
|
||||
|
||||
private:
|
||||
JapanesePokemonEnCatalog catalog_;
|
||||
JapanesePokemonSetSource setSource_;
|
||||
JapanesePokemonCardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
|
||||
// dates and set-completion checklists. English display names come from
|
||||
// JapanesePokemonEnCatalog when present.
|
||||
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class JapanesePokemonSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
PokemonSetCatalog catalog;
|
||||
};
|
||||
|
||||
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// List + per-set detail (cards + release date) + EN catalog gap-fill.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
void augmentCachedSets(std::vector<Set>& sets) const override;
|
||||
|
||||
// Pure parsers for hermetic tests.
|
||||
static Result<std::vector<Set>> parseListResponse(const std::string& body);
|
||||
static Result<std::string> parseReleaseDate(const std::string& detailBody);
|
||||
static bool shouldExcludeSetId(std::string_view setId) noexcept;
|
||||
static std::string applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa);
|
||||
static std::string rewriteReleaseDate(std::string_view isoDate);
|
||||
static std::string buildSetDetailUrl(std::string_view setId);
|
||||
|
||||
// Build one pack checklist from a set-detail body, then gap-fill from catalog.
|
||||
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set,
|
||||
const JapanesePokemonEnCatalog& enCatalog);
|
||||
|
||||
// Catalog-only pack (classic products with no TCGdex detail).
|
||||
static PokemonSetCatalogPack catalogPackFromEnCatalog(
|
||||
const Set& set, const JapanesePokemonEnCatalog& enCatalog);
|
||||
|
||||
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
|
||||
static void appendMissingClassicProducts(std::vector<Set>& sets);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
const JapanesePokemonEnCatalog& catalog_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,21 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
|
||||
// Sets come from cardsets.php; the set-completion catalog is built from the
|
||||
// unfiltered cardinfo.php dump (card_sets[] per card).
|
||||
|
||||
#include "ccm/domain/Set.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhSetSource final : public ISetSource {
|
||||
public:
|
||||
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
|
||||
static constexpr const char* kCardInfoEndpoint =
|
||||
"https://db.ygoprodeck.com/api/v7/cardinfo.php";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
YuGiOhSetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit YuGiOhSetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for
|
||||
// the pack checklist catalog.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
|
||||
// Build the offline checklist from a cardinfo.php body, resolving pack
|
||||
// ids against the already-parsed sets list (by set_name → Set.id).
|
||||
static Result<YuGiOhSetCatalog> parseCatalog(const std::string& body,
|
||||
const std::vector<Set>& sets);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
|
||||
// JsonSetRepository: persists vector<Set> under `<dataStorage>/<dirName>/`.
|
||||
// Most games use `sets.json`. Pokemon West/Asia share dir `pokemon` with
|
||||
// `sets-west.json` / `sets-asia.json` (migrate-on-load from legacy paths).
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
@@ -27,6 +29,8 @@ private:
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path setsPath(Game game) const;
|
||||
[[nodiscard]] std::filesystem::path legacySetsPath(Game game) const;
|
||||
[[nodiscard]] Result<std::vector<Set>> parseSetsText(const std::string& text) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
// ISetRepository - persistence port for the cached `sets.json` of a game.
|
||||
// ISetRepository - persistence port for the cached set list of a game.
|
||||
// Typical layout: `<dataStorage>/<dirName>/sets.json`. Pokemon West/Asia use
|
||||
// `sets-west.json` / `sets-asia.json` under the shared `pokemon/` directory.
|
||||
// Stored as a flat list to mirror the original Rust file layout.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
// * An empty filter matches every row, exactly as in JS where every string
|
||||
// `.includes("")` returns true.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
@@ -34,11 +36,19 @@ namespace ccm {
|
||||
std::string_view filter);
|
||||
|
||||
// Pokemon value-key columns from PokemonTable.tsx tableFields list:
|
||||
// name, set.name, setNo, language, condition, amount, note.
|
||||
// name, set.name, setNo, language, condition, amount, note, region.
|
||||
// Holo/FirstEdition/Signed/Altered are bool-typed and excluded.
|
||||
[[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card,
|
||||
std::string_view filter);
|
||||
[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Digi-Battle mirrors Pokemon searchable columns (includes setNo).
|
||||
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
|
||||
std::string_view filter);
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
|
||||
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
|
||||
std::string_view filter);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -32,7 +34,9 @@ namespace ccm {
|
||||
class CardPreviewService {
|
||||
public:
|
||||
explicit CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache = nullptr);
|
||||
IPreviewByteCache* persistentCache = nullptr,
|
||||
IFileSystem* fs = nullptr,
|
||||
std::filesystem::path assetRoot = {});
|
||||
|
||||
// Register a game module's preview source. Calling this with a module
|
||||
// whose `cardPreviewSource()` returns nullptr is a no-op (the game has
|
||||
@@ -96,6 +100,9 @@ private:
|
||||
|
||||
Result<std::string> fetchAndCache(const std::string& cacheKey,
|
||||
std::string_view url);
|
||||
Result<std::string, PreviewLookupError> fetchAssetAndCache(
|
||||
const std::string& cacheKey,
|
||||
std::string_view assetUrl);
|
||||
|
||||
// Returns the kind of in-memory cache entry for `key`. On Hit the
|
||||
// payload is copied into `outPayload`; on NegativeHit `outPayload` is
|
||||
@@ -107,6 +114,8 @@ private:
|
||||
|
||||
IHttpClient& http_;
|
||||
IPreviewByteCache* persistentCache_{nullptr};
|
||||
IFileSystem* fs_{nullptr};
|
||||
std::filesystem::path assetRoot_;
|
||||
std::unordered_map<Game, ICardPreviewSource*> sources_;
|
||||
|
||||
// LRU: list holds entries in MRU-first order; map points at list nodes
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// UI relies on it so successive clicks on different columns compose predictably
|
||||
// (e.g. sort by name, then by set => grouped by set, name-sorted within each).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
@@ -66,6 +68,34 @@ enum class YuGiOhSortColumn {
|
||||
Note,
|
||||
};
|
||||
|
||||
// Digi-Battle mirrors Pokemon columns (setNo is filter-only, not a sort column).
|
||||
enum class DigiBattle99SortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Japanese Pokemon mirrors Pokemon columns.
|
||||
enum class JapanesePokemonSortColumn {
|
||||
Name,
|
||||
SetReleaseDate,
|
||||
Language,
|
||||
Condition,
|
||||
Amount,
|
||||
Holo,
|
||||
FirstEdition,
|
||||
Signed,
|
||||
Altered,
|
||||
Note,
|
||||
};
|
||||
|
||||
// Stable in-place sort. `ascending=false` runs the same comparator with
|
||||
// inverted sign, matching `byField(field, asc)` semantics.
|
||||
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
|
||||
@@ -74,5 +104,11 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
bool ascending);
|
||||
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
bool ascending);
|
||||
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
DigiBattle99SortColumn column,
|
||||
bool ascending);
|
||||
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||
JapanesePokemonSortColumn column,
|
||||
bool ascending);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -80,6 +80,15 @@ public:
|
||||
return repo_.save(game, map);
|
||||
}
|
||||
|
||||
// Replace the entire collection map in one save (e.g. after bulk set-id sync).
|
||||
Result<void> saveAll(Game game, std::vector<TCard> cards) {
|
||||
Map map;
|
||||
for (auto& card : cards) {
|
||||
map.insert_or_assign(card.id, std::move(card));
|
||||
}
|
||||
return repo_.save(game, map);
|
||||
}
|
||||
|
||||
// Remove the card with the given id. Also deletes any associated images
|
||||
// via the IImageStore (best-effort - image removal failures are logged in
|
||||
// the error string but the card itself is still purged from the JSON).
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
|
||||
// the configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99SetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<DigiBattle99SetCatalog> load() const;
|
||||
Result<void> save(const DigiBattle99SetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
|
||||
// Ownership counts only when collection card.set.id matches the pack and the
|
||||
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
|
||||
// inflate the numerator. An optional languageFilter restricts ownership to
|
||||
// cards of that language (packs with zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99SetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct DigiBattle99ChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection, in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection);
|
||||
|
||||
// Packs where the collection owns ≥1 card with matching set.id, ordered by
|
||||
// setName. Packs absent from the catalog are skipped. When languageFilter is
|
||||
// set, only cards of that language count toward ownership.
|
||||
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
|
||||
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
// When languageFilter is set, only cards of that language count as owned.
|
||||
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
|
||||
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and
|
||||
// pokemon/set-catalog-asia.json under the configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class PokemonSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<PokemonSetCatalog> load(PokemonRegion region) const;
|
||||
Result<void> save(PokemonRegion region, const PokemonSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists(PokemonRegion region) const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Pokemon set-completion progress and per-set checklists.
|
||||
// Ownership requires matching PokemonRegion for the pack (West vs Asia),
|
||||
// matching set.id, and a normalized collector number / localId. Duplicates /
|
||||
// amount / holo / firstEdition do not inflate the numerator. Optional
|
||||
// regionFilter and languageFilter restrict which cards count (packs with
|
||||
// zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct PokemonSetCompletionProgress {
|
||||
PokemonRegion region{PokemonRegion::West};
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::string releaseDate; // YYYY/MM/DD from owned cards; may be empty
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct PokemonChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection (optionally region-scoped),
|
||||
// in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
|
||||
std::optional<PokemonRegion> regionFilter = std::nullopt);
|
||||
|
||||
// Distinct regions that have ≥1 owned card matching a catalog pack.
|
||||
[[nodiscard]] std::vector<PokemonRegion>
|
||||
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog);
|
||||
|
||||
// Packs where the collection owns ≥1 matching card, ordered by releaseDate
|
||||
// then setName then region. When regionFilter is set, only that region's
|
||||
// catalog/cards count.
|
||||
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
|
||||
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
std::optional<PokemonRegion> regionFilter = std::nullopt,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
[[nodiscard]] std::vector<PokemonChecklistEntry>
|
||||
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
PokemonRegion region,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -27,6 +27,10 @@ public:
|
||||
// repository, and return the new list.
|
||||
Result<std::vector<Set>> updateSets(Game game);
|
||||
|
||||
// Persist an already-fetched set list (no HTTP). Used when a game-specific
|
||||
// Update Sets path fetches sets + side payloads in one round-trip.
|
||||
Result<void> saveSets(Game game, const std::vector<Set>& sets);
|
||||
|
||||
// Cached read; returns an error if no local data exists yet.
|
||||
Result<std::vector<Set>> getSets(Game game);
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
|
||||
// configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class YuGiOhSetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<YuGiOhSetCatalog> load() const;
|
||||
Result<void> save(const YuGiOhSetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
|
||||
// Ownership counts only when collection card.set.id matches the pack and the
|
||||
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
|
||||
// amount / rarity / firstEdition do not inflate the numerator. An optional
|
||||
// languageFilter restricts ownership to cards of that language (packs with
|
||||
// zero matches are omitted).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct YuGiOhSetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct YuGiOhChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Distinct languages present in the collection, in allLanguages() order.
|
||||
[[nodiscard]] std::vector<Language>
|
||||
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
|
||||
|
||||
// Packs where the collection owns ≥1 card with matching set.id, ordered by
|
||||
// setName. Packs absent from the catalog are skipped. When languageFilter is
|
||||
// set, only cards of that language count toward ownership.
|
||||
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
|
||||
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
// When languageFilter is set, only cards of that language count as owned.
|
||||
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
|
||||
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter = std::nullopt);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,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
|
||||
@@ -13,6 +13,11 @@ void to_json(nlohmann::json& j, const Configuration& c) {
|
||||
void from_json(const nlohmann::json& j, Configuration& c) {
|
||||
j.at("dataStorage").get_to(c.dataStorage);
|
||||
j.at("defaultGame").get_to(c.defaultGame);
|
||||
// JapanesePokemon was folded into Pokemon (West/Asia region). Coerce so
|
||||
// older config.json files keep a valid user-facing default game.
|
||||
if (c.defaultGame == Game::JapanesePokemon) {
|
||||
c.defaultGame = Game::Pokemon;
|
||||
}
|
||||
c.theme = j.value("theme", Theme::Light);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99Card& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99Card& 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("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("firstEdition").get_to(c.firstEdition);
|
||||
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,40 @@
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack(
|
||||
std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
+86
-31
@@ -13,23 +13,35 @@ namespace ccm {
|
||||
|
||||
std::string_view to_string(Game g) noexcept {
|
||||
switch (g) {
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
case Game::Magic: return "Magic";
|
||||
case Game::Pokemon: return "Pokemon";
|
||||
case Game::YuGiOh: return "YuGiOh";
|
||||
case Game::DigiBattle99: return "DigiBattle99";
|
||||
case Game::JapanesePokemon: return "JapanesePokemon";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(PokemonRegion r) noexcept {
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return "West";
|
||||
case PokemonRegion::Asia: return "Asia";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
|
||||
std::string_view to_string(Language l) noexcept {
|
||||
switch (l) {
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::Chinese: return "Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Russian: return "Russian";
|
||||
case Language::English: return "English";
|
||||
case Language::German: return "German";
|
||||
case Language::French: return "French";
|
||||
case Language::Spanish: return "Spanish";
|
||||
case Language::Italian: return "Italian";
|
||||
case Language::SimplifiedChinese: return "S-Chinese";
|
||||
case Language::TraditionalChinese: return "T-Chinese";
|
||||
case Language::Japanese: return "Japanese";
|
||||
case Language::Korean: return "Korean";
|
||||
case Language::Russian: return "Russian";
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
}
|
||||
@@ -56,21 +68,33 @@ std::string_view to_string(Theme t) noexcept {
|
||||
}
|
||||
|
||||
std::optional<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "DigiBattle99") return Game::DigiBattle99;
|
||||
if (s == "JapanesePokemon") return Game::JapanesePokemon;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept {
|
||||
if (s == "West") return PokemonRegion::West;
|
||||
if (s == "Asia") return PokemonRegion::Asia;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Language> languageFromString(std::string_view s) noexcept {
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "Chinese") return Language::Chinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
if (s == "English") return Language::English;
|
||||
if (s == "German") return Language::German;
|
||||
if (s == "French") return Language::French;
|
||||
if (s == "Spanish") return Language::Spanish;
|
||||
if (s == "Italian") return Language::Italian;
|
||||
if (s == "S-Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "T-Chinese") return Language::TraditionalChinese;
|
||||
// Legacy single Chinese spelling → Simplified.
|
||||
if (s == "Chinese") return Language::SimplifiedChinese;
|
||||
if (s == "Japanese") return Language::Japanese;
|
||||
if (s == "Korean") return Language::Korean;
|
||||
if (s == "Russian") return Language::Russian;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -91,15 +115,17 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::array<Game, 3>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
|
||||
const std::array<Game, 4>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 4> v{
|
||||
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99};
|
||||
return v;
|
||||
}
|
||||
|
||||
const std::array<Language, 8>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 8> v{
|
||||
const std::array<Language, 10>& allLanguages() noexcept {
|
||||
static constexpr std::array<Language, 10> v{
|
||||
Language::English, Language::German, Language::French, Language::Spanish,
|
||||
Language::Italian, Language::Chinese, Language::Japanese, Language::Russian
|
||||
Language::Italian, Language::SimplifiedChinese, Language::TraditionalChinese,
|
||||
Language::Japanese, Language::Korean, Language::Russian
|
||||
};
|
||||
return v;
|
||||
}
|
||||
@@ -117,16 +143,45 @@ const std::array<Theme, 2>& allThemes() noexcept {
|
||||
return v;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
std::span<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept {
|
||||
static constexpr std::array<Language, 6> kWest{
|
||||
Language::English, Language::German, Language::French,
|
||||
Language::Spanish, Language::Italian, Language::Russian};
|
||||
static constexpr std::array<Language, 4> kAsia{
|
||||
Language::Japanese, Language::SimplifiedChinese,
|
||||
Language::TraditionalChinese, Language::Korean};
|
||||
switch (r) {
|
||||
case PokemonRegion::West: return kWest;
|
||||
case PokemonRegion::Asia: return kAsia;
|
||||
}
|
||||
CCM_UNREACHABLE();
|
||||
return kWest;
|
||||
}
|
||||
|
||||
Game pokemonBackendGame(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Game::JapanesePokemon : Game::Pokemon;
|
||||
}
|
||||
|
||||
Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept {
|
||||
return r == PokemonRegion::Asia ? Language::Japanese : Language::English;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, PokemonRegion v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); }
|
||||
void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); }
|
||||
|
||||
void from_json(const nlohmann::json& j, Game& v) {
|
||||
auto parsed = gameFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, PokemonRegion& v) {
|
||||
auto parsed = pokemonRegionFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown PokemonRegion value: " + j.get<std::string>());
|
||||
v = *parsed;
|
||||
}
|
||||
void from_json(const nlohmann::json& j, Language& v) {
|
||||
auto parsed = languageFromString(j.get<std::string>());
|
||||
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
void to_json(nlohmann::json& j, const JapanesePokemonCard& c) {
|
||||
j = nlohmann::json{
|
||||
{"id", c.id},
|
||||
{"amount", c.amount},
|
||||
{"name", c.name},
|
||||
{"set", c.set},
|
||||
{"setNo", c.setNo},
|
||||
{"note", c.note},
|
||||
{"images", c.images},
|
||||
{"language", c.language},
|
||||
{"condition", c.condition},
|
||||
{"firstEdition", c.firstEdition},
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, JapanesePokemonCard& 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("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("firstEdition").get_to(c.firstEdition);
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
j.at("altered").get_to(c.altered);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -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) {
|
||||
@@ -17,6 +19,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
|
||||
{"holo", c.holo},
|
||||
{"signed", c.signed_},
|
||||
{"altered", c.altered},
|
||||
{"region", c.region},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,6 +37,13 @@ void from_json(const nlohmann::json& j, PokemonCard& c) {
|
||||
j.at("holo").get_to(c.holo);
|
||||
j.at("signed").get_to(c.signed_);
|
||||
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/PokemonSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const PokemonSetCatalogPack* PokemonSetCatalog::findPack(std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const PokemonSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, PokemonSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,222 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool cardInPack(const nlohmann::json& card, std::string_view packName) {
|
||||
if (packName.empty()) return true;
|
||||
if (!card.contains("set_name") || !card.at("set_name").is_array()) return false;
|
||||
for (const auto& pack : card.at("set_name")) {
|
||||
if (pack.is_string() && pack.get<std::string>() == packName) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
|
||||
: http_(http) {}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::normalizeCardNumber(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
if (s.empty()) return s;
|
||||
// Uppercase leading alphabetic prefix (ST / BO / MO / Fx-style).
|
||||
std::size_t i = 0;
|
||||
while (i < s.size() && std::isalpha(static_cast<unsigned char>(s[i]))) {
|
||||
s[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(s[i])));
|
||||
++i;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildImageUrl(std::string_view setNo) {
|
||||
const std::string id = normalizeCardNumber(setNo);
|
||||
return std::string(kImageBase) + id + ".jpg";
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
std::string url = "https://digimoncard.io/api-public/search.php?series=";
|
||||
url += rfc3986PercentEncode(kSeries);
|
||||
if (!name.empty()) {
|
||||
url += "&n=";
|
||||
url += rfc3986PercentEncode(name);
|
||||
}
|
||||
if (!setName.empty()) {
|
||||
url += "&pack=";
|
||||
url += rfc3986PercentEncode(setName);
|
||||
}
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
url += "&card=";
|
||||
url += rfc3986PercentEncode(num);
|
||||
}
|
||||
url += "&sort=name&sortdirection=asc";
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err({K::NotFound, j.value("error", std::string{"No cards found."})});
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return R::err({K::Transient, "digimoncard.io Digi-Battle response is not a JSON array."});
|
||||
}
|
||||
if (j.empty()) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
|
||||
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
|
||||
const nlohmann::json* chosen = nullptr;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedLower) continue;
|
||||
}
|
||||
chosen = &card;
|
||||
break;
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
const std::string id = normalizeCardNumber(chosen->value("id", ""));
|
||||
if (id.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle card has no id / card number."});
|
||||
}
|
||||
return R::ok(buildImageUrl(id));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
return R::ok(buildImageUrl(num));
|
||||
}
|
||||
if (name.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle preview requires a card name or set number."});
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseImageUrlFromSearch(resp.value(), name);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err(j.value("error", std::string{"No cards found."}));
|
||||
}
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
const std::string wantedPack = trim(std::string(setName));
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) continue;
|
||||
}
|
||||
if (!cardInPack(card, wantedPack)) continue;
|
||||
AutoDetectedPrint out;
|
||||
out.setNo = normalizeCardNumber(card.value("id", ""));
|
||||
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
|
||||
if (out.setNo.empty()) continue;
|
||||
collected.push_back(std::move(out));
|
||||
}
|
||||
|
||||
if (collected.empty()) {
|
||||
if (!wantedNameLower.empty() && !wantedPack.empty()) {
|
||||
return R::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> deduped;
|
||||
deduped.reserve(collected.size());
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(collected.size() * 2);
|
||||
for (auto& p : collected) {
|
||||
if (seen.insert(p.setNo).second) deduped.push_back(std::move(p));
|
||||
}
|
||||
return R::ok(std::move(deduped));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectFirstPrint(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
auto list = detectPrintVariants(name, setName);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setName, name);
|
||||
}
|
||||
// Retry name-only; still filter by pack in parsePrintVariants.
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "", "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setName, name);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
DigiBattle99GameModule::DigiBattle99GameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,206 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Curated EN release dates for the vintage Digi-Battle product line.
|
||||
// Series 1 Starter is verified 1999-06-01; other entries use digimoncard.io /
|
||||
// checklist years (day unknown -> YYYY/01/01 or mid-year anchors for ordering).
|
||||
const std::unordered_map<std::string, std::string>& curatedReleaseDates() {
|
||||
static const std::unordered_map<std::string, std::string> kDates{
|
||||
{"Series 1 Starter Set", "1999/06/01"},
|
||||
{"Series 1 Booster Pack", "1999/06/01"},
|
||||
{"Series 2 Booster Pack", "1999/09/01"},
|
||||
{"Series 3 Booster Pack", "2000/01/01"},
|
||||
{"Series 4 Booster Pack", "2000/06/01"},
|
||||
{"Series 5 Booster Pack", "2000/10/01"},
|
||||
{"Series 6 Booster Pack", "2001/01/01"},
|
||||
{"Street Starter Set 1", "2001/01/01"},
|
||||
{"Street Starter Set 2", "2001/02/01"},
|
||||
{"Street Starter Set 3", "2001/03/01"},
|
||||
{"Street Starter Set 4", "2001/04/01"},
|
||||
{"Digimon The Movie Promo Cards", "2000/10/01"},
|
||||
};
|
||||
return kDates;
|
||||
}
|
||||
|
||||
std::string releaseDateForPack(const std::string& packName) {
|
||||
const auto& dates = curatedReleaseDates();
|
||||
const auto it = dates.find(packName);
|
||||
if (it != dates.end()) return it->second;
|
||||
return {};
|
||||
}
|
||||
|
||||
Result<nlohmann::json> parseSearchArray(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return Result<nlohmann::json>::err(
|
||||
j.value("error", std::string{"digimoncard.io set search error"}));
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return Result<nlohmann::json>::err(
|
||||
"digimoncard.io Digi-Battle response is not a JSON array.");
|
||||
}
|
||||
return Result<nlohmann::json>::ok(j);
|
||||
} catch (const std::exception& e) {
|
||||
return Result<nlohmann::json>::err(
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
|
||||
std::string out;
|
||||
out.reserve(packName.size());
|
||||
bool pendingHyphen = false;
|
||||
for (unsigned char ch : packName) {
|
||||
if (std::isalnum(ch)) {
|
||||
if (pendingHyphen && !out.empty()) out.push_back('-');
|
||||
pendingHyphen = false;
|
||||
out.push_back(static_cast<char>(std::tolower(ch)));
|
||||
} else {
|
||||
pendingHyphen = !out.empty();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<std::vector<Set>>::err(arr.error());
|
||||
|
||||
// Preserve first-seen order of pack names, then sort by release date.
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<std::string> packNames;
|
||||
packNames.reserve(16);
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string name = pack.get<std::string>();
|
||||
if (name.empty()) continue;
|
||||
if (seen.insert(name).second) packNames.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Set> out;
|
||||
out.reserve(packNames.size());
|
||||
for (const auto& name : packNames) {
|
||||
Set s;
|
||||
s.id = slugifyPackName(name);
|
||||
s.name = name;
|
||||
s.releaseDate = releaseDateForPack(name);
|
||||
if (s.id.empty()) continue;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
|
||||
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
|
||||
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
|
||||
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetCatalog> DigiBattle99SetSource::parseCatalog(const std::string& body) {
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<DigiBattle99SetCatalog>::err(arr.error());
|
||||
|
||||
// pack display name -> (setId, ordered unique cards by first-seen setNo)
|
||||
struct PackBuild {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::unordered_set<std::string> seenNos;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
};
|
||||
std::unordered_map<std::string, PackBuild> byName;
|
||||
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("name") || !entry.at("name").is_string()) continue;
|
||||
if (!entry.contains("id") || !entry.at("id").is_string()) continue;
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
|
||||
DigiBattle99CatalogCard card;
|
||||
card.name = entry.at("name").get<std::string>();
|
||||
card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(
|
||||
entry.at("id").get<std::string>());
|
||||
if (card.setNo.empty()) continue;
|
||||
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string packName = pack.get<std::string>();
|
||||
if (packName.empty()) continue;
|
||||
|
||||
auto& build = byName[packName];
|
||||
if (build.setName.empty()) {
|
||||
build.setName = packName;
|
||||
build.setId = slugifyPackName(packName);
|
||||
}
|
||||
if (build.setId.empty()) continue;
|
||||
if (!build.seenNos.insert(card.setNo).second) continue;
|
||||
build.cards.push_back(card);
|
||||
}
|
||||
}
|
||||
|
||||
DigiBattle99SetCatalog catalog;
|
||||
catalog.packs.reserve(byName.size());
|
||||
for (auto& [_, build] : byName) {
|
||||
if (build.setId.empty()) continue;
|
||||
std::sort(build.cards.begin(), build.cards.end(),
|
||||
[](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
DigiBattle99SetCatalogPack pack;
|
||||
pack.setId = std::move(build.setId);
|
||||
pack.setName = std::move(build.setName);
|
||||
pack.cards = std::move(build.cards);
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return Result<DigiBattle99SetCatalog>::ok(std::move(catalog));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetSource::FetchWithCatalog>
|
||||
DigiBattle99SetSource::fetchAllWithCatalog() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<FetchWithCatalog>::err(resp.error());
|
||||
|
||||
auto sets = parseResponse(resp.value());
|
||||
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
|
||||
auto catalog = parseCatalog(resp.value());
|
||||
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog).value();
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
|
||||
auto both = fetchAllWithCatalog();
|
||||
if (!both) return Result<std::vector<Set>>::err(both.error());
|
||||
return Result<std::vector<Set>>::ok(std::move(both).value().sets);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
@@ -13,18 +14,6 @@ namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Strip everything after the first '/' in a Pokemon collector number.
|
||||
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
|
||||
// `4/102`. Without this, no API match is found.
|
||||
std::string normalizeNumber(std::string_view setNo) {
|
||||
std::string s(setNo);
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
s = s.substr(0, slash);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
@@ -42,63 +31,145 @@ std::string toLower(std::string s) {
|
||||
|
||||
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
|
||||
std::string s(setNo);
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
s = s.substr(0, slash);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) {
|
||||
if (imageBase.empty()) return {};
|
||||
std::string url(imageBase);
|
||||
while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back();
|
||||
return url + "/high.png";
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
std::string id = idCanon + "-" + num;
|
||||
return std::string("https://api.tcgdex.net/v2/en/cards/") + rfc3986PercentEncode(id);
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/en/sets/") +
|
||||
rfc3986PercentEncode(canonicalizeWestSetId(setId));
|
||||
}
|
||||
|
||||
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
// Build the unencoded query first so the output matches what the Pokemon
|
||||
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
|
||||
std::string query = "name:\"";
|
||||
query += std::string(name);
|
||||
query += "\"";
|
||||
if (!setId.empty()) {
|
||||
query += " set.id:";
|
||||
query += std::string(setId);
|
||||
}
|
||||
const std::string num = normalizeNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
query += " number:";
|
||||
query += num;
|
||||
}
|
||||
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
|
||||
rfc3986PercentEncode(query);
|
||||
}
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
std::string url = "https://api.tcgdex.net/v2/en/cards?";
|
||||
bool first = true;
|
||||
auto append = [&](std::string_view key, std::string_view value) {
|
||||
if (value.empty()) return;
|
||||
if (!first) url += '&';
|
||||
first = false;
|
||||
url += std::string(key);
|
||||
url += "=eq:";
|
||||
url += rfc3986PercentEncode(value);
|
||||
};
|
||||
|
||||
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
|
||||
std::string_view setId) {
|
||||
std::string url = buildSearchUrl(name, setId, "");
|
||||
url += "&select=name,number,rarity,set";
|
||||
url += "&pageSize=50";
|
||||
if (!idCanon.empty() && !num.empty()) {
|
||||
append("set.id", idCanon);
|
||||
append("localId", num);
|
||||
} else {
|
||||
append("name", name);
|
||||
append("set.id", idCanon);
|
||||
append("localId", num);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::vector<PokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseSetCards(const std::string& body) {
|
||||
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex EN set detail missing 'cards' array."});
|
||||
}
|
||||
std::vector<SetCardRow> out;
|
||||
out.reserve(j.at("cards").size());
|
||||
for (const auto& card : j.at("cards")) {
|
||||
SetCardRow row;
|
||||
row.localId = card.value("localId", "");
|
||||
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
const std::string id = card.at("id").get<std::string>();
|
||||
const auto dash = id.rfind('-');
|
||||
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
|
||||
}
|
||||
row.name = card.value("name", "");
|
||||
row.rarity = card.value("rarity", "");
|
||||
if (card.contains("image") && card.at("image").is_string()) {
|
||||
row.imageBase = card.at("image").get<std::string>();
|
||||
}
|
||||
if (row.localId.empty()) continue;
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex EN set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseResponse(const std::string& body) {
|
||||
PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
|
||||
if (!j.is_object()) {
|
||||
return R::err({K::Transient, "TCGdex EN card response is not a JSON object."});
|
||||
}
|
||||
const auto& data = j.at("data");
|
||||
if (data.empty()) {
|
||||
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex EN card has no image."});
|
||||
}
|
||||
const auto& first = data.at(0);
|
||||
if (!first.contains("images") || !first.at("images").is_object()) {
|
||||
return R::err({K::NotFound, "Card has no 'images' object."});
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex EN card image field is not a string."});
|
||||
}
|
||||
const auto& images = first.at("images");
|
||||
if (images.contains("large") && images.at("large").is_string()) {
|
||||
return R::ok(images.at("large").get<std::string>());
|
||||
const std::string base = j.at("image").get<std::string>();
|
||||
if (base.empty()) {
|
||||
return R::err({K::NotFound, "TCGdex EN card has no image."});
|
||||
}
|
||||
if (images.contains("small") && images.at("small").is_string()) {
|
||||
return R::ok(images.at("small").get<std::string>());
|
||||
}
|
||||
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what()});
|
||||
std::string("TCGdex EN card JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
PokemonCardPreviewSource::parseSearchResponse(const std::string& body) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return R::err({K::Transient, "TCGdex EN cards search response is not an array."});
|
||||
}
|
||||
if (j.empty()) {
|
||||
return R::err({K::NotFound, "TCGdex EN returned no matching cards."});
|
||||
}
|
||||
for (const auto& card : j) {
|
||||
if (!card.contains("image") || !card.at("image").is_string()) continue;
|
||||
const std::string base = card.at("image").get<std::string>();
|
||||
if (base.empty()) continue;
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
}
|
||||
return R::err({K::NotFound, "TCGdex EN matching cards have no image."});
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex EN cards search JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,68 +179,54 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
const std::string url = buildSearchUrl(name, setId, setNo);
|
||||
|
||||
const std::string idCanon = canonicalizeWestSetId(setId);
|
||||
const std::string num = normalizeCollectorNumber(setNo);
|
||||
if (!idCanon.empty() && !num.empty()) {
|
||||
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
|
||||
if (byId) {
|
||||
auto img = parseCardByIdResponse(byId.value());
|
||||
if (img) return img;
|
||||
// NotFound / Transient schema: fall through to search.
|
||||
}
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, idCanon, num);
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseResponse(resp.value());
|
||||
return parseSearchResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view /*setId*/,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
|
||||
return R::err("Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
const std::string wantedSetId = trim(std::string(setId));
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
auto pushCard = [&collected](const nlohmann::json& card) {
|
||||
AutoDetectedPrint out;
|
||||
out.setNo = trim(card.value("number", ""));
|
||||
out.rarity = trim(card.value("rarity", ""));
|
||||
if (out.setNo.empty() && out.rarity.empty()) return;
|
||||
collected.push_back(std::move(out));
|
||||
};
|
||||
|
||||
for (const auto& card : j.at("data")) {
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) continue;
|
||||
}
|
||||
if (!wantedSetId.empty()) {
|
||||
std::string cardSetId;
|
||||
if (card.contains("set") && card.at("set").is_object()) {
|
||||
cardSetId = trim(card.at("set").value("id", ""));
|
||||
}
|
||||
if (cardSetId != wantedSetId) continue;
|
||||
}
|
||||
pushCard(card);
|
||||
}
|
||||
|
||||
if (collected.empty()) {
|
||||
if (!wantedNameLower.empty() && !wantedSetId.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return R::err("Pokemon TCG returned no matching cards.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> deduped;
|
||||
deduped.reserve(collected.size());
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(collected.size() * 2);
|
||||
for (auto& p : collected) {
|
||||
const std::string key = p.setNo + '\0' + p.rarity;
|
||||
if (seen.insert(key).second) deduped.push_back(std::move(p));
|
||||
}
|
||||
return R::ok(std::move(deduped));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
auto rows = parseSetCards(body);
|
||||
if (!rows) {
|
||||
return R::err(rows.error().message);
|
||||
}
|
||||
|
||||
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!wantedLower.empty()) {
|
||||
if (toLower(trim(row.name)) != wantedLower) continue;
|
||||
}
|
||||
const std::string localId = normalizeCollectorNumber(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId + '\0' + row.rarity).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = localId;
|
||||
print.rarity = row.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
|
||||
if (out.empty()) {
|
||||
return R::err("Could not auto-detect set print metadata.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
@@ -186,15 +243,60 @@ 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());
|
||||
}
|
||||
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,45 +1,170 @@
|
||||
#include "ccm/games/pokemon/PokemonSetSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
|
||||
std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
|
||||
std::string out(isoDate);
|
||||
for (char& ch : out) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/en/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::parseListResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.contains("data") || !j.at("data").is_array()) {
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"Pokemon TCG API response missing 'data' array.");
|
||||
"TCGdex EN sets response is not a JSON array.");
|
||||
}
|
||||
std::vector<Set> out;
|
||||
out.reserve(j.at("data").size());
|
||||
for (const auto& entry : j.at("data")) {
|
||||
out.reserve(j.size());
|
||||
for (const auto& entry : j) {
|
||||
Set s;
|
||||
s.id = entry.value("id", "");
|
||||
s.name = entry.value("name", "");
|
||||
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
|
||||
// no separator rewrite needed (cf. Scryfall's "released_at").
|
||||
s.releaseDate = entry.value("releaseDate", "");
|
||||
s.id = entry.value("id", "");
|
||||
if (s.id.empty()) continue;
|
||||
s.name = entry.value("name", "");
|
||||
s.releaseDate = {}; // filled from set detail
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("Pokemon TCG JSON parse error: ") + e.what());
|
||||
std::string("TCGdex EN sets JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string> PokemonSetSource::parseReleaseDate(const std::string& detailBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(detailBody);
|
||||
if (!j.is_object()) {
|
||||
return Result<std::string>::err(
|
||||
"TCGdex EN set detail response is not a JSON object.");
|
||||
}
|
||||
const std::string raw = j.value("releaseDate", "");
|
||||
if (raw.empty()) {
|
||||
return Result<std::string>::ok(std::string{});
|
||||
}
|
||||
return Result<std::string>::ok(rewriteReleaseDate(raw));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::string>::err(
|
||||
std::string("TCGdex EN set detail JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set) {
|
||||
auto rows = PokemonCardPreviewSource::parseSetCards(detailBody);
|
||||
if (!rows) {
|
||||
return Result<PokemonSetCatalogPack>::err(rows.error().message);
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& row : rows.value()) {
|
||||
const std::string localId =
|
||||
PokemonCardPreviewSource::normalizeCollectorNumber(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
std::string name = row.name;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return parsed;
|
||||
|
||||
std::vector<Set> out = std::move(parsed).value();
|
||||
for (auto& s : out) {
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue; // keep set with empty date rather than fail all
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
|
||||
|
||||
std::vector<Set> sets = std::move(parsed).value();
|
||||
PokemonSetCatalog catalog;
|
||||
catalog.packs.reserve(sets.size());
|
||||
|
||||
for (auto& s : sets) {
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) continue;
|
||||
|
||||
if (s.releaseDate.empty()) {
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
auto pack = parseCatalogPackFromSetDetail(detail.value(), s);
|
||||
if (pack) {
|
||||
catalog.packs.push_back(std::move(pack).value());
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets);
|
||||
out.catalog = std::move(catalog);
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
|
||||
#include <unordered_map>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Built by name-matching PokemonTCG/pokemon-tcg-data set ids against
|
||||
// api.tcgdex.net/v2/en/sets. Only divergences are listed; shared ids
|
||||
// (base1, swsh1, sv10, sve, …) pass through unchanged.
|
||||
const std::unordered_map<std::string, std::string>& legacyAliases() {
|
||||
static const std::unordered_map<std::string, std::string> kMap{
|
||||
// Classic / EX / HGSS renames
|
||||
{"base6", "lc"},
|
||||
{"bp", "bog"},
|
||||
{"tk1a", "tk-ex-latia"},
|
||||
{"tk1b", "tk-ex-latio"},
|
||||
{"tk2a", "tk-ex-p"},
|
||||
{"tk2b", "tk-ex-m"},
|
||||
{"hsp", "hgssp"},
|
||||
|
||||
// McDonald's Collections
|
||||
{"mcd11", "2011bw"},
|
||||
{"mcd12", "2012bw"},
|
||||
{"mcd14", "2014xy"},
|
||||
{"mcd15", "2015xy"},
|
||||
{"mcd16", "2016xy"},
|
||||
{"mcd17", "2017sm"},
|
||||
{"mcd18", "2018sm"},
|
||||
{"mcd19", "2019sm"},
|
||||
{"mcd21", "2021swsh"},
|
||||
{"mcd22", "2022swsh"},
|
||||
{"mcd23", "2023sv"},
|
||||
{"mcd24", "2024sv"},
|
||||
|
||||
// SM specials
|
||||
{"sm35", "sm3.5"},
|
||||
{"sm75", "sm7.5"},
|
||||
|
||||
// SWSH specials / galleries
|
||||
{"swsh35", "swsh3.5"},
|
||||
{"swsh45", "swsh4.5"},
|
||||
{"swsh45sv", "swsh4.5sv"},
|
||||
{"cel25c", "cel25cc"},
|
||||
{"swsh9tg", "swsh9.5tg"},
|
||||
{"swsh10tg", "swsh10.5tg"},
|
||||
{"pgo", "swsh10.5"},
|
||||
{"swsh11tg", "swsh11.5tg"},
|
||||
{"swsh12tg", "swsh12.5tg"},
|
||||
{"swsh12pt5", "swsh12.5"},
|
||||
{"swsh12pt5gg", "swsh12.5gg"},
|
||||
|
||||
// Scarlet & Violet (pokemontcg used unpadded / pt5 forms)
|
||||
{"sv1", "sv01"},
|
||||
{"sv2", "sv02"},
|
||||
{"sv3", "sv03"},
|
||||
{"sv3pt5", "sv03.5"},
|
||||
{"sv4", "sv04"},
|
||||
{"sv4pt5", "sv04.5"},
|
||||
{"sv5", "sv05"},
|
||||
{"sv6", "sv06"},
|
||||
{"sv6pt5", "sv06.5"},
|
||||
{"sv7", "sv07"},
|
||||
{"sv8", "sv08"},
|
||||
{"sv8pt5", "sv08.5"},
|
||||
{"sv9", "sv09"},
|
||||
{"zsv10pt5", "sv10.5b"},
|
||||
{"rsv10pt5", "sv10.5w"},
|
||||
|
||||
// Mega Evolution era
|
||||
{"me1", "me01"},
|
||||
{"me2", "me02"},
|
||||
{"me2pt5", "me02.5"},
|
||||
{"me3", "me03"},
|
||||
{"me4", "me04"},
|
||||
{"me5", "me05"},
|
||||
};
|
||||
return kMap;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string canonicalizeWestSetId(std::string_view setId) {
|
||||
if (setId.empty()) return {};
|
||||
const auto& map = legacyAliases();
|
||||
const auto it = map.find(std::string(setId));
|
||||
if (it != map.end()) return it->second;
|
||||
return std::string(setId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,442 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
|
||||
s.erase(s.begin());
|
||||
}
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
|
||||
s.pop_back();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string stripLeadingZeros(std::string_view s) {
|
||||
std::size_t i = 0;
|
||||
while (i + 1 < s.size() && s[i] == '0') ++i;
|
||||
return std::string(s.substr(i));
|
||||
}
|
||||
|
||||
bool localIdsMatch(std::string_view a, std::string_view b) {
|
||||
if (a == b) return true;
|
||||
return stripLeadingZeros(a) == stripLeadingZeros(b);
|
||||
}
|
||||
|
||||
bool catalogPrintMatchesRow(const JapanesePokemonPrintEnInfo& print,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row) {
|
||||
// Reject stale catalog rows whose Japanese name disagrees with TCGdex.
|
||||
// Seed data historically mapped Charmander→001 / Charizard→004; those
|
||||
// localIds are Bulbasaur / Weedle on PMCG1.
|
||||
if (print.nameJa.empty()) return true;
|
||||
return asciiLower(print.nameJa) == asciiLower(row.nameJa);
|
||||
}
|
||||
|
||||
bool nameMatchesRow(std::string_view wantedLower,
|
||||
const JapanesePokemonCardPreviewSource::SetCardRow& row,
|
||||
std::string_view setId,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
if (wantedLower.empty()) return true;
|
||||
if (asciiLower(row.nameJa) == wantedLower) return true;
|
||||
if (auto print = catalog.findPrint(setId, row.localId)) {
|
||||
if (!catalogPrintMatchesRow(*print, row)) return false;
|
||||
if (asciiLower(print->nameEn) == wantedLower) return true;
|
||||
if (asciiLower(print->nameJa) == wantedLower) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonCardPreviewSource::JapanesePokemonCardPreviewSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::normalizeLocalId(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
const auto slash = s.find('/');
|
||||
if (slash != std::string::npos) s.erase(slash);
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::buildCardUrl(std::string_view setId,
|
||||
std::string_view localId) {
|
||||
std::string id = std::string(setId) + "-" + std::string(localId);
|
||||
return std::string("https://api.tcgdex.net/v2/ja/cards/") +
|
||||
rfc3986PercentEncode(id);
|
||||
}
|
||||
|
||||
std::string JapanesePokemonCardPreviewSource::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";
|
||||
}
|
||||
|
||||
Result<std::vector<JapanesePokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::parseSetCards(const std::string& body) {
|
||||
using R = Result<std::vector<SetCardRow>, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) {
|
||||
return R::err({K::Transient,
|
||||
"TCGdex JA set detail missing 'cards' array."});
|
||||
}
|
||||
std::vector<SetCardRow> out;
|
||||
out.reserve(j.at("cards").size());
|
||||
for (const auto& card : j.at("cards")) {
|
||||
SetCardRow row;
|
||||
row.localId = card.value("localId", "");
|
||||
if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) {
|
||||
// Fallback: take suffix after last '-' from card id.
|
||||
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.nameJa = card.value("name", "");
|
||||
row.rarity = card.value("rarity", "");
|
||||
if (card.contains("image") && card.at("image").is_string()) {
|
||||
row.imageBase = card.at("image").get<std::string>();
|
||||
}
|
||||
if (row.localId.empty()) continue;
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA set detail JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::parseCardImageUrl(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_object()) {
|
||||
return R::err({K::Transient, "TCGdex JA card response is not a JSON object."});
|
||||
}
|
||||
if (!j.contains("image") || j.at("image").is_null()) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
if (!j.at("image").is_string()) {
|
||||
return R::err({K::Transient, "TCGdex JA 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 JA card has no image."});
|
||||
}
|
||||
return R::ok(imageUrlFromBase(base));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("TCGdex JA card JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
auto rows = parseSetCards(body);
|
||||
if (!rows) {
|
||||
return R::err(rows.error().message);
|
||||
}
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenCatalogUrls;
|
||||
|
||||
// Prefer catalog EN matches first so typed English names resolve — but
|
||||
// only when the catalog localId exists in the set and name_ja agrees
|
||||
// with TCGdex (guards against stale seed mappings).
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
const SetCardRow* row = nullptr;
|
||||
for (const auto& r : rows.value()) {
|
||||
if (localIdsMatch(r.localId, p.localId)) {
|
||||
row = &r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (row == nullptr) {
|
||||
// Set detail sometimes omits cards[]; keep catalog-only hits
|
||||
// (UnnumberedPromo). Dedupe only among non-empty preview URLs
|
||||
// so empty-image prints still appear in the Next ring.
|
||||
if (!rows.value().empty()) continue;
|
||||
if (!previewUrl.empty() &&
|
||||
!seenCatalogUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
} else if (!catalogPrintMatchesRow(p, *row)) {
|
||||
continue;
|
||||
}
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (row != nullptr) print.rarity = row->rarity;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!nameMatchesRow(wantedLower, row, setId, catalog)) continue;
|
||||
if (!seen.insert(row.localId).second) continue;
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = row.localId;
|
||||
print.rarity = row.rarity;
|
||||
out.push_back(std::move(print));
|
||||
}
|
||||
|
||||
if (out.empty() && !wantedLower.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
std::string_view setId,
|
||||
std::string_view wantedCardName,
|
||||
const JapanesePokemonEnCatalog& catalog) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
if (!catalog.hasPrintsForSet(setId)) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
|
||||
std::vector<AutoDetectedPrint> out;
|
||||
std::unordered_set<std::string> seen;
|
||||
std::unordered_set<std::string> seenUrls;
|
||||
if (wantedLower.empty()) {
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
std::vector<AutoDetectedPrint> withPreview;
|
||||
std::vector<AutoDetectedPrint> withoutPreview;
|
||||
for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) {
|
||||
if (!seen.insert(p.localId).second) continue;
|
||||
// Dedupe only among non-empty preview URLs so identical art is not
|
||||
// cycled; empty-image prints still join the Next ring (card-back).
|
||||
// Emit imaged prints first so Auto-detect lands on real art.
|
||||
const std::string previewUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(p);
|
||||
if (!previewUrl.empty() && !seenUrls.insert(previewUrl).second) {
|
||||
continue;
|
||||
}
|
||||
AutoDetectedPrint print;
|
||||
print.setNo = p.localId;
|
||||
if (previewUrl.empty()) {
|
||||
withoutPreview.push_back(std::move(print));
|
||||
} else {
|
||||
withPreview.push_back(std::move(print));
|
||||
}
|
||||
}
|
||||
for (auto& print : withPreview) out.push_back(std::move(print));
|
||||
for (auto& print : withoutPreview) out.push_back(std::move(print));
|
||||
if (out.empty()) {
|
||||
return R::err("No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return R::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
JapanesePokemonCardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setId,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string localId = normalizeLocalId(setNo);
|
||||
if (setId.empty()) {
|
||||
return R::err({K::NotFound, "Japanese Pokemon preview requires a set id."});
|
||||
}
|
||||
|
||||
auto catalogPreviewFor = [&](std::string_view lid) -> Result<std::string, PreviewLookupError> {
|
||||
if (lid.empty()) return R::err({K::NotFound, "No catalog preview for print."});
|
||||
if (auto print = catalog_.findPrint(setId, lid)) {
|
||||
const std::string catalogUrl =
|
||||
JapanesePokemonEnCatalog::previewImageUrlFromPrint(*print);
|
||||
if (!catalogUrl.empty()) return R::ok(catalogUrl);
|
||||
}
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
};
|
||||
|
||||
// Prefer direct card fetch when we have a localId.
|
||||
if (!localId.empty()) {
|
||||
auto cardResp = http_.get(buildCardUrl(setId, localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
// NotFound from card object: fall through to set list / catalog.
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Synthetic / classic products: try catalog gap-fill before set detail.
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
// Known catalog print with no preview URL: honest miss (do not
|
||||
// borrow a sibling print's art via name match).
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
// Catalog-only products (City Gym theme decks, etc.) are not on TCGdex.
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
// Network failure and no catalog entry: Transient so a brief outage
|
||||
// is not negative-cached as a permanent miss.
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
return R::err({K::Transient, setResp.error()});
|
||||
}
|
||||
auto rows = parseSetCards(setResp.value());
|
||||
if (!rows) return R::err(rows.error());
|
||||
|
||||
const std::string wantedLower = asciiLower(trim(std::string(name)));
|
||||
const SetCardRow* chosen = nullptr;
|
||||
for (const auto& row : rows.value()) {
|
||||
if (!localId.empty() && localIdsMatch(row.localId, localId)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Name match only when setNo was not provided — never borrow a sibling
|
||||
// print's art for a concrete localId.
|
||||
if (chosen == nullptr && localId.empty() && !wantedLower.empty()) {
|
||||
for (const auto& row : rows.value()) {
|
||||
if (nameMatchesRow(wantedLower, row, setId, catalog_)) {
|
||||
chosen = &row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
// Empty cards[] with catalog prints: resolve from catalog.
|
||||
if (rows.value().empty() && catalog_.hasPrintsForSet(setId)) {
|
||||
if (!localId.empty()) {
|
||||
auto catalogImg = catalogPreviewFor(localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
if (catalog_.findPrint(setId, localId)) {
|
||||
return R::err({K::NotFound, "TCGdex JA card has no image."});
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!wantedLower.empty()) {
|
||||
for (const auto& p : catalog_.findPrintsByName(setId, name)) {
|
||||
auto catalogImg = catalogPreviewFor(p.localId);
|
||||
if (catalogImg) return catalogImg;
|
||||
}
|
||||
}
|
||||
}
|
||||
return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."});
|
||||
}
|
||||
if (!chosen->imageBase.empty()) {
|
||||
return R::ok(imageUrlFromBase(chosen->imageBase));
|
||||
}
|
||||
|
||||
// Try full card object — set résumé sometimes omits image.
|
||||
auto cardResp = http_.get(buildCardUrl(setId, chosen->localId));
|
||||
if (cardResp) {
|
||||
auto img = parseCardImageUrl(cardResp.value());
|
||||
if (img) return img;
|
||||
if (img.error().kind == K::Transient) return img;
|
||||
} else {
|
||||
// Odd localId padding can 404; still try catalog gap-fill below.
|
||||
}
|
||||
|
||||
// Classic JA sets often have image:null on TCGdex. Prefer a catalog
|
||||
// printing-accurate TCGPlayer product image for this exact setId+localId
|
||||
// (never search other printings by Pokémon name).
|
||||
return catalogPreviewFor(chosen->localId);
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint>
|
||||
JapanesePokemonCardPreviewSource::detectFirstPrint(std::string_view name,
|
||||
std::string_view setId) {
|
||||
auto variants = detectPrintVariants(name, setId);
|
||||
if (!variants) return Result<AutoDetectedPrint>::err(variants.error());
|
||||
if (variants.value().empty()) {
|
||||
return Result<AutoDetectedPrint>::err(
|
||||
"No matching Japanese Pokemon prints for that name in the set.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(variants.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>>
|
||||
JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
|
||||
std::string_view setId) {
|
||||
if (setId.empty()) {
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(
|
||||
"Select a set before auto-detecting Japanese Pokemon prints.");
|
||||
}
|
||||
auto setResp = http_.get(buildSetDetailUrl(setId));
|
||||
if (!setResp) {
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return Result<std::vector<AutoDetectedPrint>>::err(setResp.error());
|
||||
}
|
||||
auto parsed = parsePrintVariants(setResp.value(), setId, name, catalog_);
|
||||
if (parsed) return parsed;
|
||||
// Empty/unusable TCGdex detail: fall back to catalog prints when present.
|
||||
if (catalog_.hasPrintsForSet(setId)) {
|
||||
return detectPrintVariantsFromCatalog(setId, name, catalog_);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,173 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string asciiLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string printKey(std::string_view setId, std::string_view localId) {
|
||||
return std::string(setId) + '\0' + std::string(localId);
|
||||
}
|
||||
|
||||
bool isAsciiAlnumToken(std::string_view s) {
|
||||
if (s.empty()) return false;
|
||||
for (unsigned char ch : s) {
|
||||
if (!std::isalnum(ch)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// True when `needle` appears in `hay` as a whole alphanumeric token
|
||||
/// (e.g. "mewtwo" in "team gr's mewtwo" / "mewtwo strikes back", but not
|
||||
/// "mew" inside "mewtwo"). ASCII needles only.
|
||||
bool containsWholeAsciiToken(std::string_view hay, std::string_view needle) {
|
||||
if (!isAsciiAlnumToken(needle)) return false;
|
||||
const std::size_t n = needle.size();
|
||||
for (std::size_t i = 0; i + n <= hay.size(); ++i) {
|
||||
if (hay.compare(i, n, needle) != 0) continue;
|
||||
const bool leftOk = i == 0 || !std::isalnum(static_cast<unsigned char>(hay[i - 1]));
|
||||
const bool rightOk =
|
||||
i + n == hay.size() ||
|
||||
!std::isalnum(static_cast<unsigned char>(hay[i + n]));
|
||||
if (leftOk && rightOk) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Result<JapanesePokemonEnCatalog>
|
||||
JapanesePokemonEnCatalog::parse(const std::string& jsonBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(jsonBody);
|
||||
JapanesePokemonEnCatalog out;
|
||||
|
||||
if (j.contains("sets") && j.at("sets").is_object()) {
|
||||
for (auto it = j.at("sets").begin(); it != j.at("sets").end(); ++it) {
|
||||
JapanesePokemonSetEnInfo info;
|
||||
info.nameEn = it.value().value("name_en", "");
|
||||
info.nameJa = it.value().value("name_ja", "");
|
||||
info.releaseDate = it.value().value("releaseDate", "");
|
||||
out.sets_[it.key()] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
if (j.contains("prints") && j.at("prints").is_array()) {
|
||||
for (const auto& entry : j.at("prints")) {
|
||||
JapanesePokemonPrintEnInfo info;
|
||||
info.setId = entry.value("set_id", "");
|
||||
info.localId = entry.value("local_id", "");
|
||||
info.nameEn = entry.value("name_en", "");
|
||||
info.nameJa = entry.value("name_ja", "");
|
||||
info.nameEnSource = entry.value("name_en_source", "");
|
||||
info.imageUrl = entry.value("image_url", "");
|
||||
if (entry.contains("tcgplayer_id")) {
|
||||
const auto& tp = entry.at("tcgplayer_id");
|
||||
if (tp.is_string()) {
|
||||
info.tcgplayerId = tp.get<std::string>();
|
||||
} else if (tp.is_number_integer()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::int64_t>());
|
||||
} else if (tp.is_number_unsigned()) {
|
||||
info.tcgplayerId = std::to_string(tp.get<std::uint64_t>());
|
||||
}
|
||||
}
|
||||
if (info.setId.empty() || info.localId.empty()) continue;
|
||||
const std::string key = printKey(info.setId, info.localId);
|
||||
out.printKeysBySet_[info.setId].push_back(key);
|
||||
out.printsByKey_[key] = std::move(info);
|
||||
}
|
||||
}
|
||||
|
||||
return Result<JapanesePokemonEnCatalog>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<JapanesePokemonEnCatalog>::err(
|
||||
std::string("Japanese Pokemon EN catalog JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonSetEnInfo>
|
||||
JapanesePokemonEnCatalog::findSet(std::string_view setId) const {
|
||||
const auto it = sets_.find(std::string(setId));
|
||||
if (it == sets_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::optional<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrint(std::string_view setId,
|
||||
std::string_view localId) const {
|
||||
const auto it = printsByKey_.find(printKey(setId, localId));
|
||||
if (it == printsByKey_.end()) return std::nullopt;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::vector<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::findPrintsByName(std::string_view setId,
|
||||
std::string_view cardName) const {
|
||||
std::vector<JapanesePokemonPrintEnInfo> out;
|
||||
if (cardName.empty()) return out;
|
||||
const std::string wanted = asciiLower(std::string(cardName));
|
||||
const auto keysIt = printKeysBySet_.find(std::string(setId));
|
||||
if (keysIt == printKeysBySet_.end()) return out;
|
||||
for (const auto& key : keysIt->second) {
|
||||
const auto pit = printsByKey_.find(key);
|
||||
if (pit == printsByKey_.end()) continue;
|
||||
const auto& p = pit->second;
|
||||
const std::string enLower = asciiLower(p.nameEn);
|
||||
const std::string jaLower = asciiLower(p.nameJa);
|
||||
// Exact, qualified "Mewtwo (...)", or whole-token in a longer title
|
||||
// ("Team GR's Mewtwo", "Mewtwo Strikes Back (...)").
|
||||
if (enLower == wanted || jaLower == wanted ||
|
||||
enLower.starts_with(wanted + " (") ||
|
||||
containsWholeAsciiToken(enLower, wanted) ||
|
||||
containsWholeAsciiToken(jaLower, wanted)) {
|
||||
out.push_back(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noexcept {
|
||||
const auto it = printKeysBySet_.find(std::string(setId));
|
||||
return it != printKeysBySet_.end() && !it->second.empty();
|
||||
}
|
||||
|
||||
std::vector<JapanesePokemonPrintEnInfo>
|
||||
JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const {
|
||||
std::vector<JapanesePokemonPrintEnInfo> out;
|
||||
const auto keysIt = printKeysBySet_.find(std::string(setId));
|
||||
if (keysIt == printKeysBySet_.end()) return out;
|
||||
out.reserve(keysIt->second.size());
|
||||
for (const auto& key : keysIt->second) {
|
||||
const auto pit = printsByKey_.find(key);
|
||||
if (pit == printsByKey_.end()) continue;
|
||||
out.push_back(pit->second);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
|
||||
if (productId.empty()) return {};
|
||||
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
|
||||
std::string(productId) + ".jpg";
|
||||
}
|
||||
|
||||
std::string JapanesePokemonEnCatalog::previewImageUrlFromPrint(
|
||||
const JapanesePokemonPrintEnInfo& print) {
|
||||
if (!print.imageUrl.empty()) return print.imageUrl;
|
||||
return tcgplayerImageUrl(print.tcgplayerId);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
JapanesePokemonGameModule::JapanesePokemonGameModule(IHttpClient& http,
|
||||
JapanesePokemonEnCatalog catalog)
|
||||
: catalog_(std::move(catalog)),
|
||||
setSource_(http, catalog_),
|
||||
previewSource_(http, catalog_) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,353 @@
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
struct ClassicMissingProduct {
|
||||
const char* id;
|
||||
const char* nameEn;
|
||||
const char* releaseDate; // YYYY/MM/DD
|
||||
};
|
||||
|
||||
// Keep in sync with tools/pokemon_jp/classic_missing_sets.json and
|
||||
// docs/assets-and-info-apis.md (Japanese Pokémon Info API).
|
||||
constexpr std::array<ClassicMissingProduct, 11> kMissingClassicProducts{{
|
||||
// Day after Pokémon Jungle (PMCG2, 1997/03/05) so the set list places
|
||||
// Unnumbered Promo immediately after Jungle when sorted by releaseDate.
|
||||
{"UnnumberedPromo", "Unnumbered Promotional cards", "1997/03/06"},
|
||||
{"ExpSheet1", "Expansion Sheet Series 1", "1998/03/23"},
|
||||
{"NiviCG", "Nivi City Gym", "1998/04/26"},
|
||||
{"HanadaCG", "Hanada City Gym", "1998/04/26"},
|
||||
{"ExpSheet2", "Expansion Sheet Series 2", "1998/06/17"},
|
||||
{"KuchibaCG", "Kuchiba City Gym", "1998/07/25"},
|
||||
{"TamamushiCG", "Tamamushi City Gym", "1998/07/25"},
|
||||
{"ExpSheet3", "Expansion Sheet Series 3", "1998/11/24"},
|
||||
{"YamabukiCG", "Yamabuki City Gym", "1999/02/26"},
|
||||
{"GurenTG", "Guren Town Gym", "1999/02/26"},
|
||||
{"SouthernIslands", "Southern Islands", "1999/07/17"},
|
||||
}};
|
||||
|
||||
const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
|
||||
// Field-level corrections for known TCGdex JA mislabels (never edit cache).
|
||||
static const std::unordered_map<std::string, std::string> kOverrides{
|
||||
{"SV4a", "シャイニートレジャーex"},
|
||||
};
|
||||
return kOverrides;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool containsCjk(std::string_view s) noexcept {
|
||||
// Detect hiragana / katakana / CJK unified (UTF-8 lead bytes 0xE3–0xE9).
|
||||
// Do NOT treat Latin-1 accents (e.g. é in "Pokémon", lead 0xC3) as CJK —
|
||||
// that used to wipe catalog English names back to the set id.
|
||||
for (unsigned char ch : s) {
|
||||
if (ch >= 0xE3 && ch <= 0xE9) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
|
||||
const JapanesePokemonEnCatalog& enCatalog) {
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& card : pack.cards) {
|
||||
seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo));
|
||||
}
|
||||
for (const auto& print : enCatalog.printsForSet(pack.setId)) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
std::string name = print.nameEn;
|
||||
if (name.empty()) name = print.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
}
|
||||
|
||||
void sortPackCards(PokemonSetCatalogPack& pack) {
|
||||
std::sort(pack.cards.begin(), pack.cards.end(),
|
||||
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
}
|
||||
|
||||
void applyEnglishSetName(Set& s, const JapanesePokemonEnCatalog& catalog) {
|
||||
if (auto en = catalog.findSet(s.id)) {
|
||||
if (!en->nameEn.empty()) s.name = en->nameEn;
|
||||
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
|
||||
}
|
||||
if (s.name.empty() || containsCjk(s.name)) {
|
||||
s.name = s.id;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
JapanesePokemonSetSource::JapanesePokemonSetSource(
|
||||
IHttpClient& http, const JapanesePokemonEnCatalog& catalog)
|
||||
: http_(http), catalog_(catalog) {}
|
||||
|
||||
bool JapanesePokemonSetSource::shouldExcludeSetId(std::string_view setId) noexcept {
|
||||
// Chinese-region CS* entries are mislabeled on the JA endpoint.
|
||||
return setId.size() >= 2 && setId[0] == 'C' && setId[1] == 'S';
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::applySetNameOverride(std::string_view setId,
|
||||
std::string nameJa) {
|
||||
const auto& overrides = setNameJaOverrides();
|
||||
const auto it = overrides.find(std::string(setId));
|
||||
if (it != overrides.end()) return it->second;
|
||||
return nameJa;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
|
||||
std::string out(isoDate);
|
||||
for (char& ch : out) {
|
||||
if (ch == '-') ch = '/';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string JapanesePokemonSetSource::buildSetDetailUrl(std::string_view setId) {
|
||||
return std::string("https://api.tcgdex.net/v2/ja/sets/") +
|
||||
rfc3986PercentEncode(setId);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>>
|
||||
JapanesePokemonSetSource::parseListResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"TCGdex JA 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() || shouldExcludeSetId(s.id)) continue;
|
||||
s.name = applySetNameOverride(s.id, entry.value("name", ""));
|
||||
s.releaseDate = {}; // filled from catalog or set detail
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
appendMissingClassicProducts(out);
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("TCGdex JA sets JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::appendMissingClassicProducts(std::vector<Set>& sets) {
|
||||
for (const auto& product : kMissingClassicProducts) {
|
||||
auto it = std::find_if(sets.begin(), sets.end(), [&](const Set& s) {
|
||||
return s.id == product.id;
|
||||
});
|
||||
if (it != sets.end()) {
|
||||
// Keep curated display name / sort date in sync (e.g. UnnumberedPromo
|
||||
// placement after Pokémon Jungle) even when the id was already cached.
|
||||
it->name = product.nameEn;
|
||||
it->releaseDate = product.releaseDate;
|
||||
continue;
|
||||
}
|
||||
Set s;
|
||||
s.id = product.id;
|
||||
s.name = product.nameEn;
|
||||
s.releaseDate = product.releaseDate;
|
||||
sets.push_back(std::move(s));
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string>
|
||||
JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(detailBody);
|
||||
if (!j.is_object()) {
|
||||
return Result<std::string>::err(
|
||||
"TCGdex JA 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 JA set detail JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack JapanesePokemonSetSource::catalogPackFromEnCatalog(
|
||||
const Set& set, const JapanesePokemonEnCatalog& enCatalog) {
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
for (const auto& print : enCatalog.printsForSet(set.id)) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
|
||||
if (localId.empty()) continue;
|
||||
std::string name = print.nameEn;
|
||||
if (name.empty()) name = print.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
sortPackCards(pack);
|
||||
return pack;
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalogPack> JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
|
||||
const std::string& detailBody,
|
||||
const Set& set,
|
||||
const JapanesePokemonEnCatalog& enCatalog) {
|
||||
auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody);
|
||||
if (!rows) {
|
||||
// Transient/NotFound from parse — treat empty cards as catalog-only.
|
||||
if (rows.error().kind == PreviewLookupError::Kind::NotFound) {
|
||||
auto pack = catalogPackFromEnCatalog(set, enCatalog);
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err(
|
||||
"No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::err(rows.error().message);
|
||||
}
|
||||
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = set.id;
|
||||
pack.setName = set.name.empty() ? set.id : set.name;
|
||||
|
||||
std::unordered_set<std::string> seen;
|
||||
for (const auto& row : rows.value()) {
|
||||
const std::string localId =
|
||||
JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId);
|
||||
if (localId.empty() || !seen.insert(localId).second) continue;
|
||||
|
||||
std::string name;
|
||||
if (auto print = enCatalog.findPrint(set.id, localId)) {
|
||||
name = print->nameEn;
|
||||
if (name.empty()) name = print->nameJa;
|
||||
}
|
||||
if (name.empty()) name = row.nameJa;
|
||||
if (name.empty()) name = localId;
|
||||
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
|
||||
}
|
||||
|
||||
gapFillFromEnCatalog(pack, enCatalog);
|
||||
sortPackCards(pack);
|
||||
if (pack.cards.empty()) {
|
||||
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
|
||||
}
|
||||
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return parsed;
|
||||
|
||||
std::vector<Set> out = std::move(parsed).value();
|
||||
for (auto& s : out) {
|
||||
// Prefer catalog English; never leave Japanese TCGdex names in Set.name
|
||||
// (the set picker must stay English-only).
|
||||
applyEnglishSetName(s, catalog_);
|
||||
if (!s.releaseDate.empty()) continue;
|
||||
|
||||
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<JapanesePokemonSetSource::FetchWithCatalog>
|
||||
JapanesePokemonSetSource::fetchAllWithCatalog() {
|
||||
auto listResp = http_.get(kListEndpoint);
|
||||
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
|
||||
|
||||
auto parsed = parseListResponse(listResp.value());
|
||||
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
|
||||
|
||||
std::vector<Set> sets = std::move(parsed).value();
|
||||
PokemonSetCatalog catalog;
|
||||
catalog.packs.reserve(sets.size());
|
||||
|
||||
for (auto& s : sets) {
|
||||
applyEnglishSetName(s, catalog_);
|
||||
|
||||
auto detail = http_.get(buildSetDetailUrl(s.id));
|
||||
if (!detail) {
|
||||
// Classic / catalog-only products often have no TCGdex detail.
|
||||
auto pack = catalogPackFromEnCatalog(s, catalog_);
|
||||
if (!pack.cards.empty()) {
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s.releaseDate.empty()) {
|
||||
auto date = parseReleaseDate(detail.value());
|
||||
if (date && !date.value().empty()) {
|
||||
s.releaseDate = std::move(date).value();
|
||||
}
|
||||
}
|
||||
|
||||
auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_);
|
||||
if (pack) {
|
||||
catalog.packs.push_back(std::move(pack).value());
|
||||
} else {
|
||||
auto fallback = catalogPackFromEnCatalog(s, catalog_);
|
||||
if (!fallback.cards.empty()) {
|
||||
catalog.packs.push_back(std::move(fallback));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets);
|
||||
out.catalog = std::move(catalog);
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
|
||||
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
|
||||
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
|
||||
for (auto& s : sets) {
|
||||
applyEnglishSetName(s, catalog_);
|
||||
}
|
||||
appendMissingClassicProducts(sets);
|
||||
std::sort(sets.begin(), sets.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,10 +1,15 @@
|
||||
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
|
||||
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
namespace {
|
||||
@@ -39,6 +44,47 @@ void appendMissingSetAliases(std::vector<Set>& sets) {
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
|
||||
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
|
||||
const std::string digits = ygoCollectorDigitsOnly(setNo);
|
||||
if (abbrev.empty() || digits.empty()) return {};
|
||||
return abbrev + "|" + digits;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool ygoHasEnRegionInfix(std::string_view setCode) {
|
||||
const std::string_view s = trimAsciiSpaces(setCode);
|
||||
const auto dash = s.find('-');
|
||||
if (dash == std::string_view::npos || dash + 3 > s.size()) return false;
|
||||
const std::string_view tail = s.substr(dash + 1);
|
||||
if (tail.size() < 3) return false;
|
||||
return (tail[0] == 'E' || tail[0] == 'e') && (tail[1] == 'N' || tail[1] == 'n')
|
||||
&& std::isdigit(static_cast<unsigned char>(tail[2])) != 0;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string uppercaseAscii(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::toupper(c));
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string resolvePackId(const std::unordered_map<std::string, std::string>& nameToId,
|
||||
const std::string& setName,
|
||||
const std::string& setCode) {
|
||||
const auto it = nameToId.find(setName);
|
||||
if (it != nameToId.end() && !it->second.empty()) return it->second;
|
||||
const std::string abbrev = uppercaseAscii(ygoAbbrevBeforeDash(setCode));
|
||||
return abbrev;
|
||||
}
|
||||
|
||||
struct PackBuild {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
// slotKey → index into cards (for EN preference upgrades).
|
||||
std::unordered_map<std::string, std::size_t> slotIndex;
|
||||
std::vector<YuGiOhCatalogCard> cards;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
|
||||
@@ -73,10 +119,116 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
|
||||
}
|
||||
}
|
||||
|
||||
Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& body,
|
||||
const std::vector<Set>& sets) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (!j.is_object() || !j.contains("data") || !j.at("data").is_array()) {
|
||||
return Result<YuGiOhSetCatalog>::err(
|
||||
"YGOPRODeck cardinfo response missing data array.");
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::string> nameToId;
|
||||
nameToId.reserve(sets.size());
|
||||
for (const auto& set : sets) {
|
||||
if (set.name.empty() || set.id.empty()) continue;
|
||||
// First wins — aliases and upstream rows rarely collide by name.
|
||||
nameToId.emplace(set.name, set.id);
|
||||
}
|
||||
|
||||
// Keyed by pack setId.
|
||||
std::unordered_map<std::string, PackBuild> byId;
|
||||
|
||||
for (const auto& cardJson : j.at("data")) {
|
||||
const std::string cardName = cardJson.value("name", "");
|
||||
if (cardName.empty()) continue;
|
||||
if (!cardJson.contains("card_sets") || !cardJson.at("card_sets").is_array()) {
|
||||
continue;
|
||||
}
|
||||
for (const auto& printing : cardJson.at("card_sets")) {
|
||||
const std::string setName = printing.value("set_name", "");
|
||||
const std::string setCode = printing.value("set_code", "");
|
||||
if (setName.empty() || setCode.empty()) continue;
|
||||
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
|
||||
|
||||
const std::string slot = ygoSlotKey(setCode);
|
||||
if (slot.empty()) continue;
|
||||
|
||||
const std::string packId = resolvePackId(nameToId, setName, setCode);
|
||||
if (packId.empty()) continue;
|
||||
|
||||
auto& build = byId[packId];
|
||||
if (build.setId.empty()) {
|
||||
build.setId = packId;
|
||||
build.setName = setName;
|
||||
}
|
||||
|
||||
const auto existing = build.slotIndex.find(slot);
|
||||
if (existing == build.slotIndex.end()) {
|
||||
build.slotIndex.emplace(slot, build.cards.size());
|
||||
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer an EN-embedded code over a bare / other-region equivalent.
|
||||
auto& prev = build.cards[existing->second];
|
||||
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
|
||||
prev.setNo = setCode;
|
||||
if (!cardName.empty()) prev.name = cardName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
YuGiOhSetCatalog catalog;
|
||||
catalog.packs.reserve(byId.size());
|
||||
for (auto& [_, build] : byId) {
|
||||
if (build.setId.empty() || build.cards.empty()) continue;
|
||||
std::sort(build.cards.begin(), build.cards.end(),
|
||||
[](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
YuGiOhSetCatalogPack pack;
|
||||
pack.setId = std::move(build.setId);
|
||||
pack.setName = std::move(build.setName);
|
||||
pack.cards = std::move(build.cards);
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return Result<YuGiOhSetCatalog>::ok(std::move(catalog));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<YuGiOhSetCatalog>::err(
|
||||
std::string("YGOPRODeck catalog parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
Result<YuGiOhSetSource::FetchWithCatalog> YuGiOhSetSource::fetchAllWithCatalog() {
|
||||
auto setsResp = http_.get(kEndpoint);
|
||||
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
|
||||
|
||||
auto sets = parseResponse(setsResp.value());
|
||||
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
|
||||
|
||||
auto infoResp = http_.get(kCardInfoEndpoint);
|
||||
if (!infoResp) return Result<FetchWithCatalog>::err(infoResp.error());
|
||||
|
||||
auto catalog = parseCatalog(infoResp.value(), sets.value());
|
||||
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog).value();
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -12,24 +12,59 @@ JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, Dir
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path JsonSetRepository::setsPath(Game game) const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json";
|
||||
const fs::path root = fs::path(config_.current().dataStorage) / dirName_(game);
|
||||
switch (game) {
|
||||
case Game::Pokemon: return root / "sets-west.json";
|
||||
case Game::JapanesePokemon: return root / "sets-asia.json";
|
||||
default: return root / "sets.json";
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
fs::path JsonSetRepository::legacySetsPath(Game game) const {
|
||||
const fs::path dataRoot(config_.current().dataStorage);
|
||||
switch (game) {
|
||||
case Game::Pokemon:
|
||||
// Pre-flatten: pokemon/sets.json
|
||||
return dataRoot / "pokemon" / "sets.json";
|
||||
case Game::JapanesePokemon:
|
||||
// Pre-flatten: pokemonjp/sets.json
|
||||
return dataRoot / "pokemonjp" / "sets.json";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::parseSetsText(const std::string& text) const {
|
||||
try {
|
||||
auto j = nlohmann::json::parse(text.value());
|
||||
auto j = nlohmann::json::parse(text);
|
||||
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
|
||||
const auto p = setsPath(game);
|
||||
if (fs_.exists(p)) {
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
return parseSetsText(text.value());
|
||||
}
|
||||
|
||||
const auto legacy = legacySetsPath(game);
|
||||
if (!legacy.empty() && fs_.exists(legacy)) {
|
||||
auto text = fs_.readText(legacy);
|
||||
if (!text) return Result<std::vector<Set>>::err(text.error());
|
||||
auto parsed = parseSetsText(text.value());
|
||||
if (!parsed) return parsed;
|
||||
// Best-effort promote to the new path; UI still gets the sets if write fails.
|
||||
(void)save(game, parsed.value());
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return Result<std::vector<Set>>::err("Set list not yet downloaded for this game.");
|
||||
}
|
||||
|
||||
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
|
||||
const auto p = setsPath(game);
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
|
||||
@@ -47,6 +47,7 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) {
|
||||
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;
|
||||
if (containsLower(to_string(card.region), needle)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -67,4 +68,35 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool matchesDigiBattle99Filter(const DigiBattle99Card& 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(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;
|
||||
|
||||
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(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;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -36,11 +37,18 @@ std::string makeUrlKey(std::string_view url) {
|
||||
return k;
|
||||
}
|
||||
|
||||
constexpr std::string_view kAssetScheme = "asset:";
|
||||
|
||||
} // namespace
|
||||
|
||||
CardPreviewService::CardPreviewService(IHttpClient& http,
|
||||
IPreviewByteCache* persistentCache)
|
||||
: http_(http), persistentCache_(persistentCache) {}
|
||||
IPreviewByteCache* persistentCache,
|
||||
IFileSystem* fs,
|
||||
std::filesystem::path assetRoot)
|
||||
: http_(http),
|
||||
persistentCache_(persistentCache),
|
||||
fs_(fs),
|
||||
assetRoot_(std::move(assetRoot)) {}
|
||||
|
||||
void CardPreviewService::registerModule(IGameModule& module) {
|
||||
if (auto* src = module.cardPreviewSource(); src != nullptr) {
|
||||
@@ -122,6 +130,37 @@ Result<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKe
|
||||
return Result<std::string>::ok(std::move(payload));
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError> CardPreviewService::fetchAssetAndCache(
|
||||
const std::string& cacheKey,
|
||||
std::string_view assetUrl) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
if (fs_ == nullptr || assetRoot_.empty()) {
|
||||
return R::err({K::Transient, "Asset preview path is not configured."});
|
||||
}
|
||||
if (!assetUrl.starts_with(kAssetScheme)) {
|
||||
return R::err({K::Transient, "Asset preview URL is missing the asset: prefix."});
|
||||
}
|
||||
std::filesystem::path rel(std::string(assetUrl.substr(kAssetScheme.size())));
|
||||
const auto fullPath = assetRoot_ / rel;
|
||||
auto bytes = fs_->readText(fullPath);
|
||||
if (!bytes) {
|
||||
return R::err({K::NotFound,
|
||||
"Bundled preview asset not found: " + fullPath.generic_string()});
|
||||
}
|
||||
std::string payload = std::move(bytes).value();
|
||||
if (payload.empty()) {
|
||||
return R::err({K::NotFound,
|
||||
"Bundled preview asset is empty: " + fullPath.generic_string()});
|
||||
}
|
||||
cacheStore(cacheKey, payload);
|
||||
if (persistentCache_ != nullptr) {
|
||||
persistentCache_->store(cacheKey, payload);
|
||||
}
|
||||
return R::ok(std::move(payload));
|
||||
}
|
||||
|
||||
Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
std::string_view name,
|
||||
std::string_view setId,
|
||||
@@ -179,6 +218,18 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
|
||||
}
|
||||
return Result<std::string>::err(err.message);
|
||||
}
|
||||
if (url.value().starts_with(kAssetScheme)) {
|
||||
auto asset = fetchAssetAndCache(key, url.value());
|
||||
if (!asset) {
|
||||
const auto err = std::move(asset).error();
|
||||
if (err.kind == PreviewLookupError::Kind::NotFound) {
|
||||
cacheStoreNegative(key);
|
||||
if (persistentCache_ != nullptr) persistentCache_->storeNegative(key);
|
||||
}
|
||||
return Result<std::string>::err(err.message);
|
||||
}
|
||||
return Result<std::string>::ok(std::move(asset).value());
|
||||
}
|
||||
return fetchAndCache(key, url.value());
|
||||
}
|
||||
|
||||
@@ -231,6 +282,11 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
|
||||
return Result<std::string>::ok(disk.payload);
|
||||
}
|
||||
}
|
||||
if (url.starts_with(kAssetScheme)) {
|
||||
auto asset = fetchAssetAndCache(key, url);
|
||||
if (!asset) return Result<std::string>::err(asset.error().message);
|
||||
return Result<std::string>::ok(std::move(asset).value());
|
||||
}
|
||||
return fetchAndCache(key, url);
|
||||
}
|
||||
|
||||
|
||||
@@ -223,4 +223,144 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
}
|
||||
}
|
||||
|
||||
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
DigiBattle99SortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case DigiBattle99SortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
|
||||
JapanesePokemonSortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case JapanesePokemonSortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case JapanesePokemonSortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const JapanesePokemonCard& a, const JapanesePokemonCard& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
DigiBattle99SetCatalogService::DigiBattle99SetCatalogService(IFileSystem& fs,
|
||||
ConfigService& config,
|
||||
DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path DigiBattle99SetCatalogService::catalogPath() const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(Game::DigiBattle99) /
|
||||
"set-catalog.json";
|
||||
}
|
||||
|
||||
bool DigiBattle99SetCatalogService::exists() const {
|
||||
return fs_.exists(catalogPath());
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetCatalog> DigiBattle99SetCatalogService::load() const {
|
||||
const auto p = catalogPath();
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<DigiBattle99SetCatalog>::err(
|
||||
"Digimon Digi-Battle set catalog not yet downloaded.");
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<DigiBattle99SetCatalog>::err(text.error());
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(text.value());
|
||||
return Result<DigiBattle99SetCatalog>::ok(j.get<DigiBattle99SetCatalog>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<DigiBattle99SetCatalog>::err(
|
||||
std::string("set-catalog.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> DigiBattle99SetCatalogService::save(const DigiBattle99SetCatalog& catalog) {
|
||||
const auto p = catalogPath();
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dir) return dir;
|
||||
const nlohmann::json j = catalog;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,129 @@
|
||||
#include "ccm/services/DigiBattle99SetCompletion.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
|
||||
|
||||
bool passesLanguageFilter(const DigiBattle99Card& card,
|
||||
std::optional<Language> languageFilter) {
|
||||
return !languageFilter.has_value() || card.language == *languageFilter;
|
||||
}
|
||||
|
||||
OwnedBySet ownedSetNosBySetId(const std::vector<DigiBattle99Card>& collection,
|
||||
std::optional<Language> languageFilter) {
|
||||
OwnedBySet out;
|
||||
for (const auto& card : collection) {
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
if (card.set.id.empty()) continue;
|
||||
const std::string setNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (setNo.empty()) continue;
|
||||
out[card.set.id].insert(setNo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<Language>
|
||||
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection) {
|
||||
const auto& langs = allLanguages();
|
||||
std::array<bool, 10> present{};
|
||||
for (const auto& card : collection) {
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (langs[i] == card.language) {
|
||||
present[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Language> out;
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (present[i]) out.push_back(langs[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99SetCompletionProgress>
|
||||
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::optional<Language> languageFilter) {
|
||||
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
|
||||
|
||||
std::vector<DigiBattle99SetCompletionProgress> out;
|
||||
out.reserve(owned.size());
|
||||
|
||||
for (const auto& [setId, ownedNos] : owned) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr || pack->cards.empty()) continue;
|
||||
|
||||
std::size_t matched = 0;
|
||||
for (const auto& card : pack->cards) {
|
||||
const std::string catalogNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
|
||||
}
|
||||
|
||||
DigiBattle99SetCompletionProgress row;
|
||||
row.setId = pack->setId;
|
||||
row.setName = pack->setName;
|
||||
row.ownedUnique = matched;
|
||||
row.total = pack->cards.size();
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const DigiBattle99SetCompletionProgress& a,
|
||||
const DigiBattle99SetCompletionProgress& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99ChecklistEntry>
|
||||
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr) return {};
|
||||
|
||||
std::unordered_set<std::string> ownedNos;
|
||||
for (const auto& card : collection) {
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
if (card.set.id != setId) continue;
|
||||
const std::string setNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (!setNo.empty()) ownedNos.insert(setNo);
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99ChecklistEntry> out;
|
||||
out.reserve(pack->cards.size());
|
||||
for (const auto& card : pack->cards) {
|
||||
DigiBattle99ChecklistEntry entry;
|
||||
entry.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
entry.name = card.name;
|
||||
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
|
||||
out.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const DigiBattle99ChecklistEntry& a,
|
||||
const DigiBattle99ChecklistEntry& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,54 @@
|
||||
#include "ccm/services/PokemonSetCatalogService.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
PokemonSetCatalogService::PokemonSetCatalogService(IFileSystem& fs,
|
||||
ConfigService& config,
|
||||
DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path PokemonSetCatalogService::catalogPath(PokemonRegion region) const {
|
||||
const char* file = region == PokemonRegion::Asia ? "set-catalog-asia.json"
|
||||
: "set-catalog-west.json";
|
||||
return fs::path(config_.current().dataStorage) / dirName_(Game::Pokemon) / file;
|
||||
}
|
||||
|
||||
bool PokemonSetCatalogService::exists(PokemonRegion region) const {
|
||||
return fs_.exists(catalogPath(region));
|
||||
}
|
||||
|
||||
Result<PokemonSetCatalog> PokemonSetCatalogService::load(PokemonRegion region) const {
|
||||
const auto p = catalogPath(region);
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<PokemonSetCatalog>::err(
|
||||
region == PokemonRegion::Asia
|
||||
? "Asia Pokemon set catalog not yet downloaded."
|
||||
: "West Pokemon set catalog not yet downloaded.");
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<PokemonSetCatalog>::err(text.error());
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(text.value());
|
||||
return Result<PokemonSetCatalog>::ok(j.get<PokemonSetCatalog>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<PokemonSetCatalog>::err(
|
||||
std::string("set-catalog.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> PokemonSetCatalogService::save(PokemonRegion region,
|
||||
const PokemonSetCatalog& catalog) {
|
||||
const auto p = catalogPath(region);
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dir) return dir;
|
||||
const nlohmann::json j = catalog;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,217 @@
|
||||
#include "ccm/services/PokemonSetCompletion.hpp"
|
||||
|
||||
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
|
||||
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/util/SetNoNatural.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
struct OwnedSetInfo {
|
||||
std::unordered_set<std::string> nos;
|
||||
std::string releaseDate;
|
||||
};
|
||||
|
||||
using OwnedBySet = std::unordered_map<std::string, OwnedSetInfo>;
|
||||
|
||||
bool passesLanguageFilter(const PokemonCard& card, std::optional<Language> languageFilter) {
|
||||
return !languageFilter.has_value() || card.language == *languageFilter;
|
||||
}
|
||||
|
||||
bool passesRegionFilter(const PokemonCard& card, std::optional<PokemonRegion> regionFilter) {
|
||||
return !regionFilter.has_value() || card.region == *regionFilter;
|
||||
}
|
||||
|
||||
std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
|
||||
if (region == PokemonRegion::Asia) {
|
||||
return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo);
|
||||
}
|
||||
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
|
||||
}
|
||||
|
||||
std::string westSetKey(std::string_view setId) {
|
||||
return canonicalizeWestSetId(setId);
|
||||
}
|
||||
|
||||
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
|
||||
PokemonRegion region,
|
||||
std::optional<Language> languageFilter) {
|
||||
OwnedBySet out;
|
||||
for (const auto& card : collection) {
|
||||
if (card.region != region) continue;
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
if (card.set.id.empty()) continue;
|
||||
const std::string setNo = normalizeForRegion(region, card.setNo);
|
||||
if (setNo.empty()) continue;
|
||||
const std::string setKey =
|
||||
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
|
||||
auto& info = out[setKey];
|
||||
info.nos.insert(setNo);
|
||||
if (info.releaseDate.empty() && !card.set.releaseDate.empty()) {
|
||||
info.releaseDate = card.set.releaseDate;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<PokemonSetCompletionProgress>
|
||||
computeForCatalog(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& catalog,
|
||||
PokemonRegion region,
|
||||
std::optional<Language> languageFilter) {
|
||||
const OwnedBySet owned = ownedSetNosBySetId(collection, region, languageFilter);
|
||||
|
||||
std::vector<PokemonSetCompletionProgress> out;
|
||||
out.reserve(owned.size());
|
||||
|
||||
for (const auto& [setId, info] : owned) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr || pack->cards.empty()) continue;
|
||||
|
||||
std::size_t matched = 0;
|
||||
for (const auto& card : pack->cards) {
|
||||
const std::string catalogNo = normalizeForRegion(region, card.setNo);
|
||||
if (!catalogNo.empty() && info.nos.count(catalogNo) != 0) ++matched;
|
||||
}
|
||||
|
||||
PokemonSetCompletionProgress row;
|
||||
row.region = region;
|
||||
row.setId = pack->setId;
|
||||
row.setName = pack->setName;
|
||||
row.releaseDate = info.releaseDate;
|
||||
row.ownedUnique = matched;
|
||||
row.total = pack->cards.size();
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<Language>
|
||||
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
|
||||
std::optional<PokemonRegion> regionFilter) {
|
||||
const auto& langs = allLanguages();
|
||||
std::array<bool, 10> present{};
|
||||
for (const auto& card : collection) {
|
||||
if (!passesRegionFilter(card, regionFilter)) continue;
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (langs[i] == card.language) {
|
||||
present[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Language> out;
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (present[i]) out.push_back(langs[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<PokemonRegion>
|
||||
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog) {
|
||||
std::vector<PokemonRegion> out;
|
||||
const auto westRows =
|
||||
computeForCatalog(collection, westCatalog, PokemonRegion::West, std::nullopt);
|
||||
if (!westRows.empty()) out.push_back(PokemonRegion::West);
|
||||
const auto asiaRows =
|
||||
computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, std::nullopt);
|
||||
if (!asiaRows.empty()) out.push_back(PokemonRegion::Asia);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<PokemonSetCompletionProgress>
|
||||
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
std::optional<PokemonRegion> regionFilter,
|
||||
std::optional<Language> languageFilter) {
|
||||
std::vector<PokemonSetCompletionProgress> out;
|
||||
|
||||
const bool includeWest =
|
||||
!regionFilter.has_value() || *regionFilter == PokemonRegion::West;
|
||||
const bool includeAsia =
|
||||
!regionFilter.has_value() || *regionFilter == PokemonRegion::Asia;
|
||||
|
||||
if (includeWest) {
|
||||
auto west = computeForCatalog(collection, westCatalog, PokemonRegion::West,
|
||||
languageFilter);
|
||||
out.insert(out.end(), std::make_move_iterator(west.begin()),
|
||||
std::make_move_iterator(west.end()));
|
||||
}
|
||||
if (includeAsia) {
|
||||
auto asia = computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia,
|
||||
languageFilter);
|
||||
out.insert(out.end(), std::make_move_iterator(asia.begin()),
|
||||
std::make_move_iterator(asia.end()));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const PokemonSetCompletionProgress& a,
|
||||
const PokemonSetCompletionProgress& b) {
|
||||
// YYYY/MM/DD lex order is chronological (CardSorter parity).
|
||||
if (a.releaseDate != b.releaseDate) {
|
||||
return a.releaseDate < b.releaseDate;
|
||||
}
|
||||
if (a.setName != b.setName) return a.setName < b.setName;
|
||||
return static_cast<int>(a.region) < static_cast<int>(b.region);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<PokemonChecklistEntry>
|
||||
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
|
||||
const PokemonSetCatalog& westCatalog,
|
||||
const PokemonSetCatalog& asiaCatalog,
|
||||
PokemonRegion region,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter) {
|
||||
const PokemonSetCatalog& catalog =
|
||||
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
|
||||
const std::string wantSetId =
|
||||
region == PokemonRegion::West ? westSetKey(setId) : std::string(setId);
|
||||
const auto* pack = catalog.findPack(wantSetId);
|
||||
if (pack == nullptr) return {};
|
||||
|
||||
std::unordered_set<std::string> ownedNos;
|
||||
for (const auto& card : collection) {
|
||||
if (card.region != region) continue;
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
const std::string cardSetId =
|
||||
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
|
||||
if (cardSetId != wantSetId) continue;
|
||||
const std::string setNo = normalizeForRegion(region, card.setNo);
|
||||
if (!setNo.empty()) ownedNos.insert(setNo);
|
||||
}
|
||||
|
||||
std::vector<PokemonChecklistEntry> out;
|
||||
out.reserve(pack->cards.size());
|
||||
for (const auto& card : pack->cards) {
|
||||
PokemonChecklistEntry entry;
|
||||
entry.setNo = normalizeForRegion(region, card.setNo);
|
||||
entry.name = card.name;
|
||||
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
|
||||
out.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
|
||||
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
|
||||
if (cmp != 0) return cmp < 0;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -20,8 +20,18 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
|
||||
return fetched;
|
||||
}
|
||||
|
||||
Result<void> SetService::saveSets(Game game, const std::vector<Set>& sets) {
|
||||
return repo_.save(game, sets);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> SetService::getSets(Game game) {
|
||||
return repo_.load(game);
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return loaded;
|
||||
auto it = modules_.find(game);
|
||||
if (it != modules_.end() && it->second != nullptr) {
|
||||
it->second->setSource().augmentCachedSets(loaded.value());
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#include "ccm/services/YuGiOhSetCatalogService.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
YuGiOhSetCatalogService::YuGiOhSetCatalogService(IFileSystem& fs,
|
||||
ConfigService& config,
|
||||
DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path YuGiOhSetCatalogService::catalogPath() const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOh) /
|
||||
"set-catalog.json";
|
||||
}
|
||||
|
||||
bool YuGiOhSetCatalogService::exists() const {
|
||||
return fs_.exists(catalogPath());
|
||||
}
|
||||
|
||||
Result<YuGiOhSetCatalog> YuGiOhSetCatalogService::load() const {
|
||||
const auto p = catalogPath();
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<YuGiOhSetCatalog>::err("Yu-Gi-Oh! set catalog not yet downloaded.");
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<YuGiOhSetCatalog>::err(text.error());
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(text.value());
|
||||
return Result<YuGiOhSetCatalog>::ok(j.get<YuGiOhSetCatalog>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<YuGiOhSetCatalog>::err(
|
||||
std::string("set-catalog.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> YuGiOhSetCatalogService::save(const YuGiOhSetCatalog& catalog) {
|
||||
const auto p = catalogPath();
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dir) return dir;
|
||||
const nlohmann::json j = catalog;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,132 @@
|
||||
#include "ccm/services/YuGiOhSetCompletion.hpp"
|
||||
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
|
||||
|
||||
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
|
||||
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
|
||||
const std::string digits = ygoCollectorDigitsOnly(setNo);
|
||||
if (abbrev.empty() || digits.empty()) return {};
|
||||
return abbrev + "|" + digits;
|
||||
}
|
||||
|
||||
bool passesLanguageFilter(const YuGiOhCard& card, std::optional<Language> languageFilter) {
|
||||
return !languageFilter.has_value() || card.language == *languageFilter;
|
||||
}
|
||||
|
||||
OwnedBySet ownedSlotsBySetId(const std::vector<YuGiOhCard>& collection,
|
||||
std::optional<Language> languageFilter) {
|
||||
OwnedBySet out;
|
||||
for (const auto& card : collection) {
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
if (card.set.id.empty()) continue;
|
||||
const std::string key = ygoSlotKey(card.setNo);
|
||||
if (key.empty()) continue;
|
||||
out[card.set.id].insert(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<Language>
|
||||
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection) {
|
||||
const auto& langs = allLanguages();
|
||||
std::array<bool, 10> present{};
|
||||
for (const auto& card : collection) {
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (langs[i] == card.language) {
|
||||
present[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Language> out;
|
||||
for (std::size_t i = 0; i < langs.size(); ++i) {
|
||||
if (present[i]) out.push_back(langs[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<YuGiOhSetCompletionProgress>
|
||||
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::optional<Language> languageFilter) {
|
||||
const OwnedBySet owned = ownedSlotsBySetId(collection, languageFilter);
|
||||
|
||||
std::vector<YuGiOhSetCompletionProgress> out;
|
||||
out.reserve(owned.size());
|
||||
|
||||
for (const auto& [setId, ownedSlots] : owned) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr || pack->cards.empty()) continue;
|
||||
|
||||
std::size_t matched = 0;
|
||||
for (const auto& card : pack->cards) {
|
||||
const std::string key = ygoSlotKey(card.setNo);
|
||||
if (!key.empty() && ownedSlots.count(key) != 0) ++matched;
|
||||
}
|
||||
|
||||
YuGiOhSetCompletionProgress row;
|
||||
row.setId = pack->setId;
|
||||
row.setName = pack->setName;
|
||||
row.ownedUnique = matched;
|
||||
row.total = pack->cards.size();
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const YuGiOhSetCompletionProgress& a,
|
||||
const YuGiOhSetCompletionProgress& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<YuGiOhChecklistEntry>
|
||||
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
|
||||
const YuGiOhSetCatalog& catalog,
|
||||
std::string_view setId,
|
||||
std::optional<Language> languageFilter) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr) return {};
|
||||
|
||||
std::unordered_set<std::string> ownedSlots;
|
||||
for (const auto& card : collection) {
|
||||
if (!passesLanguageFilter(card, languageFilter)) continue;
|
||||
if (card.set.id != setId) continue;
|
||||
const std::string key = ygoSlotKey(card.setNo);
|
||||
if (!key.empty()) ownedSlots.insert(key);
|
||||
}
|
||||
|
||||
std::vector<YuGiOhChecklistEntry> out;
|
||||
out.reserve(pack->cards.size());
|
||||
for (const auto& card : pack->cards) {
|
||||
YuGiOhChecklistEntry entry;
|
||||
entry.setNo = card.setNo;
|
||||
entry.name = card.name;
|
||||
const std::string key = ygoSlotKey(card.setNo);
|
||||
entry.owned = !key.empty() && ownedSlots.count(key) != 0;
|
||||
out.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const YuGiOhChecklistEntry& a, const YuGiOhChecklistEntry& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -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, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), 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!, 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`). 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-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`) — 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`, `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/`).
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ This folder contains contributor documentation for Card Collection Manager 3. St
|
||||
|
||||
- [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`.
|
||||
|
||||
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, and Yu-Gi-Oh! modules, preview fallback URLs / bundled YGO card-back asset, and shared HTTP behavior (`CprHttpClient`).
|
||||
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, Yu-Gi-Oh!, and Digimon Digi-Battle modules, preview fallback URLs / bundled card-back assets, and shared HTTP behavior (`CprHttpClient`).
|
||||
|
||||
## Performance & Caching
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ private:
|
||||
|
||||
Two subtle requirements:
|
||||
|
||||
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short.
|
||||
- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short. **Pokemon exception:** a unified Game menu entry may keep one `dirName` (`pokemon`) for collection/images and disambiguate region set caches by filename (`sets-west.json` / `sets-asia.json`) instead of a second data subdirectory.
|
||||
- `cardPreviewSource()` defaults to `nullptr` in `IGameModule`. Only override it if you actually have a preview source. Returning `nullptr` makes `CardPreviewService::registerModule(*module)` a silent no-op for that game; the UI gracefully falls back to "no preview available".
|
||||
|
||||
The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`.
|
||||
@@ -360,12 +360,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.
|
||||
|
||||
|
||||
+217
-11
@@ -18,17 +18,37 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`,
|
||||
|
||||
## Pokemon APIs
|
||||
|
||||
**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.
|
||||
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`, TCGdex EN)
|
||||
|
||||
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?q=...`
|
||||
Used by `PokemonCardPreviewSource` in two ways:
|
||||
|
||||
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
|
||||
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/en/cards/{setId}-{localId}` (card object with `image` base). On HTTP failure or missing image, falls back to a filtered search `set.id=eq:…&localId=eq:…` (collector numbers are unique within a set). When Set # or set id is missing, uses `name=eq:…` with optional `set.id` / `localId`. Legacy set ids are canonicalized before URL build.
|
||||
|
||||
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the 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 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 `number:` query expects only the printed-number component.
|
||||
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `localId` path expects only the printed-number component.
|
||||
|
||||
### Set-completion catalog (West)
|
||||
|
||||
**Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes:
|
||||
|
||||
1. The set list (`pokemon/sets-west.json`) from `/v2/en/sets` + per-set detail dates
|
||||
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-west.json` from each set’s detail `cards[]` (`localId` → `setNo`, `name` → name)
|
||||
|
||||
Each catalog pack stores `id` (TCGdex EN set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by `localId` (normalized by stripping anything after `/`). Duplicate collector numbers within a pack collapse to one checklist row. The Pokemon **Set Completion** tab reads this file offline; ownership for a West pack requires `PokemonRegion::West`, a canonicalized `card.set.id` match, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts.
|
||||
|
||||
After a successful Update, `PokemonGameView` also runs `syncPokemonCollectionSets` against the refreshed set lists: West cards get legacy set-id migration plus `set.name` / `releaseDate` refresh when the id is present; Asia cards refresh name/date the same way. Changed cards are persisted via `CollectionService::saveAll`.
|
||||
|
||||
If `set-catalog-west.json` is missing (and the active region filter is West or All with no Asia catalog either), the Set Completion tab prompts the user to run Update Pokemon.
|
||||
|
||||
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
|
||||
|
||||
@@ -78,6 +98,190 @@ Used in two situations:
|
||||
|
||||
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)
|
||||
|
||||
**Sets → Update Yu-Gi-Oh!** uses `YuGiOhSetSource::fetchAllWithCatalog()` so two HTTP responses write:
|
||||
|
||||
1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases)
|
||||
2. A pack checklist at `<dataStorage>/yugioh/set-catalog.json` from the unfiltered `cardinfo.php` dump
|
||||
|
||||
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name }` 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.
|
||||
|
||||
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
|
||||
|
||||
## 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).
|
||||
|
||||
### Info API: derived set list from `search.php`
|
||||
|
||||
There is **no** dedicated sets endpoint. `DigiBattle99SetSource` calls:
|
||||
|
||||
`https://digimoncard.io/api-public/search.php?series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc`
|
||||
|
||||
and collects unique `set_name[]` pack strings. Each pack becomes a `Set` with:
|
||||
|
||||
- `Set.name` — exact pack display name (used as `pack=` on search / auto-detect)
|
||||
- `Set.id` — stable slug (`Series 1 Starter Set` → `series-1-starter-set`); never rename after ship
|
||||
- `Set.releaseDate` — curated table in the set source (Series 1 Starter = `1999/06/01` verified; other packs use documented year/month anchors)
|
||||
|
||||
Unknown future packs get an empty release date and sort last.
|
||||
|
||||
Cached on disk as `<dataStorage>/digibattle99/sets.json` via `SetService` / `JsonSetRepository`.
|
||||
|
||||
### Set-completion catalog (same `search.php` payload)
|
||||
|
||||
**Sets → Update Digimon (Digi-Battle)** uses `DigiBattle99SetSource::fetchAllWithCatalog()` so one HTTP response writes both:
|
||||
|
||||
1. The set list (`sets.json`) as above
|
||||
2. A pack checklist at `<dataStorage>/digibattle99/set-catalog.json`
|
||||
|
||||
Each catalog pack stores `id` (slug), `name` (display), and `cards[]` of `{ setNo, name }` (API `id` normalized like preview — alphabetic prefix uppercased). A card listed in multiple `set_name[]` packs appears under **each** pack. The Digimon **Set Completion** tab reads this file offline (no live HTTP while browsing); ownership for a pack requires matching `card.set.id` plus normalized `setNo`.
|
||||
|
||||
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Digimon (Digi-Battle).
|
||||
|
||||
### Asset API: CDN images + `search.php` lookup
|
||||
|
||||
Card scans live at:
|
||||
|
||||
`https://images.digimoncard.io/images/cards/{id}.jpg`
|
||||
|
||||
where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also serves `.webp`, but CCM3 uses `.jpg` because `OnInit` only registers `wxPNGHandler` / `wxJPEGHandler` (WebP bytes would surface as “image decode failed”).
|
||||
|
||||
`DigiBattle99CardPreviewSource::fetchImageUrl`:
|
||||
|
||||
1. If `setNo` is non-empty → normalize alphabetic prefix to uppercase (**no** invented zero-padding) and return the CDN URL with **no** search round-trip.
|
||||
2. Otherwise search with `n=` + optional `pack=` (display set name) + `series=`, take the first exact name match’s `id`, then build the CDN URL.
|
||||
|
||||
**Preview key:** `(name, set.name, setNo)` — middle slot is the pack **display name** (same idea as Yu-Gi-Oh! passing `set.name` for YGOPRODeck `cardset=`), not the slug id.
|
||||
|
||||
**Auto-detect** (`detectPrintVariants`): same search; distinct `id` values become `AutoDetectedPrint::setNo`. Digi-Battle UI is Pokémon-like (no persisted rarity).
|
||||
|
||||
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 TCGdex EN ids.
|
||||
|
||||
### Info API: TCGdex `GET /v2/ja/sets` (+ per-set detail)
|
||||
|
||||
`https://api.tcgdex.net/v2/ja/sets` returns a slim array (`id`, `name`, `cardCount`). Release dates require `GET /v2/ja/sets/{id}` (`releaseDate` as `YYYY-MM-DD`, rewritten to `YYYY/MM/DD`). `JapanesePokemonSetSource`:
|
||||
|
||||
- Excludes Chinese-region `CS*` junk rows mislabeled on the JA endpoint.
|
||||
- Applies field overrides (e.g. `SV4a` Japanese name → `シャイニートレジャーex`).
|
||||
- Prefers English display names and release dates from the bundled EN catalog when present; otherwise keeps the TCGdex Japanese name and fetches detail for the date.
|
||||
- After parsing the TCGdex list, **injects Original-era / catalog-only products TCGdex omits** (idempotent by set id — skipped if upstream later adds them). The same injection runs when loading a cached Asia set list (`sets-asia.json`) via `ISetSource::augmentCachedSets`, so these products appear without requiring **Update Sets** first. Stable ids and English names:
|
||||
|
||||
| Id | English name |
|
||||
|---|---|
|
||||
| `UnnumberedPromo` | Unnumbered Promotional cards (Bulbapedia catch-all; synthetic `001`… localIds; preview via catalog `image_url` preferring Japanese / Unnumbered Bulbagarden scans) |
|
||||
| `ExpSheet1` / `ExpSheet2` / `ExpSheet3` | Expansion Sheet Series 1–3 |
|
||||
| `NiviCG` | Nivi City Gym |
|
||||
| `HanadaCG` | Hanada City Gym |
|
||||
| `KuchibaCG` | Kuchiba City Gym |
|
||||
| `TamamushiCG` | Tamamushi City Gym |
|
||||
| `YamabukiCG` | Yamabuki City Gym |
|
||||
| `GurenTG` | Guren Town Gym |
|
||||
| `SouthernIslands` | Southern Islands |
|
||||
|
||||
Seed data lives in `tools/pokemon_jp/classic_missing_sets.json` + `classic_missing_prints.json` (merged into the EN catalog via `merge_classic_missing.py`). LocalIds for these products are sequential `001`… within each product (cards were unnumbered in print). Refresh `UnnumberedPromo` prints from Bulbapedia with `python tools/pokemon_jp/harvest_unnumbered_promos.py`, then fill preview images with `python tools/pokemon_jp/enrich_unnumbered_promo_images.py` (prefers Unnumbered / Japanese reprint-gallery scans over English Wizards `|image=` primaries; EN-only Bulbapedia pages leave `image_url` empty), then re-run `merge_classic_missing.py`. Numbered Japanese promo eras (`SV-P`, `S-P`, …) remain out of scope — TCGdex does not expose them, and they are not part of this curated set.
|
||||
|
||||
### Set-completion catalog (Asia)
|
||||
|
||||
**Sets → Update Pokemon** uses `JapanesePokemonSetSource::fetchAllWithCatalog()` so the Asia path writes:
|
||||
|
||||
1. The set list (`pokemon/sets-asia.json`) as above (EN names + classic product injection)
|
||||
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-asia.json`
|
||||
|
||||
For each set, the source `GET`s `/v2/ja/sets/{id}` and builds checklist rows from `cards[]` (`localId` → `setNo`, display name prefers EN catalog `nameEn`, else TCGdex Japanese `name`). Prints present in the bundled EN catalog but missing from TCGdex `cards[]` are **gap-filled** into the pack (covers UnnumberedPromo / City Gym / Expansion Sheets / Southern Islands and sparse classic sets). Catalog-only products with no TCGdex detail become packs entirely from `JapanesePokemonEnCatalog::printsForSet`.
|
||||
|
||||
The Pokemon **Set Completion** tab also loads this file offline; ownership for an Asia pack requires `PokemonRegion::Asia`, matching `card.set.id`, and `normalizeLocalId` on `setNo`. Region and language filters on the tab restrict which packs/cards count. West and Asia never cross-count.
|
||||
|
||||
If `set-catalog-asia.json` is missing (and the active region filter needs it), the Set Completion tab prompts the user to run Update Pokemon.
|
||||
|
||||
### Sets without printed collector numbers (`UnnumberedPromo`)
|
||||
|
||||
Physically unnumbered Japanese promos (and the other classic catalog-only products above) have **no printed set number**. The app still stores a synthetic `setNo` / catalog `local_id` (`001`, `002`, …) so preview and collection JSON stay keyed by `(setId, localId)` — but that value must not be treated as something the user can read off the card.
|
||||
|
||||
**Edit dialog (`PokemonCardEditDialog`, Asia region) for set id `UnnumberedPromo`:**
|
||||
|
||||
- The **Set #** text field is hidden (row label becomes **Print**). **Auto detect** and **Next** remain.
|
||||
- Auto-detect / silent Edit prefetch lists catalog prints matching the typed name (exact EN/JA, plus qualified titles such as `Mewtwo` → `Mewtwo (CoroCoro promo)`). Distinct synthetic localIds form the Next ring.
|
||||
- **Next** on the edit form shows a position counter (`Next (2/5)`), not the synthetic id. For ordinary numbered JP sets, Next still shows the current collector number (`Next (42)`).
|
||||
- A modeless **Print preview** popup (`VariantImagePreviewDialog`) opens ~20px to the right of the Add/Edit dialog. It loads the current print via `CardPreviewService::fetchPreviewBytes` and refreshes on each ring step. The popup has its own **`<< Prev` / `Next >>`** controls that drive the same ring as the edit dialog (buttons disabled when fewer than two variants).
|
||||
- On save, the dialog writes the ring’s synthetic `setNo` into `PokemonCard::setNo` even though the text field was hidden.
|
||||
|
||||
Other classic unnumbered products (City Gyms, Expansion Sheets, Southern Islands) currently keep the normal Set # field; only `UnnumberedPromo` uses the print-preview UX above.
|
||||
|
||||
### Asset API: TCGdex card / set-detail images
|
||||
|
||||
Preview is **local-id based**. `JapanesePokemonCardPreviewSource`:
|
||||
|
||||
1. With `setId` + `setNo` (`localId`), tries `GET /v2/ja/cards/{setId}-{localId}` and reads `image`.
|
||||
2. Falls back to set-detail `cards[]` (which often already carries `image` on modern sets).
|
||||
3. Appends `/high.png` to the TCGdex image base URL (PNG — wxImage does not decode webp).
|
||||
4. If the set-specific card still has no scan (classic sets like `PMCG1`), looks up the bundled EN catalog print for that exact `setId`+`localId` and uses optional `image_url` or a TCGPlayer product image built from `tcgplayer_id` (`https://product-images.tcgplayer.com/fit-in/437x437/{id}.jpg`). Gap-fill sources differ by era:
|
||||
- **PMCG and other data-asia sets with `thirdParty.tcgplayer`**: printing-accurate `tcgplayer_id` harvested offline from [tcgdex/cards-database](https://github.com/tcgdex/cards-database) `data-asia` (the live TCGdex API does not expose them).
|
||||
- **neo1–neo4**: data-asia has no `tcgplayer_id` and TCGdex JA `image` is null; the catalog may carry an ETL-written `image_url` from a **Japanese** [CardIndex](https://www.cardindex.co/) set scan (`enrich_neo_image_urls.py` scrapes the JA neo set pages and matches by English card name). **No English pokemontcg.io fallback** — if CardIndex has no JP image, `image_url` is left empty and the UI shows the card-back. Use `--overwrite` to re-resolve / clear stale EN URLs. Runtime still resolves only by exact JA `setId`+`localId` — no C++ name search across printings.
|
||||
This is **printing-accurate** gap-fill — not a name search across other Charizard printings at runtime.
|
||||
5. For **catalog-only products** (Unnumbered Promotional cards, City Gym theme decks, Expansion Sheets, Southern Islands), when TCGdex set/card GETs fail, Auto-detect and preview fall back to the bundled catalog prints for that `setId` (EN/JA name → `localId`; optional `tcgplayer_id` / `image_url` for preview). `UnnumberedPromo` rows typically carry Bulbagarden Archives `image_url` values written by `enrich_unnumbered_promo_images.py`, which prefers Japanese / Unnumbered Promotional reprint scans and omits English-only Wizards Black Star primaries when no JP file is available. Without a catalog image field, preview returns `NotFound` and the UI shows the card-back. Auto-detect matches exact EN/JA names and also qualified English titles (`Mewtwo` → `Mewtwo (CoroCoro promo)`).
|
||||
6. City Gym deck exclusives must stay **printing-accurate**. Do **not** reuse Leaders' Stadium / PMCG donor `tcgplayer_id`s for those prints; that shows the wrong set art. Instead, bundle local scans under `assets/pokemon_jp_classic/<setId>/<localId>.jpg` and point the catalog row at `image_url: "asset:pokemon_jp_classic/<setId>/<localId>.jpg"`. `CardPreviewService` loads `asset:` URLs from disk next to the executable, bypassing HTTP entirely.
|
||||
|
||||
It does **not** substitute another printing of the same Pokémon when both TCGdex and the catalog lack an image. Then preview returns `NotFound` and the UI shows the Japanese TCG card-back.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
### Variant Pokémon English titles
|
||||
|
||||
Auto-detect for English owner / Rocket's / Dark / Light / Shining Pokémon names requires the bundled catalog's **full** `name_en` for that print (same rule as City Gym manuals that already store `Erika's Oddish`). Typing the Japanese TCGdex name still works when `name_ja` is correct.
|
||||
|
||||
To extend variant coverage:
|
||||
|
||||
1. Add new JA prefix → English title prefix pairs to `VARIANT_JA_PREFIXES` in [`tools/pokemon_jp/enrich_preview_images.py`](../tools/pokemon_jp/enrich_preview_images.py) (longest prefixes first).
|
||||
2. Re-run:
|
||||
|
||||
```bash
|
||||
python tools/pokemon_jp/enrich_preview_images.py
|
||||
```
|
||||
|
||||
3. For neo1–neo4 Japanese preview images (CardIndex JP scans only; clears EN
|
||||
pokemontcg.io URLs on miss), run:
|
||||
|
||||
```bash
|
||||
python tools/pokemon_jp/enrich_neo_image_urls.py
|
||||
python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite
|
||||
```
|
||||
|
||||
4. Rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated.
|
||||
|
||||
Rows with `name_en_source: "manual"` (City Gym theme decks in `classic_missing_prints.json`) are never overwritten. Prefer stable English TCG product names (Bulbapedia / Limitless English titles).
|
||||
|
||||
### Extending Trainer/Energy English aliases
|
||||
|
||||
Auto-detect for English Trainer/Energy names only works when the bundled catalog has a `name_en` for that print. Pokémon get `name_en` automatically from `dexId` (bare species) or from variant prefix composition (full titles); Trainers and Energy do not. To add more sets or staples:
|
||||
|
||||
1. Collect unique Japanese Trainer/Energy names for the sets you care about (from TCGdex set detail `cards[].name`, or from `tools/pokemon_jp/_tcgdex_cards_database/data-asia/<serie>/<setId>/*.ts` after running enrich once).
|
||||
2. Add each missing `name_ja` → English display name to [`tools/pokemon_jp/non_pokemon_en_by_ja.json`](../tools/pokemon_jp/non_pokemon_en_by_ja.json). One entry covers **every set** that reprints that Japanese title.
|
||||
3. Re-run:
|
||||
|
||||
```bash
|
||||
python tools/pokemon_jp/enrich_preview_images.py
|
||||
```
|
||||
|
||||
4. Confirm `enrich_preview_images.py` prints `FIRST15 trainer/energy coverage OK` (or extend `FIRST15_SETS` in that script if you raise the coverage baseline). Copy/rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated.
|
||||
5. Prefer stable English TCG product names (Bulbapedia / Limitless English titles). Do not invent per-set aliases that differ for the same `name_ja`.
|
||||
|
||||
### Bundled English catalog
|
||||
|
||||
`ui_wx/assets/pokemon_jp_en_catalog.json` is copied next to the exe on build (`assets/pokemon_jp_en_catalog.json`). It supplies English set/card names TCGdex JA cannot provide, plus optional classic-image gap-fill fields (`tcgplayer_id` / `image_url`). Generated offline via `tools/pokemon_jp/` (set EN merge + `enrich_preview_images.py` using species, variant, and trainer/energy tables + optional `enrich_neo_image_urls.py` for neo `image_url`). Missing catalog → Japanese-only labels still work; missing image fields → card-back for unscanned printings. Missing EN aliases for a Trainer still allow Auto-detect when the Japanese name is typed.
|
||||
|
||||
Card-back fallback uses the Japanese TCG Bulbagarden scan
|
||||
(`TCG_Card_Back_Japanese.jpg`), not the Western `Cardback.jpg`.
|
||||
|
||||
## Runtime Flow In CCM3
|
||||
|
||||
The app uses the same flow for every game that registers a module:
|
||||
@@ -85,21 +289,21 @@ The app uses the same flow for every game that registers a module:
|
||||
- `SetService` asks the game's `ISetSource` (info API) for the latest set list.
|
||||
- `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL.
|
||||
- `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer.
|
||||
- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`).
|
||||
- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon / Japanese Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`).
|
||||
|
||||
### Caching And Connection Reuse
|
||||
|
||||
See [caching.md](caching.md) for a dedicated reference on preview cache tiers, internal keys, eviction, clearing, and HTTP session reuse.
|
||||
|
||||
Three mechanisms reduce preview latency for **all** games (Magic, Pokemon, Yu-Gi-Oh!). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews:
|
||||
Three mechanisms reduce preview latency for **all** games (Magic, Pokemon West/Asia backends, Yu-Gi-Oh!, DigiBattle99). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews:
|
||||
|
||||
- **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`, and `ms.yugipedia.com` 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.
|
||||
|
||||
The combined effect on the preview path: first selection of a previously-unseen card pays one TLS handshake per *new* host this session (typically two hops for Yu-Gi-Oh!: `yugipedia.com` for the API, `ms.yugipedia.com` for the image), each subsequent fresh card on the same host skips the handshake, any re-selection of an already-viewed card is instant, after the first run with the disk cache populated **even a fresh app launch is decode-only for previously-seen cards** until eviction or a manual cache clear, and **records the upstream cleanly has no image for** stay "instant card-back" across restarts instead of re-paying the lookup every launch. Editing a lookup-relevant field of a record (name, set, setNo, or for Yu-Gi-Oh! the rarity / edition packed into setNo) changes the cache key automatically, so a fresh resolution attempt happens on the next click.
|
||||
The combined effect on the preview path: first selection of a previously-unseen card pays one TLS handshake per *new* host this session (typically two hops for Yu-Gi-Oh!: `yugipedia.com` for the API, `ms.yugipedia.com` for the image; Digi-Battle often hits `images.digimoncard.io` only when `setNo` is already known), each subsequent fresh card on the same host skips the handshake, any re-selection of an already-viewed card is instant, after the first run with the disk cache populated **even a fresh app launch is decode-only for previously-seen cards** until eviction or a manual cache clear, and **records the upstream cleanly has no image for** stay "instant card-back" across restarts instead of re-paying the lookup every launch. Editing a lookup-relevant field of a record (name, set, setNo, or for Yu-Gi-Oh! the rarity / edition packed into setNo) changes the cache key automatically, so a fresh resolution attempt happens on the next click.
|
||||
|
||||
To clear the persistent cache (for example to recover from a bad upstream image), delete the `<exeDir>/.cache/preview-cache/` subdirectory or the umbrella `<exeDir>/.cache/` folder. Note: the in-app "Reset" / data-storage-relocation flow does **not** touch this directory — the cache is install-scoped, not collection-scoped, so it is preserved across data-dir moves and only cleared by deleting the directory above explicitly (or by reinstalling / relocating the executable).
|
||||
|
||||
@@ -109,7 +313,9 @@ Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match C
|
||||
|
||||
- Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg`
|
||||
- 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.
|
||||
- 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."
|
||||
|
||||
@@ -120,6 +326,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
|
||||
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
|
||||
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
|
||||
|
||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon West (`GET /v2/cards/{setId}-{number}` → `data` object, or search `data[]`; `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||
|
||||
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
+1
-1
@@ -68,7 +68,7 @@ There is no explicit "refresh" or "invalidate" API on `CardPreviewService` — b
|
||||
|
||||
### 1. Edit-driven invalidation (record changed → fresh lookup, automatic)
|
||||
|
||||
The cache key for the preview path is `(game, name, setId, setNo)`. For Yu-Gi-Oh! the third slot also encodes rarity and edition, packed by `YuGiOhSelectedCardPanel::previewKey()` as `<setNo>||<rarity>||<1E|UE>`. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means:
|
||||
The cache key for the preview path is `(game, name, setId, setNo)`. For Yu-Gi-Oh! the third slot also encodes rarity and edition, packed by `YuGiOhSelectedCardPanel::previewKey()` as `<setNo>||<rarity>||<1E|UE>`. For Digimon Digi-Battle the middle slot is the pack **display name** (`Set.name`), not the slug id, so `pack=` search and the CDN path stay aligned. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means:
|
||||
|
||||
- Memory and disk lookups for the new key **miss** the old entry (positive or negative).
|
||||
- A fresh `ICardPreviewSource::fetchImageUrl` call runs.
|
||||
|
||||
@@ -113,7 +113,7 @@ Automated tests primarily cover `core/` and infrastructure adapters. UI testing
|
||||
|
||||
`cpr` builds as shared, so `build/bin` contains runtime DLLs (for example `libcpr.dll`, `libcurl.dll`, `libzlib.dll`) next to `ccm3.exe`.
|
||||
|
||||
For MinGW/MSYS2 builds, UCRT runtime DLLs must be available (typically via MSYS2 UCRT64 `bin` on `PATH`).
|
||||
For MinGW/MSYS2 builds, the `ccm` POST_BUILD step also copies `libstdc++-6.dll`, `libgcc_s_seh-1.dll`, and `libwinpthread-1.dll` from the compiler’s `bin/` next to `ccm3.exe`. That keeps Explorer / IDE launches on the same UCRT runtime used to build (avoids “Entry Point Not Found” / `__emutls_v._ZSt11__once_call` against `libcpr.dll` when a different `libstdc++` is on `PATH`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+12
-5
@@ -11,7 +11,7 @@
|
||||
- `image_service_tests.cpp` — `ImageService` (uses inline `RecordingImageStore` fake).
|
||||
- `collection_service_tests.cpp` — `CollectionService<MagicCard>` (uses inline `InMemoryRepo` + `StubImageStore`).
|
||||
- `config_service_tests.cpp` — `ConfigService` against `InMemoryFileSystem`.
|
||||
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`.
|
||||
- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`. Set-repo cases also pin Pokemon `sets-west.json` / `sets-asia.json` paths and migrate-on-load from legacy `pokemon/sets.json` / `pokemonjp/sets.json`.
|
||||
- `local_image_store_tests.cpp` — `LocalImageStore` against `InMemoryFileSystem` + `ConfigService`: `copyIn` (extension preserved, missing source errors), `remove` (existing file deleted; absent path is a no-op), `resolvePath` layout under `dataStorage/<game>/images/`.
|
||||
- `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`.
|
||||
- `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake.
|
||||
@@ -19,11 +19,18 @@
|
||||
- `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly.
|
||||
- `local_preview_byte_cache_tests.cpp` — `LocalPreviewByteCache` adapter against `StdFileSystem` (real disk under a unique `temp_directory_path()/ccm_preview_cache_test_*` per case, RAII `TempDir` cleanup; see also `std_file_system_tests.cpp`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too.
|
||||
- `std_file_system_tests.cpp` — `StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
|
||||
- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
|
||||
- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
|
||||
- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
|
||||
- `pokemon_west_set_id_tests.cpp` — `canonicalizeWestSetId` identity + legacy pokemontcg → TCGdex EN mappings (`sv1`→`sv01`, `pgo`→`swsh10.5`, …) and unknown passthrough.
|
||||
- `pokemon_collection_set_sync_tests.cpp` — `syncPokemonCollectionSets` West id migration + name/date refresh; Asia metadata-only refresh.
|
||||
- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseListResponse` (TCGdex EN `/v2/en/sets` top-level array) + `parseReleaseDate` / `parseCatalogPackFromSetDetail`. Drives `fetchAll` / `fetchAllWithCatalog` via routing HTTP fakes and asserts EN endpoints.
|
||||
- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` / `buildCardByIdUrl` (canonicalization + `localId` filters), `parseSearchResponse` / `parseCardByIdResponse` (`image` + `/high.png`), and `fetchImageUrl` / auto-detect via `FixedHttpClient`.
|
||||
- `digibattle99_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`.
|
||||
- `digibattle99_set_completion_tests.cpp` — `computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`.
|
||||
- `yugioh_set_completion_tests.cpp` — `computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`.
|
||||
- `pokemon_set_completion_tests.cpp` — `computePokemonSetCompletion` / `pokemonChecklistForSet` West/Asia ownership isolation + region/language filters + `PokemonSetCatalogService` dual-path FS round-trip.
|
||||
- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`.
|
||||
- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. Also `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist from `cardinfo.php`.
|
||||
- `yugioh_set_lookup_tests.cpp` — `lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
|
||||
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
|
||||
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh / DigiBattle99) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
|
||||
- `yugioh_card_preview_source_tests.cpp` — `YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005` → `LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
|
||||
- `card_sorter_tests.cpp` — `sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key.
|
||||
- `card_filter_tests.cpp` — `matchesMagicFilter` / `matchesPokemonFilter` / `matchesYuGiOhFilter` row-matcher behavior. Pin-down tests for `applyFilter`-equivalent semantics: case-insensitive substring match across `tableFields` valueKeys (name, set.name, language, condition, amount-as-string, note; Pokemon adds `setNo`; Yu-Gi-Oh adds `setNo` + `rarity`), boolean flag columns intentionally excluded, empty filter matches everything. Update this file whenever you add a new searchable column.
|
||||
|
||||
@@ -19,8 +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
|
||||
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
|
||||
icard_preview_source_tests.cpp
|
||||
yugioh_set_source_tests.cpp
|
||||
yugioh_set_lookup_tests.cpp
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
@@ -185,6 +187,15 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") {
|
||||
TEST_CASE("empty filter matches everything") {
|
||||
CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), ""));
|
||||
}
|
||||
|
||||
TEST_CASE("region is searchable") {
|
||||
PokemonCard c = pc("Charizard", "Base Set");
|
||||
c.region = PokemonRegion::Asia;
|
||||
CHECK(matchesPokemonFilter(c, "asia"));
|
||||
CHECK_FALSE(matchesPokemonFilter(c, "west"));
|
||||
c.region = PokemonRegion::West;
|
||||
CHECK(matchesPokemonFilter(c, "west"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
|
||||
@@ -238,3 +249,77 @@ TEST_SUITE("CardFilter::matchesYuGiOhFilter") {
|
||||
CHECK_FALSE(matchesYuGiOhFilter(c, "zzznomatch"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardFilter::matchesDigiBattle99Filter") {
|
||||
TEST_CASE("matches by name and set.name") {
|
||||
DigiBattle99Card c;
|
||||
c.name = "Agumon";
|
||||
c.set.name = "Series 1 Starter Set";
|
||||
CHECK(matchesDigiBattle99Filter(c, "agu"));
|
||||
CHECK(matchesDigiBattle99Filter(c, "STARTER"));
|
||||
CHECK_FALSE(matchesDigiBattle99Filter(c, "greymon"));
|
||||
}
|
||||
|
||||
TEST_CASE("includes setNo in searchable columns") {
|
||||
DigiBattle99Card c;
|
||||
c.name = "Agumon";
|
||||
c.set.name = "Series 1 Starter Set";
|
||||
c.setNo = "ST-01";
|
||||
CHECK(matchesDigiBattle99Filter(c, "st-01"));
|
||||
CHECK(matchesDigiBattle99Filter(c, "ST-"));
|
||||
}
|
||||
|
||||
TEST_CASE("empty filter matches everything") {
|
||||
DigiBattle99Card c;
|
||||
c.name = "Agumon";
|
||||
CHECK(matchesDigiBattle99Filter(c, ""));
|
||||
}
|
||||
|
||||
TEST_CASE("boolean flag columns are not matched") {
|
||||
DigiBattle99Card c;
|
||||
c.name = "Agumon";
|
||||
c.holo = true;
|
||||
c.firstEdition = true;
|
||||
c.signed_ = true;
|
||||
c.altered = true;
|
||||
CHECK_FALSE(matchesDigiBattle99Filter(c, "true"));
|
||||
CHECK(matchesDigiBattle99Filter(c, "agu"));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") {
|
||||
TEST_CASE("matches by name and set.name") {
|
||||
JapanesePokemonCard c;
|
||||
c.name = "Charmander";
|
||||
c.set.name = "Expansion Pack";
|
||||
CHECK(matchesJapanesePokemonFilter(c, "char"));
|
||||
CHECK(matchesJapanesePokemonFilter(c, "EXPANSION"));
|
||||
CHECK_FALSE(matchesJapanesePokemonFilter(c, "pikachu"));
|
||||
}
|
||||
|
||||
TEST_CASE("includes setNo in searchable columns") {
|
||||
JapanesePokemonCard c;
|
||||
c.name = "Charmander";
|
||||
c.set.name = "Expansion Pack";
|
||||
c.setNo = "001";
|
||||
CHECK(matchesJapanesePokemonFilter(c, "001"));
|
||||
CHECK(matchesJapanesePokemonFilter(c, "00"));
|
||||
}
|
||||
|
||||
TEST_CASE("empty filter matches everything") {
|
||||
JapanesePokemonCard c;
|
||||
c.name = "Charmander";
|
||||
CHECK(matchesJapanesePokemonFilter(c, ""));
|
||||
}
|
||||
|
||||
TEST_CASE("boolean flag columns are not matched") {
|
||||
JapanesePokemonCard c;
|
||||
c.name = "Charmander";
|
||||
c.holo = true;
|
||||
c.firstEdition = true;
|
||||
c.signed_ = true;
|
||||
c.altered = true;
|
||||
CHECK_FALSE(matchesJapanesePokemonFilter(c, "true"));
|
||||
CHECK(matchesJapanesePokemonFilter(c, "char"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
#include "ccm/ports/IPreviewByteCache.hpp"
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
@@ -130,6 +132,50 @@ public:
|
||||
void storeNegative(std::string_view) override {}
|
||||
};
|
||||
|
||||
class MemoryFileSystem final : public IFileSystem {
|
||||
public:
|
||||
std::unordered_map<std::string, std::string> files;
|
||||
|
||||
[[nodiscard]] bool exists(const std::filesystem::path& p) const override {
|
||||
return files.contains(p.generic_string());
|
||||
}
|
||||
[[nodiscard]] bool isDirectory(const std::filesystem::path&) const override {
|
||||
return false;
|
||||
}
|
||||
Result<void> ensureDirectory(const std::filesystem::path&) override {
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<std::string> readText(const std::filesystem::path& p) override {
|
||||
auto it = files.find(p.generic_string());
|
||||
if (it == files.end()) {
|
||||
return Result<std::string>::err("Unable to open " + p.generic_string());
|
||||
}
|
||||
return Result<std::string>::ok(it->second);
|
||||
}
|
||||
Result<void> writeText(const std::filesystem::path& p, std::string_view contents) override {
|
||||
files[p.generic_string()] = std::string(contents);
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> copyFile(const std::filesystem::path& from,
|
||||
const std::filesystem::path& to,
|
||||
bool) override {
|
||||
auto it = files.find(from.generic_string());
|
||||
if (it == files.end()) {
|
||||
return Result<void>::err("missing source");
|
||||
}
|
||||
files[to.generic_string()] = it->second;
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<void> remove(const std::filesystem::path& p) override {
|
||||
files.erase(p.generic_string());
|
||||
return Result<void>::ok();
|
||||
}
|
||||
Result<std::vector<std::filesystem::path>> listDirectory(
|
||||
const std::filesystem::path&) override {
|
||||
return Result<std::vector<std::filesystem::path>>::ok({});
|
||||
}
|
||||
};
|
||||
|
||||
// Minimal IGameModule fake that exposes a configurable preview source.
|
||||
class FakeGameModule final : public IGameModule {
|
||||
public:
|
||||
@@ -232,6 +278,26 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") {
|
||||
CHECK(out.isErr());
|
||||
CHECK(out.error() == "net down");
|
||||
}
|
||||
|
||||
TEST_CASE("asset: preview loads bytes from configured asset root") {
|
||||
FakeSource source;
|
||||
source.url = "asset:pokemon_jp_classic/TamamushiCG/016.jpg";
|
||||
FakeGameModule module;
|
||||
module.gameId = Game::JapanesePokemon;
|
||||
module.preview = &source;
|
||||
|
||||
FixedHttpClient http;
|
||||
MemoryFileSystem fs;
|
||||
fs.files["assets/pokemon_jp_classic/TamamushiCG/016.jpg"] = "JPEG-bytes";
|
||||
|
||||
CardPreviewService svc{http, nullptr, &fs, "assets"};
|
||||
svc.registerModule(module);
|
||||
|
||||
const auto out = svc.fetchPreviewBytes(Game::JapanesePokemon, "Erika", "TamamushiCG", "016");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "JPEG-bytes");
|
||||
CHECK(http.calls == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardPreviewService caching") {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
@@ -94,6 +96,54 @@ YuGiOhCard yc(std::uint32_t id, std::string name,
|
||||
return c;
|
||||
}
|
||||
|
||||
DigiBattle99Card db(std::uint32_t id, std::string name,
|
||||
std::string setName, std::string releaseDate,
|
||||
std::uint8_t amount = 1,
|
||||
bool holo = false, bool firstEdition = false,
|
||||
bool sgnd = false, bool altered = false,
|
||||
Language lang = Language::English,
|
||||
Condition cond = Condition::NearMint,
|
||||
std::string note = "") {
|
||||
DigiBattle99Card c;
|
||||
c.id = id;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.set.releaseDate = std::move(releaseDate);
|
||||
c.amount = amount;
|
||||
c.holo = holo;
|
||||
c.firstEdition = firstEdition;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
c.language = lang;
|
||||
c.condition = cond;
|
||||
c.note = std::move(note);
|
||||
return c;
|
||||
}
|
||||
|
||||
JapanesePokemonCard jp(std::uint32_t id, std::string name,
|
||||
std::string setName, std::string releaseDate,
|
||||
std::uint8_t amount = 1,
|
||||
bool holo = false, bool firstEdition = false,
|
||||
bool sgnd = false, bool altered = false,
|
||||
Language lang = Language::Japanese,
|
||||
Condition cond = Condition::NearMint,
|
||||
std::string note = "") {
|
||||
JapanesePokemonCard c;
|
||||
c.id = id;
|
||||
c.name = std::move(name);
|
||||
c.set.name = std::move(setName);
|
||||
c.set.releaseDate = std::move(releaseDate);
|
||||
c.amount = amount;
|
||||
c.holo = holo;
|
||||
c.firstEdition = firstEdition;
|
||||
c.signed_ = sgnd;
|
||||
c.altered = altered;
|
||||
c.language = lang;
|
||||
c.condition = cond;
|
||||
c.note = std::move(note);
|
||||
return c;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<MagicCard>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
@@ -115,6 +165,20 @@ std::vector<std::uint32_t> ids(const std::vector<YuGiOhCard>& v) {
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<DigiBattle99Card>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
for (const auto& c : v) out.push_back(c.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<JapanesePokemonCard>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
for (const auto& c : v) out.push_back(c.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CardSorter - Magic columns") {
|
||||
@@ -451,3 +515,67 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardSorter - DigiBattle99 columns") {
|
||||
TEST_CASE("Holo and FirstEdition sort false before true") {
|
||||
std::vector<DigiBattle99Card> v = {
|
||||
db(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false),
|
||||
db(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true),
|
||||
db(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false),
|
||||
};
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::Holo, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::FirstEdition, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Set column sorts by release date") {
|
||||
std::vector<DigiBattle99Card> v = {
|
||||
db(1, "x", "Late", "2001/01/01"),
|
||||
db(2, "y", "Early", "1999/06/01"),
|
||||
};
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::SetReleaseDate, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
|
||||
TEST_CASE("Name sorts case-insensitively") {
|
||||
std::vector<DigiBattle99Card> v = {
|
||||
db(1, "greymon", "X", "2000/01/01"),
|
||||
db(2, "Agumon", "X", "2000/01/01"),
|
||||
};
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::Name, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardSorter - JapanesePokemon columns") {
|
||||
TEST_CASE("Holo and FirstEdition sort false before true") {
|
||||
std::vector<JapanesePokemonCard> v = {
|
||||
jp(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false),
|
||||
jp(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true),
|
||||
jp(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false),
|
||||
};
|
||||
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Holo, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::FirstEdition, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Set column sorts by release date") {
|
||||
std::vector<JapanesePokemonCard> v = {
|
||||
jp(1, "x", "Late", "2023/03/10"),
|
||||
jp(2, "y", "Early", "1996/10/20"),
|
||||
};
|
||||
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::SetReleaseDate, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
|
||||
TEST_CASE("Name sorts case-insensitively") {
|
||||
std::vector<JapanesePokemonCard> v = {
|
||||
jp(1, "charmander", "X", "1996/10/20"),
|
||||
jp(2, "Bulbasaur", "X", "1996/10/20"),
|
||||
};
|
||||
sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Name, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::normalizeCardNumber") {
|
||||
TEST_CASE("uppercases alphabetic prefix without zero-padding") {
|
||||
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber("bo-88") == "BO-88");
|
||||
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber("st-01") == "ST-01");
|
||||
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber(" MO-06 ") == "MO-06");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::buildImageUrl") {
|
||||
TEST_CASE("builds CDN jpeg URL from card id") {
|
||||
CHECK(DigiBattle99CardPreviewSource::buildImageUrl("ST-01") ==
|
||||
"https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
CHECK(DigiBattle99CardPreviewSource::buildImageUrl("bo-115") ==
|
||||
"https://images.digimoncard.io/images/cards/BO-115.jpg");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::buildSearchUrl") {
|
||||
TEST_CASE("percent-encodes name pack and series") {
|
||||
const auto url = DigiBattle99CardPreviewSource::buildSearchUrl(
|
||||
"Agumon", "Series 1 Starter Set", "");
|
||||
CHECK(url.find("https://digimoncard.io/api-public/search.php?series=") == 0);
|
||||
CHECK(url.find("Digimon%20Digi-Battle%20Card%20Game") != std::string::npos);
|
||||
CHECK(url.find("&n=Agumon") != std::string::npos);
|
||||
CHECK(url.find("&pack=Series%201%20Starter%20Set") != std::string::npos);
|
||||
CHECK(url.find("&card=") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("includes card= when setNo is present") {
|
||||
const auto url = DigiBattle99CardPreviewSource::buildSearchUrl(
|
||||
"Agumon", "Series 1 Starter Set", "st-01");
|
||||
CHECK(url.find("&card=ST-01") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("empty pack omits pack clause") {
|
||||
const auto url =
|
||||
DigiBattle99CardPreviewSource::buildSearchUrl("Agumon", "", "ST-01");
|
||||
CHECK(url.find("&pack=") == std::string::npos);
|
||||
CHECK(url.find("&card=ST-01") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::parseImageUrlFromSearch") {
|
||||
TEST_CASE("returns CDN URL for first exact name match") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]}
|
||||
])";
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(json, "Agumon");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("empty array is NotFound") {
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch("[]", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("API error object is NotFound") {
|
||||
const auto out = DigiBattle99CardPreviewSource::parseImageUrlFromSearch(
|
||||
R"({"error":"No cards found for this search."})", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("non-array is Transient") {
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(R"({"meta":{}})", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("malformed JSON is Transient") {
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch("{not json", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::fetchImageUrl") {
|
||||
TEST_CASE("setNo present skips HTTP and returns CDN URL") {
|
||||
FixedHttpClient http;
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "st-01");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
CHECK(http.lastUrl.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("empty setNo searches and parses") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"([{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}])";
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
CHECK(http.lastUrl.find("search.php") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("n=Agumon") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("HTTP failure is Transient") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::parsePrintVariants") {
|
||||
TEST_CASE("collects distinct card ids for name+pack") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]},
|
||||
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
const auto out = DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
json, "Series 1 Starter Set", "Agumon");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "ST-01");
|
||||
}
|
||||
|
||||
TEST_CASE("pack miss with exact name returns error") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]}
|
||||
])";
|
||||
const auto out = DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
json, "Series 1 Starter Set", "Agumon");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::detectPrintVariants") {
|
||||
TEST_CASE("round-trips through FixedHttpClient") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"ST-126","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.detectPrintVariants("Agumon", "Series 1 Starter Set");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].setNo == "ST-01");
|
||||
CHECK(out.value()[1].setNo == "ST-126");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCompletion.hpp"
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
namespace {
|
||||
|
||||
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
|
||||
Configuration c;
|
||||
c.dataStorage = dataDir;
|
||||
c.defaultGame = Game::Magic;
|
||||
fs.writeText("/app/config.json", nlohmann::json(c).dump());
|
||||
ConfigService cfg{fs, "/app/config.json", dataDir};
|
||||
cfg.initialize();
|
||||
return cfg;
|
||||
}
|
||||
|
||||
DigiBattle99Card makeOwned(std::string setId, std::string setName, std::string setNo) {
|
||||
DigiBattle99Card c;
|
||||
c.id = 1;
|
||||
c.name = "Owned";
|
||||
c.set.id = std::move(setId);
|
||||
c.set.name = std::move(setName);
|
||||
c.setNo = std::move(setNo);
|
||||
return c;
|
||||
}
|
||||
|
||||
DigiBattle99SetCatalog sampleCatalog() {
|
||||
DigiBattle99SetCatalog catalog;
|
||||
DigiBattle99SetCatalogPack starter;
|
||||
starter.setId = "series-1-starter-set";
|
||||
starter.setName = "Series 1 Starter Set";
|
||||
starter.cards = {
|
||||
{"ST-01", "Agumon"},
|
||||
{"ST-02", "Greymon"},
|
||||
{"ST-03", "Gabumon"},
|
||||
};
|
||||
DigiBattle99SetCatalogPack booster;
|
||||
booster.setId = "series-1-booster-pack";
|
||||
booster.setName = "Series 1 Booster Pack";
|
||||
booster.cards = {
|
||||
{"ST-01", "Agumon"},
|
||||
{"BO-01", "MetalGreymon"},
|
||||
};
|
||||
catalog.packs.push_back(std::move(booster));
|
||||
catalog.packs.push_back(std::move(starter));
|
||||
return catalog;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("computeDigiBattle99SetCompletion") {
|
||||
TEST_CASE("only packs with owned cards appear") {
|
||||
const auto catalog = sampleCatalog();
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
|
||||
REQUIRE(rows.size() == 1);
|
||||
CHECK(rows[0].setId == "series-1-starter-set");
|
||||
CHECK(rows[0].ownedUnique == 1);
|
||||
CHECK(rows[0].total == 3);
|
||||
CHECK(rows[0].percent() == 33);
|
||||
}
|
||||
|
||||
TEST_CASE("unique setNo within a pack; amount does not inflate") {
|
||||
const auto catalog = sampleCatalog();
|
||||
DigiBattle99Card a = makeOwned("series-1-starter-set", "Series 1 Starter Set", "st-01");
|
||||
a.amount = 4;
|
||||
DigiBattle99Card b = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
|
||||
b.id = 2;
|
||||
DigiBattle99Card c = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
|
||||
c.id = 3;
|
||||
const auto rows =
|
||||
computeDigiBattle99SetCompletion({a, b, c}, catalog);
|
||||
REQUIRE(rows.size() == 1);
|
||||
CHECK(rows[0].ownedUnique == 2);
|
||||
CHECK(rows[0].total == 3);
|
||||
CHECK(rows[0].percent() == 66);
|
||||
}
|
||||
|
||||
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
|
||||
const auto catalog = sampleCatalog();
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
|
||||
REQUIRE(rows.size() == 1);
|
||||
CHECK(rows[0].setId == "series-1-starter-set");
|
||||
}
|
||||
|
||||
TEST_CASE("empty catalog yields no rows") {
|
||||
DigiBattle99SetCatalog empty;
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
CHECK(computeDigiBattle99SetCompletion(collection, empty).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("owned set missing from catalog is skipped") {
|
||||
DigiBattle99SetCatalog catalog;
|
||||
DigiBattle99SetCatalogPack onlyBooster;
|
||||
onlyBooster.setId = "series-1-booster-pack";
|
||||
onlyBooster.setName = "Series 1 Booster Pack";
|
||||
onlyBooster.cards = {{"BO-01", "MetalGreymon"}};
|
||||
catalog.packs.push_back(std::move(onlyBooster));
|
||||
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("language filter hides packs with no cards in that language") {
|
||||
const auto catalog = sampleCatalog();
|
||||
DigiBattle99Card en =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
|
||||
en.language = Language::English;
|
||||
|
||||
const auto allRows = computeDigiBattle99SetCompletion({en}, catalog);
|
||||
REQUIRE(allRows.size() == 1);
|
||||
|
||||
const auto deRows =
|
||||
computeDigiBattle99SetCompletion({en}, catalog, Language::German);
|
||||
CHECK(deRows.empty());
|
||||
|
||||
const auto enRows =
|
||||
computeDigiBattle99SetCompletion({en}, catalog, Language::English);
|
||||
REQUIRE(enRows.size() == 1);
|
||||
CHECK(enRows[0].ownedUnique == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("same setNo in two languages counts once aggregated; filter is exclusive") {
|
||||
const auto catalog = sampleCatalog();
|
||||
DigiBattle99Card en =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
|
||||
en.language = Language::English;
|
||||
DigiBattle99Card de =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
|
||||
de.id = 2;
|
||||
de.language = Language::German;
|
||||
|
||||
const auto allRows = computeDigiBattle99SetCompletion({en, de}, catalog);
|
||||
REQUIRE(allRows.size() == 1);
|
||||
CHECK(allRows[0].ownedUnique == 1);
|
||||
|
||||
const auto enRows =
|
||||
computeDigiBattle99SetCompletion({en, de}, catalog, Language::English);
|
||||
REQUIRE(enRows.size() == 1);
|
||||
CHECK(enRows[0].ownedUnique == 1);
|
||||
|
||||
DigiBattle99Card deOnly =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
|
||||
deOnly.id = 3;
|
||||
deOnly.language = Language::German;
|
||||
const auto deRows = computeDigiBattle99SetCompletion({en, de, deOnly}, catalog,
|
||||
Language::German);
|
||||
REQUIRE(deRows.size() == 1);
|
||||
CHECK(deRows[0].ownedUnique == 2);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("digiBattle99ChecklistForSet") {
|
||||
TEST_CASE("greys missing cards and marks owned ones") {
|
||||
const auto catalog = sampleCatalog();
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"),
|
||||
};
|
||||
const auto list =
|
||||
digiBattle99ChecklistForSet(collection, catalog, "series-1-starter-set");
|
||||
REQUIRE(list.size() == 3);
|
||||
CHECK(list[0].setNo == "ST-01");
|
||||
CHECK(list[0].owned == false);
|
||||
CHECK(list[1].setNo == "ST-02");
|
||||
CHECK(list[1].owned == true);
|
||||
CHECK(list[2].setNo == "ST-03");
|
||||
CHECK(list[2].owned == false);
|
||||
}
|
||||
|
||||
TEST_CASE("unknown set returns empty") {
|
||||
const auto catalog = sampleCatalog();
|
||||
CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("owned flags respect language filter") {
|
||||
const auto catalog = sampleCatalog();
|
||||
DigiBattle99Card en =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
|
||||
en.language = Language::English;
|
||||
|
||||
const auto filtered =
|
||||
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
|
||||
Language::German);
|
||||
REQUIRE(filtered.size() == 3);
|
||||
CHECK(filtered[0].owned == false);
|
||||
CHECK(filtered[1].owned == false);
|
||||
CHECK(filtered[2].owned == false);
|
||||
|
||||
const auto english =
|
||||
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
|
||||
Language::English);
|
||||
REQUIRE(english.size() == 3);
|
||||
CHECK(english[1].owned == true);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("digiBattle99LanguagesInCollection") {
|
||||
TEST_CASE("empty collection yields empty") {
|
||||
CHECK(digiBattle99LanguagesInCollection({}).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("returns distinct languages in allLanguages order") {
|
||||
DigiBattle99Card jp =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
|
||||
jp.language = Language::Japanese;
|
||||
DigiBattle99Card en =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
|
||||
en.id = 2;
|
||||
en.language = Language::English;
|
||||
DigiBattle99Card enDup =
|
||||
makeOwned("series-1-booster-pack", "Series 1 Booster Pack", "BO-01");
|
||||
enDup.id = 3;
|
||||
enDup.language = Language::English;
|
||||
DigiBattle99Card de =
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-03");
|
||||
de.id = 4;
|
||||
de.language = Language::German;
|
||||
|
||||
const auto langs = digiBattle99LanguagesInCollection({jp, en, enDup, de});
|
||||
REQUIRE(langs.size() == 3);
|
||||
CHECK(langs[0] == Language::English);
|
||||
CHECK(langs[1] == Language::German);
|
||||
CHECK(langs[2] == Language::Japanese);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetCatalogService") {
|
||||
TEST_CASE("save then load round-trips") {
|
||||
InMemoryFileSystem fs;
|
||||
auto config = makeConfig(fs, "/data");
|
||||
DigiBattle99SetCatalogService store{fs, config, [](Game) { return "digibattle99"; }};
|
||||
|
||||
CHECK_FALSE(store.exists());
|
||||
CHECK(store.load().isErr());
|
||||
|
||||
const auto catalog = sampleCatalog();
|
||||
REQUIRE(store.save(catalog).isOk());
|
||||
CHECK(store.exists());
|
||||
|
||||
const auto loaded = store.load();
|
||||
REQUIRE(loaded.isOk());
|
||||
CHECK(loaded.value() == catalog);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::slugifyPackName") {
|
||||
TEST_CASE("slugifies pack display names") {
|
||||
CHECK(DigiBattle99SetSource::slugifyPackName("Series 1 Starter Set") ==
|
||||
"series-1-starter-set");
|
||||
CHECK(DigiBattle99SetSource::slugifyPackName("Digimon The Movie Promo Cards") ==
|
||||
"digimon-the-movie-promo-cards");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::parseResponse") {
|
||||
TEST_CASE("derives unique packs with curated release dates") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]},
|
||||
{"name":"Agumon","id":"ST-126","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Promo","id":"MO-06","set_name":["Digimon The Movie Promo Cards"]}
|
||||
])";
|
||||
|
||||
const auto out = DigiBattle99SetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 3);
|
||||
// Same curated date for Series 1 products → secondary sort by name:
|
||||
// "Booster" before "Starter".
|
||||
CHECK(out.value()[0].id == "series-1-booster-pack");
|
||||
CHECK(out.value()[0].name == "Series 1 Booster Pack");
|
||||
CHECK(out.value()[0].releaseDate == "1999/06/01");
|
||||
CHECK(out.value()[1].id == "series-1-starter-set");
|
||||
CHECK(out.value()[1].name == "Series 1 Starter Set");
|
||||
CHECK(out.value()[1].releaseDate == "1999/06/01");
|
||||
CHECK(out.value()[2].id == "digimon-the-movie-promo-cards");
|
||||
CHECK(out.value()[2].releaseDate == "2000/10/01");
|
||||
}
|
||||
|
||||
TEST_CASE("sorts by release date then name") {
|
||||
const std::string json = R"([
|
||||
{"name":"A","id":"ST-1","set_name":["Street Starter Set 2"]},
|
||||
{"name":"B","id":"ST-2","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"C","id":"ST-3","set_name":["Street Starter Set 1"]}
|
||||
])";
|
||||
const auto out = DigiBattle99SetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 3);
|
||||
CHECK(out.value()[0].name == "Series 1 Starter Set");
|
||||
CHECK(out.value()[1].name == "Street Starter Set 1");
|
||||
CHECK(out.value()[2].name == "Street Starter Set 2");
|
||||
}
|
||||
|
||||
TEST_CASE("empty array returns an empty list") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse("[]");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("non-array object with error is an error") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse(
|
||||
R"({"error":"No cards found for this search."})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("missing top-level array returns an error") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse(R"({"meta":{}})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON returns an error") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse("{not json");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::fetchAll") {
|
||||
TEST_CASE("network error is surfaced as a Result error") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
DigiBattle99SetSource src{http};
|
||||
CHECK(src.fetchAll().isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("network success hits the Digi-Battle search endpoint") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"([{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}])";
|
||||
DigiBattle99SetSource src{http};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "series-1-starter-set");
|
||||
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
|
||||
}
|
||||
|
||||
TEST_CASE("fetchAllWithCatalog returns sets and pack cards in one GET") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"([
|
||||
{"name":"Agumon","id":"st-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
|
||||
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
DigiBattle99SetSource src{http};
|
||||
const auto out = src.fetchAllWithCatalog();
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().sets.size() == 2);
|
||||
const auto* starter = out.value().catalog.findPack("series-1-starter-set");
|
||||
REQUIRE(starter != nullptr);
|
||||
REQUIRE(starter->cards.size() == 2);
|
||||
CHECK(starter->cards[0].setNo == "ST-01");
|
||||
CHECK(starter->cards[0].name == "Agumon");
|
||||
const auto* booster = out.value().catalog.findPack("series-1-booster-pack");
|
||||
REQUIRE(booster != nullptr);
|
||||
REQUIRE(booster->cards.size() == 1);
|
||||
CHECK(booster->cards[0].setNo == "ST-01");
|
||||
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::parseCatalog") {
|
||||
TEST_CASE("lists a card under every pack in set_name") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
|
||||
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]}
|
||||
])";
|
||||
const auto out = DigiBattle99SetSource::parseCatalog(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().packs.size() == 2);
|
||||
|
||||
const auto* booster = out.value().findPack("series-1-booster-pack");
|
||||
REQUIRE(booster != nullptr);
|
||||
REQUIRE(booster->cards.size() == 2);
|
||||
CHECK(booster->cards[0].setNo == "BO-01");
|
||||
CHECK(booster->cards[1].setNo == "ST-01");
|
||||
|
||||
const auto* starter = out.value().findPack("series-1-starter-set");
|
||||
REQUIRE(starter != nullptr);
|
||||
REQUIRE(starter->cards.size() == 1);
|
||||
CHECK(starter->cards[0].setNo == "ST-01");
|
||||
}
|
||||
|
||||
TEST_CASE("dedupes the same setNo within one pack") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon Alt","id":"ST-01","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
const auto out = DigiBattle99SetSource::parseCatalog(json);
|
||||
REQUIRE(out.isOk());
|
||||
const auto* starter = out.value().findPack("series-1-starter-set");
|
||||
REQUIRE(starter != nullptr);
|
||||
REQUIRE(starter->cards.size() == 1);
|
||||
CHECK(starter->cards[0].name == "Agumon");
|
||||
}
|
||||
|
||||
TEST_CASE("empty array returns an empty catalog") {
|
||||
const auto out = DigiBattle99SetSource::parseCatalog("[]");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("error object is an error") {
|
||||
const auto out = DigiBattle99SetSource::parseCatalog(
|
||||
R"({"error":"No cards found for this search."})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/PokemonSetCatalog.hpp"
|
||||
#include "ccm/domain/YuGiOhSetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
#include "ccm/domain/PokemonCard.hpp"
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
@@ -23,6 +28,12 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
|
||||
nlohmann::json jYgo = "YuGiOh";
|
||||
CHECK(jYgo.get<Game>() == Game::YuGiOh);
|
||||
|
||||
nlohmann::json jDigi = "DigiBattle99";
|
||||
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
|
||||
|
||||
nlohmann::json jJp = "JapanesePokemon";
|
||||
CHECK(jJp.get<Game>() == Game::JapanesePokemon);
|
||||
|
||||
nlohmann::json j3 = Theme::Dark;
|
||||
CHECK(j3.get<std::string>() == "Dark");
|
||||
CHECK(j3.get<Theme>() == Theme::Dark);
|
||||
@@ -33,11 +44,45 @@ TEST_SUITE("domain enums round-trip JSON as strings") {
|
||||
CHECK(l.get<std::string>() == "Japanese");
|
||||
CHECK(l.get<Language>() == Language::Japanese);
|
||||
|
||||
nlohmann::json k = Language::Korean;
|
||||
CHECK(k.get<std::string>() == "Korean");
|
||||
CHECK(k.get<Language>() == Language::Korean);
|
||||
|
||||
nlohmann::json sc = Language::SimplifiedChinese;
|
||||
CHECK(sc.get<std::string>() == "S-Chinese");
|
||||
CHECK(sc.get<Language>() == Language::SimplifiedChinese);
|
||||
|
||||
nlohmann::json tc = Language::TraditionalChinese;
|
||||
CHECK(tc.get<std::string>() == "T-Chinese");
|
||||
CHECK(tc.get<Language>() == Language::TraditionalChinese);
|
||||
|
||||
nlohmann::json legacyChinese = "Chinese";
|
||||
CHECK(legacyChinese.get<Language>() == Language::SimplifiedChinese);
|
||||
|
||||
nlohmann::json c = Condition::LightPlayed;
|
||||
CHECK(c.get<std::string>() == "LightPlayed");
|
||||
CHECK(c.get<Condition>() == Condition::LightPlayed);
|
||||
}
|
||||
|
||||
TEST_CASE("PokemonRegion") {
|
||||
nlohmann::json j = PokemonRegion::Asia;
|
||||
CHECK(j.get<std::string>() == "Asia");
|
||||
CHECK(j.get<PokemonRegion>() == PokemonRegion::Asia);
|
||||
|
||||
nlohmann::json w = "West";
|
||||
CHECK(w.get<PokemonRegion>() == PokemonRegion::West);
|
||||
}
|
||||
|
||||
TEST_CASE("allGames excludes JapanesePokemon but string mapping remains") {
|
||||
for (const auto game : allGames()) {
|
||||
CHECK(game != Game::JapanesePokemon);
|
||||
}
|
||||
CHECK(allGames().size() == 4);
|
||||
CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon);
|
||||
CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon);
|
||||
CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon);
|
||||
}
|
||||
|
||||
TEST_CASE("invalid enum string throws") {
|
||||
nlohmann::json bad = "Spanglish";
|
||||
CHECK_THROWS(bad.get<Language>());
|
||||
@@ -142,15 +187,201 @@ TEST_SUITE("PokemonCard JSON") {
|
||||
c.holo = true;
|
||||
c.signed_ = false;
|
||||
c.altered = false;
|
||||
c.region = PokemonRegion::West;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.at("setNo") == "4/102");
|
||||
CHECK(j.at("firstEdition") == true);
|
||||
CHECK(j.at("signed") == false);
|
||||
CHECK(j.at("region") == "West");
|
||||
|
||||
const PokemonCard back = j.get<PokemonCard>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
|
||||
TEST_CASE("region Asia round-trips and missing region defaults to West") {
|
||||
PokemonCard c;
|
||||
c.id = 1;
|
||||
c.amount = 1;
|
||||
c.name = "Charmander";
|
||||
c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"};
|
||||
c.setNo = "001";
|
||||
c.language = Language::Japanese;
|
||||
c.condition = Condition::NearMint;
|
||||
c.region = PokemonRegion::Asia;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.at("region") == "Asia");
|
||||
CHECK(j.get<PokemonCard>().region == PokemonRegion::Asia);
|
||||
|
||||
j.erase("region");
|
||||
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") {
|
||||
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
|
||||
DigiBattle99Card c;
|
||||
c.id = 3;
|
||||
c.amount = 2;
|
||||
c.name = "Agumon";
|
||||
c.set = Set{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"};
|
||||
c.setNo = "ST-01";
|
||||
c.note = "starter";
|
||||
c.images = {"a.png"};
|
||||
c.language = Language::English;
|
||||
c.condition = Condition::NearMint;
|
||||
c.firstEdition = false;
|
||||
c.holo = true;
|
||||
c.signed_ = true;
|
||||
c.altered = false;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.at("setNo") == "ST-01");
|
||||
CHECK(j.at("firstEdition") == false);
|
||||
CHECK(j.at("holo") == true);
|
||||
CHECK(j.at("signed") == true);
|
||||
|
||||
const DigiBattle99Card back = j.get<DigiBattle99Card>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetCatalog JSON") {
|
||||
TEST_CASE("round-trips packs and setNo alias") {
|
||||
DigiBattle99SetCatalog catalog;
|
||||
DigiBattle99SetCatalogPack pack;
|
||||
pack.setId = "series-1-starter-set";
|
||||
pack.setName = "Series 1 Starter Set";
|
||||
pack.cards.push_back(DigiBattle99CatalogCard{"ST-01", "Agumon"});
|
||||
pack.cards.push_back(DigiBattle99CatalogCard{"ST-126", "Agumon"});
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
|
||||
nlohmann::json j = catalog;
|
||||
CHECK(j.at("packs").is_array());
|
||||
CHECK(j.at("packs").at(0).at("id") == "series-1-starter-set");
|
||||
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "ST-01");
|
||||
|
||||
const DigiBattle99SetCatalog back = j.get<DigiBattle99SetCatalog>();
|
||||
CHECK(back == catalog);
|
||||
CHECK(back.findPack("series-1-starter-set") != nullptr);
|
||||
CHECK(back.findPack("missing") == nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("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"});
|
||||
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");
|
||||
|
||||
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
|
||||
CHECK(back == catalog);
|
||||
CHECK(back.findPack("LOB") != nullptr);
|
||||
CHECK(back.findPack("missing") == nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("PokemonSetCatalog JSON") {
|
||||
TEST_CASE("round-trips packs and setNo alias") {
|
||||
PokemonSetCatalog catalog;
|
||||
PokemonSetCatalogPack pack;
|
||||
pack.setId = "base1";
|
||||
pack.setName = "Base";
|
||||
pack.cards.push_back(PokemonCatalogCard{"4", "Charizard"});
|
||||
pack.cards.push_back(PokemonCatalogCard{"58", "Growlithe"});
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
|
||||
nlohmann::json j = catalog;
|
||||
CHECK(j.at("packs").is_array());
|
||||
CHECK(j.at("packs").at(0).at("id") == "base1");
|
||||
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "4");
|
||||
|
||||
const PokemonSetCatalog back = j.get<PokemonSetCatalog>();
|
||||
CHECK(back == catalog);
|
||||
CHECK(back.findPack("base1") != nullptr);
|
||||
CHECK(back.findPack("missing") == nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonCard JSON") {
|
||||
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
|
||||
JapanesePokemonCard c;
|
||||
c.id = 9;
|
||||
c.amount = 1;
|
||||
c.name = "Charmander";
|
||||
c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"};
|
||||
c.setNo = "001";
|
||||
c.note = "";
|
||||
c.images = {};
|
||||
c.language = Language::Japanese;
|
||||
c.condition = Condition::NearMint;
|
||||
c.firstEdition = true;
|
||||
c.holo = false;
|
||||
c.signed_ = false;
|
||||
c.altered = false;
|
||||
|
||||
nlohmann::json j = c;
|
||||
CHECK(j.at("setNo") == "001");
|
||||
CHECK(j.at("firstEdition") == true);
|
||||
CHECK(j.at("signed") == false);
|
||||
CHECK(j.at("language") == "Japanese");
|
||||
|
||||
const JapanesePokemonCard back = j.get<JapanesePokemonCard>();
|
||||
CHECK(back == c);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("Configuration JSON matches Rust serde aliases") {
|
||||
@@ -169,6 +400,16 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") {
|
||||
CHECK(back == cfg);
|
||||
}
|
||||
|
||||
TEST_CASE("legacy defaultGame JapanesePokemon coerces to Pokemon") {
|
||||
nlohmann::json j = {
|
||||
{"dataStorage", "/data"},
|
||||
{"defaultGame", "JapanesePokemon"},
|
||||
{"theme", "Light"},
|
||||
};
|
||||
const auto cfg = j.get<Configuration>();
|
||||
CHECK(cfg.defaultGame == Game::Pokemon);
|
||||
}
|
||||
|
||||
TEST_CASE("missing theme key defaults to Light") {
|
||||
const nlohmann::json j = {
|
||||
{"dataStorage", "/portable/data"},
|
||||
@@ -497,6 +738,66 @@ TEST_SUITE("Domain JSON required fields") {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("DigiBattle99Card missing each required key throws") {
|
||||
const nlohmann::json full = {
|
||||
{"id", 3},
|
||||
{"amount", 1},
|
||||
{"name", "Agumon"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "series-1-starter-set"},
|
||||
{"name", "Series 1 Starter Set"},
|
||||
{"releaseDate", "1999/06/01"},
|
||||
}},
|
||||
{"setNo", "ST-01"},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "English"},
|
||||
{"condition", "NearMint"},
|
||||
{"firstEdition", false},
|
||||
{"holo", true},
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
|
||||
for (const char* key :
|
||||
{"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition",
|
||||
"firstEdition", "holo", "signed", "altered"}) {
|
||||
nlohmann::json partial = full;
|
||||
partial.erase(key);
|
||||
CHECK_THROWS(partial.get<DigiBattle99Card>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("JapanesePokemonCard missing each required key throws") {
|
||||
const nlohmann::json full = {
|
||||
{"id", 9},
|
||||
{"amount", 1},
|
||||
{"name", "Charmander"},
|
||||
{"set", nlohmann::json{
|
||||
{"id", "PMCG1"},
|
||||
{"name", "Expansion Pack"},
|
||||
{"releaseDate", "1996/10/20"},
|
||||
}},
|
||||
{"setNo", "001"},
|
||||
{"note", ""},
|
||||
{"images", nlohmann::json::array()},
|
||||
{"language", "Japanese"},
|
||||
{"condition", "NearMint"},
|
||||
{"firstEdition", true},
|
||||
{"holo", false},
|
||||
{"signed", false},
|
||||
{"altered", false},
|
||||
};
|
||||
|
||||
for (const char* key :
|
||||
{"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition",
|
||||
"firstEdition", "holo", "signed", "altered"}) {
|
||||
nlohmann::json partial = full;
|
||||
partial.erase(key);
|
||||
CHECK_THROWS(partial.get<JapanesePokemonCard>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Configuration missing required key throws") {
|
||||
const nlohmann::json j = {
|
||||
{"defaultGame", "Magic"},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
@@ -51,4 +53,26 @@ TEST_SUITE("game modules expose stable identity and wiring") {
|
||||
CHECK(module.cardPreviewSource() != nullptr);
|
||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||
}
|
||||
|
||||
TEST_CASE("DigiBattle99 module reports canonical metadata") {
|
||||
NoopHttpClient http;
|
||||
DigiBattle99GameModule module(http);
|
||||
|
||||
CHECK(module.id() == Game::DigiBattle99);
|
||||
CHECK(module.dirName() == "digibattle99");
|
||||
CHECK(module.displayName() == "Digimon (Digi-Battle)");
|
||||
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);
|
||||
|
||||
CHECK(module.id() == Game::JapanesePokemon);
|
||||
CHECK(module.dirName() == "pokemon");
|
||||
CHECK(module.displayName() == "Pokemon (Japan)");
|
||||
CHECK(module.cardPreviewSource() != nullptr);
|
||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class RoutingHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::unordered_map<std::string, std::string> bodies;
|
||||
std::string lastUrl;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
if (!ok) return Result<std::string>::err("offline");
|
||||
const auto it = bodies.find(lastUrl);
|
||||
if (it == bodies.end()) return Result<std::string>::err("unknown url: " + lastUrl);
|
||||
return Result<std::string>::ok(it->second);
|
||||
}
|
||||
};
|
||||
|
||||
JapanesePokemonEnCatalog sampleCatalog() {
|
||||
auto c = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {
|
||||
"SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート"}
|
||||
},
|
||||
"prints": [
|
||||
{"set_id":"SV1a","local_id":"001","name_en":"Tropius","name_ja":"トロピウス","name_en_source":"bulbapedia"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(c.isOk());
|
||||
return std::move(c).value();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("JapanesePokemonCardPreviewSource helpers") {
|
||||
TEST_CASE("normalizeLocalId strips slash and whitespace") {
|
||||
CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId(" 001/102 ") == "001");
|
||||
CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId("4/102") == "4");
|
||||
}
|
||||
|
||||
TEST_CASE("imageUrlFromBase appends high.png") {
|
||||
CHECK(JapanesePokemonCardPreviewSource::imageUrlFromBase(
|
||||
"https://assets.tcgdex.net/ja/SV/SV1a/001") ==
|
||||
"https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
|
||||
}
|
||||
|
||||
TEST_CASE("buildCardUrl encodes set-local id") {
|
||||
CHECK(JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001") ==
|
||||
"https://api.tcgdex.net/v2/ja/cards/SV1a-001");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonCardPreviewSource::parseSetCards") {
|
||||
TEST_CASE("parses localId name and image") {
|
||||
const std::string json = R"({
|
||||
"id":"SV1a",
|
||||
"cards":[
|
||||
{"id":"SV1a-001","localId":"001","name":"トロピウス",
|
||||
"image":"https://assets.tcgdex.net/ja/SV/SV1a/001","rarity":"Common"}
|
||||
]
|
||||
})";
|
||||
const auto out = JapanesePokemonCardPreviewSource::parseSetCards(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].localId == "001");
|
||||
CHECK(out.value()[0].nameJa == "トロピウス");
|
||||
CHECK(out.value()[0].imageBase == "https://assets.tcgdex.net/ja/SV/SV1a/001");
|
||||
}
|
||||
|
||||
TEST_CASE("missing cards array is Transient") {
|
||||
const auto out = JapanesePokemonCardPreviewSource::parseSetCards(R"({"id":"X"})");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonCardPreviewSource::parseCardImageUrl") {
|
||||
TEST_CASE("returns high.png URL") {
|
||||
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl(
|
||||
R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
|
||||
}
|
||||
|
||||
TEST_CASE("null image is NotFound") {
|
||||
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl(
|
||||
R"({"id":"PMCG1-001","image":null})");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("malformed JSON is Transient") {
|
||||
const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl("{bad");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonCardPreviewSource::parsePrintVariants") {
|
||||
TEST_CASE("matches English catalog name") {
|
||||
const std::string body = R"({
|
||||
"id":"SV1a",
|
||||
"cards":[
|
||||
{"localId":"001","name":"トロピウス","rarity":"Common"},
|
||||
{"localId":"002","name":"other","rarity":"Common"}
|
||||
]
|
||||
})";
|
||||
const auto catalog = sampleCatalog();
|
||||
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
body, "SV1a", "Tropius", catalog);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "001");
|
||||
}
|
||||
|
||||
TEST_CASE("matches Japanese name without catalog") {
|
||||
const std::string body = R"({
|
||||
"id":"SV1a",
|
||||
"cards":[{"localId":"001","name":"トロピウス"}]
|
||||
})";
|
||||
JapanesePokemonEnCatalog empty;
|
||||
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
body, "SV1a", "トロピウス", empty);
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().setNo == "001");
|
||||
}
|
||||
|
||||
TEST_CASE("rejects stale catalog localId when name_ja disagrees with TCGdex") {
|
||||
// Historical seed bug: Charmander mapped to 001 (actually Bulbasaur).
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
const std::string body = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[
|
||||
{"localId":"001","name":"フシギダネ","rarity":"Common"},
|
||||
{"localId":"014","name":"ヒトカゲ","rarity":"Common"}
|
||||
]
|
||||
})";
|
||||
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
body, "PMCG1", "Charmander", catalog.value());
|
||||
REQUIRE(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("accepts corrected catalog localId for Charmander") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
const std::string body = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[
|
||||
{"localId":"001","name":"フシギダネ"},
|
||||
{"localId":"014","name":"ヒトカゲ","rarity":"Common"}
|
||||
]
|
||||
})";
|
||||
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
body, "PMCG1", "Charmander", catalog.value());
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "014");
|
||||
}
|
||||
|
||||
TEST_CASE("English Blastoise and Mewtwo resolve Expansion Pack localIds") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"032","name_en":"Blastoise","name_ja":"カメックス"},
|
||||
{"set_id":"PMCG1","local_id":"050","name_en":"Mewtwo","name_ja":"ミュウツー"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
const std::string body = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[
|
||||
{"localId":"032","name":"カメックス","rarity":"Holo Rare"},
|
||||
{"localId":"050","name":"ミュウツー","rarity":"Holo Rare"}
|
||||
]
|
||||
})";
|
||||
auto blast = JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
body, "PMCG1", "Blastoise", catalog.value());
|
||||
REQUIRE(blast.isOk());
|
||||
REQUIRE(blast.value().size() == 1);
|
||||
CHECK(blast.value()[0].setNo == "032");
|
||||
|
||||
auto mew = JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
body, "PMCG1", "Mewtwo", catalog.value());
|
||||
REQUIRE(mew.isOk());
|
||||
REQUIRE(mew.value().size() == 1);
|
||||
CHECK(mew.value()[0].setNo == "050");
|
||||
}
|
||||
|
||||
TEST_CASE("English Switch resolves Expansion Pack localId 073") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"073","name_en":"Switch","name_ja":"ポケモンいれかえ"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
const std::string body = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[
|
||||
{"localId":"071","name":"きずぐすり","rarity":"Common"},
|
||||
{"localId":"073","name":"ポケモンいれかえ","rarity":"Common"}
|
||||
]
|
||||
})";
|
||||
const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants(
|
||||
body, "PMCG1", "Switch", catalog.value());
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "073");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonCardPreviewSource::fetchImageUrl") {
|
||||
TEST_CASE("resolves via card endpoint when localId present") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] =
|
||||
R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})";
|
||||
auto catalog = sampleCatalog();
|
||||
JapanesePokemonCardPreviewSource src{http, catalog};
|
||||
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png");
|
||||
}
|
||||
|
||||
TEST_CASE("resolves via set detail when card has no image but set row does") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] =
|
||||
R"({"id":"SV1a-001","image":null})";
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("SV1a")] = R"({
|
||||
"id":"SV1a",
|
||||
"cards":[{"localId":"001","name":"トロピウス",
|
||||
"image":"https://assets.tcgdex.net/ja/SV/SV1a/001"}]
|
||||
})";
|
||||
auto catalog = sampleCatalog();
|
||||
JapanesePokemonCardPreviewSource src{http, catalog};
|
||||
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().find("/high.png") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("stale catalog does not bind English name to wrong localId image") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[
|
||||
{"localId":"001","name":"フシギダネ",
|
||||
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"},
|
||||
{"localId":"014","name":"ヒトカゲ",
|
||||
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"}
|
||||
]
|
||||
})";
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
// Empty setNo forces name match; stale catalog must not pick Bulbasaur's art.
|
||||
const auto out = src.fetchImageUrl("Charmander", "PMCG1", "");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("English name resolves correct localId image via catalog") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[
|
||||
{"localId":"001","name":"フシギダネ",
|
||||
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"},
|
||||
{"localId":"014","name":"ヒトカゲ",
|
||||
"image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"}
|
||||
]
|
||||
})";
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto out = src.fetchImageUrl("Charmander", "PMCG1", "");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://assets.tcgdex.net/ja/PMCG/PMCG1/014/high.png");
|
||||
}
|
||||
|
||||
TEST_CASE("null image on set-specific card is NotFound without other-printing fallback") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] =
|
||||
R"({"id":"PMCG1-021","name":"リザードン","image":null})";
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[{"localId":"021","name":"リザードン"}]
|
||||
})";
|
||||
JapanesePokemonEnCatalog empty;
|
||||
JapanesePokemonCardPreviewSource src{http, empty};
|
||||
const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("catalog tcgplayer_id gap-fills when TCGdex image is null") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] =
|
||||
R"({"id":"PMCG1-021","name":"リザードン","image":null})";
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({
|
||||
"id":"PMCG1",
|
||||
"cards":[{"localId":"021","name":"リザードン"}]
|
||||
})";
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"021","name_en":"Charizard",
|
||||
"name_ja":"リザードン","tcgplayer_id":"575604"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() ==
|
||||
"https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("HTTP failure is Transient") {
|
||||
RoutingHttpClient http;
|
||||
http.ok = false;
|
||||
JapanesePokemonEnCatalog empty;
|
||||
JapanesePokemonCardPreviewSource src{http, empty};
|
||||
const auto out = src.fetchImageUrl("Tropius", "SV1a", "001");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("catalog-only theme deck resolves preview from tcgplayer_id") {
|
||||
RoutingHttpClient http;
|
||||
// No TCGdex bodies: card + set detail both miss.
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
|
||||
"prints": [
|
||||
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym",
|
||||
"name_ja":"タマムシシティジム","tcgplayer_id":"12345"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto out = src.fetchImageUrl("Celadon City Gym", "TamamushiCG", "021");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() ==
|
||||
"https://product-images.tcgplayer.com/fit-in/437x437/12345.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("Tamamushi City Gym Erika uses catalog tcgplayer gap-fill") {
|
||||
RoutingHttpClient http;
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
|
||||
"prints": [
|
||||
{"set_id":"TamamushiCG","local_id":"016","name_en":"Erika",
|
||||
"name_ja":"エリカ","name_en_source":"trainer-table",
|
||||
"tcgplayer_id":"576776"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto out = src.fetchImageUrl("Erika", "TamamushiCG", "016");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() ==
|
||||
"https://product-images.tcgplayer.com/fit-in/437x437/576776.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("neo catalog image_url gap-fills when TCGdex image is null") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("neo4", "106")] =
|
||||
R"({"id":"neo4-106","name":"ラッキースタジアム","image":null})";
|
||||
http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("neo4")] = R"({
|
||||
"id":"neo4",
|
||||
"cards":[{"localId":"106","name":"ラッキースタジアム"}]
|
||||
})";
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"neo4","local_id":"106","name_en":"Shining Celebi",
|
||||
"name_ja":"輝くセレビ","name_en_source":"species-table-variant",
|
||||
"image_url":"https://images.pokemontcg.io/neo4/106_hires.png"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto out = src.fetchImageUrl("Shining Celebi", "neo4", "106");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.pokemontcg.io/neo4/106_hires.png");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only") {
|
||||
TEST_CASE("English trainer name resolves when TCGdex set detail is unavailable") {
|
||||
RoutingHttpClient http;
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}},
|
||||
"prints": [
|
||||
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym",
|
||||
"name_ja":"タマムシシティジム","name_en_source":"trainer-table",
|
||||
"image_url":"https://example.com/celadon.jpg"},
|
||||
{"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish",
|
||||
"name_ja":"エリカのナゾノクサ","name_en_source":"manual",
|
||||
"image_url":"https://example.com/oddish.jpg"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto out = src.detectPrintVariants("Celadon City Gym", "TamamushiCG");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "021");
|
||||
}
|
||||
|
||||
TEST_CASE("detectPrintVariantsFromCatalog includes UnnumberedPromo prints without image_url") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)",
|
||||
"name_ja":"ミュウツー","name_en_source":"manual"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)",
|
||||
"name_ja":"ミュウツー","name_en_source":"manual"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
|
||||
"name_ja":"ミュウツー","name_en_source":"manual",
|
||||
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)",
|
||||
"name_ja":"","name_en_source":"manual"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"UnnumberedPromo", "Mewtwo", catalog.value());
|
||||
REQUIRE(mew.isOk());
|
||||
REQUIRE(mew.value().size() == 4);
|
||||
// Imaged prints first, then empty-URL identity rows.
|
||||
CHECK(mew.value()[0].setNo == "030");
|
||||
CHECK(mew.value()[1].setNo == "007");
|
||||
CHECK(mew.value()[2].setNo == "008");
|
||||
CHECK(mew.value()[3].setNo == "045");
|
||||
|
||||
RoutingHttpClient http;
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto img = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030");
|
||||
REQUIRE(img.isOk());
|
||||
CHECK(img.value() ==
|
||||
"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("fetchImageUrl does not borrow sibling UnnumberedPromo image") {
|
||||
RoutingHttpClient http;
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)",
|
||||
"name_ja":"ミュウツー","name_en_source":"manual"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
|
||||
"name_ja":"ミュウツー","name_en_source":"manual",
|
||||
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonCardPreviewSource src{http, catalog.value()};
|
||||
const auto empty = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "007");
|
||||
REQUIRE(empty.isErr());
|
||||
CHECK(empty.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
|
||||
const auto whf = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030");
|
||||
REQUIRE(whf.isOk());
|
||||
CHECK(whf.value() ==
|
||||
"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("detectPrintVariantsFromCatalog dedupes shared preview URLs") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)",
|
||||
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"073","name_en":"Mewtwo (Song Best Collection promo)",
|
||||
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"197","name_en":"Mewtwo (Wizards Promo 12)",
|
||||
"image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"UnnumberedPromo", "Mewtwo", catalog.value());
|
||||
REQUIRE(mew.isOk());
|
||||
REQUIRE(mew.value().size() == 1);
|
||||
CHECK(mew.value()[0].setNo == "030");
|
||||
}
|
||||
|
||||
TEST_CASE("detectPrintVariantsFromCatalog matches owner Pokemon English title") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish",
|
||||
"name_ja":"エリカのナゾノクサ",
|
||||
"image_url":"https://example.com/oddish.jpg"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto out = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"TamamushiCG", "Erika's Oddish", catalog.value());
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "001");
|
||||
}
|
||||
|
||||
TEST_CASE("detectPrintVariantsFromCatalog matches Dark Rocket and Owner PMCG titles") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG4","local_id":"017","name_en":"Dark Charizard",
|
||||
"name_ja":"わるいリザードン","name_en_source":"species-table-variant",
|
||||
"tcgplayer_id":"575744"},
|
||||
{"set_id":"PMCG6","local_id":"042","name_en":"Rocket's Zapdos",
|
||||
"name_ja":"R団のサンダー","name_en_source":"species-table-variant",
|
||||
"tcgplayer_id":"1"},
|
||||
{"set_id":"PMCG5","local_id":"002","name_en":"Erika's Oddish",
|
||||
"name_ja":"エリカのナゾノクサ","name_en_source":"species-table-variant",
|
||||
"tcgplayer_id":"2"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto dark = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"PMCG4", "Dark Charizard", catalog.value());
|
||||
REQUIRE(dark.isOk());
|
||||
REQUIRE(dark.value().size() == 1);
|
||||
CHECK(dark.value()[0].setNo == "017");
|
||||
|
||||
const auto rocket = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"PMCG6", "Rocket's Zapdos", catalog.value());
|
||||
REQUIRE(rocket.isOk());
|
||||
REQUIRE(rocket.value().size() == 1);
|
||||
CHECK(rocket.value()[0].setNo == "042");
|
||||
|
||||
const auto owner = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"PMCG5", "Erika's Oddish", catalog.value());
|
||||
REQUIRE(owner.isOk());
|
||||
REQUIRE(owner.value().size() == 1);
|
||||
CHECK(owner.value()[0].setNo == "002");
|
||||
}
|
||||
|
||||
TEST_CASE("detectPrintVariantsFromCatalog matches Light and Shining neo titles") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"neo4","local_id":"004","name_en":"Light Sunflora",
|
||||
"name_ja":"軽いサンフロラ","name_en_source":"species-table-variant",
|
||||
"image_url":"https://example.com/sunflora.png"},
|
||||
{"set_id":"neo4","local_id":"013","name_en":"Shining Celebi",
|
||||
"name_ja":"輝くセレビ","name_en_source":"species-table-variant",
|
||||
"image_url":"https://example.com/celebi.png"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto light = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"neo4", "Light Sunflora", catalog.value());
|
||||
REQUIRE(light.isOk());
|
||||
REQUIRE(light.value().size() == 1);
|
||||
CHECK(light.value()[0].setNo == "004");
|
||||
|
||||
const auto shining = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
|
||||
"neo4", "Shining Celebi", catalog.value());
|
||||
REQUIRE(shining.isOk());
|
||||
REQUIRE(shining.value().size() == 1);
|
||||
CHECK(shining.value()[0].setNo == "013");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
TEST_SUITE("JapanesePokemonEnCatalog") {
|
||||
TEST_CASE("parses sets and prints") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {
|
||||
"PMCG1": {
|
||||
"name_en": "Expansion Pack",
|
||||
"name_ja": "拡張パック",
|
||||
"releaseDate": "1996/10/20"
|
||||
}
|
||||
},
|
||||
"prints": [
|
||||
{
|
||||
"set_id": "PMCG1",
|
||||
"local_id": "001",
|
||||
"name_en": "Charmander",
|
||||
"name_ja": "ヒトカゲ",
|
||||
"name_en_source": "bulbapedia"
|
||||
}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
CHECK_FALSE(catalog.value().empty());
|
||||
|
||||
auto set = catalog.value().findSet("PMCG1");
|
||||
REQUIRE(set.has_value());
|
||||
CHECK(set->nameEn == "Expansion Pack");
|
||||
CHECK(set->releaseDate == "1996/10/20");
|
||||
|
||||
auto print = catalog.value().findPrint("PMCG1", "001");
|
||||
REQUIRE(print.has_value());
|
||||
CHECK(print->nameEn == "Charmander");
|
||||
CHECK(print->nameEnSource == "bulbapedia");
|
||||
}
|
||||
|
||||
TEST_CASE("parses optional tcgplayer_id and image_url") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{
|
||||
"set_id": "PMCG1",
|
||||
"local_id": "021",
|
||||
"name_en": "Charizard",
|
||||
"name_ja": "リザードン",
|
||||
"tcgplayer_id": 575604
|
||||
},
|
||||
{
|
||||
"set_id": "SV1a",
|
||||
"local_id": "001",
|
||||
"name_en": "Tropius",
|
||||
"image_url": "https://example.com/tropius.png"
|
||||
}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
auto charizard = catalog.value().findPrint("PMCG1", "021");
|
||||
REQUIRE(charizard.has_value());
|
||||
CHECK(charizard->tcgplayerId == "575604");
|
||||
CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*charizard) ==
|
||||
"https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg");
|
||||
|
||||
auto tropius = catalog.value().findPrint("SV1a", "001");
|
||||
REQUIRE(tropius.has_value());
|
||||
CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*tropius) ==
|
||||
"https://example.com/tropius.png");
|
||||
}
|
||||
|
||||
TEST_CASE("findPrintsByName is case-insensitive on English") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto hits = catalog.value().findPrintsByName("PMCG1", "charmander");
|
||||
REQUIRE(hits.size() == 1);
|
||||
CHECK(hits[0].localId == "001");
|
||||
}
|
||||
|
||||
TEST_CASE("findPrintsByName matches qualified English titles by bare prefix") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu (CoroCoro promo)"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto hits = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo");
|
||||
REQUIRE(hits.size() == 3);
|
||||
CHECK(hits[0].localId == "007");
|
||||
CHECK(hits[1].localId == "008");
|
||||
CHECK(hits[2].localId == "045");
|
||||
// Exact full title still works.
|
||||
const auto exact = catalog.value().findPrintsByName(
|
||||
"UnnumberedPromo", "Mewtwo Strikes Back (Jumbo)");
|
||||
REQUIRE(exact.size() == 1);
|
||||
CHECK(exact[0].localId == "045");
|
||||
}
|
||||
|
||||
TEST_CASE("findPrintsByName whole-token matches owner and GR titles") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"json({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"UnnumberedPromo","local_id":"227","name_en":"Team GR's Mewtwo (Pokémon Card GB2 promo)"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (CoroCoro promo) (Jumbo)"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"016","name_en":"Mew (CoroCoro promo)"}
|
||||
]
|
||||
})json");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto mewtwo = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo");
|
||||
REQUIRE(mewtwo.size() == 2);
|
||||
CHECK(mewtwo[0].localId == "227");
|
||||
CHECK(mewtwo[1].localId == "045");
|
||||
|
||||
// "Mew" must not match "Mewtwo …" rows.
|
||||
const auto mew = catalog.value().findPrintsByName("UnnumberedPromo", "Mew");
|
||||
REQUIRE(mew.size() == 1);
|
||||
CHECK(mew[0].localId == "016");
|
||||
}
|
||||
|
||||
TEST_CASE("hasPrintsForSet reports curated classic products") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
CHECK(catalog.value().hasPrintsForSet("TamamushiCG"));
|
||||
CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1"));
|
||||
}
|
||||
|
||||
TEST_CASE("printsForSet returns all prints for a set id") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"A","local_id":"1","name_en":"One"},
|
||||
{"set_id":"A","local_id":"2","name_en":"Two"},
|
||||
{"set_id":"B","local_id":"1","name_en":"Other"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
const auto prints = catalog.value().printsForSet("A");
|
||||
REQUIRE(prints.size() == 2);
|
||||
CHECK(catalog.value().printsForSet("missing").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("missing set/print returns nullopt") {
|
||||
JapanesePokemonEnCatalog empty;
|
||||
CHECK_FALSE(empty.findSet("X").has_value());
|
||||
CHECK_FALSE(empty.findPrint("X", "1").has_value());
|
||||
CHECK(empty.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("malformed JSON is an error") {
|
||||
CHECK(JapanesePokemonEnCatalog::parse("{not json").isErr());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class RoutingHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::unordered_map<std::string, std::string> bodies;
|
||||
std::string lastUrl;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
if (!ok) return Result<std::string>::err("offline");
|
||||
const auto it = bodies.find(lastUrl);
|
||||
if (it == bodies.end()) return Result<std::string>::err("unknown url");
|
||||
return Result<std::string>::ok(it->second);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("JapanesePokemonSetSource helpers") {
|
||||
TEST_CASE("excludes CS* set ids") {
|
||||
CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS1a"));
|
||||
CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS4a"));
|
||||
CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("PMCG1"));
|
||||
CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("SV1a"));
|
||||
}
|
||||
|
||||
TEST_CASE("applies SV4a name override") {
|
||||
CHECK(JapanesePokemonSetSource::applySetNameOverride("SV4a", "wrong") ==
|
||||
"シャイニートレジャーex");
|
||||
CHECK(JapanesePokemonSetSource::applySetNameOverride("PMCG1", "拡張パック") ==
|
||||
"拡張パック");
|
||||
}
|
||||
|
||||
TEST_CASE("rewrites release date separators") {
|
||||
CHECK(JapanesePokemonSetSource::rewriteReleaseDate("1996-10-20") == "1996/10/20");
|
||||
}
|
||||
|
||||
TEST_CASE("buildSetDetailUrl percent-encodes id") {
|
||||
CHECK(JapanesePokemonSetSource::buildSetDetailUrl("SV1a") ==
|
||||
"https://api.tcgdex.net/v2/ja/sets/SV1a");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonSetSource::parseListResponse") {
|
||||
TEST_CASE("maps id/name and drops CS* entries") {
|
||||
const std::string json = R"([
|
||||
{"id":"PMCG1","name":"拡張パック","cardCount":{"total":102,"official":102}},
|
||||
{"id":"CS1a","name":"トリプレットビート","cardCount":{"total":1,"official":1}},
|
||||
{"id":"SV4a","name":"レイジングサーフ","cardCount":{"total":320,"official":190}}
|
||||
])";
|
||||
const auto out = JapanesePokemonSetSource::parseListResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
// 2 from TCGdex + 11 curated products omitted by TCGdex.
|
||||
REQUIRE(out.value().size() == 13);
|
||||
CHECK(out.value()[0].id == "PMCG1");
|
||||
CHECK(out.value()[0].name == "拡張パック");
|
||||
CHECK(out.value()[1].id == "SV4a");
|
||||
CHECK(out.value()[1].name == "シャイニートレジャーex");
|
||||
}
|
||||
|
||||
TEST_CASE("injects classic City Gym and Expansion Sheet products") {
|
||||
const auto out = JapanesePokemonSetSource::parseListResponse("[]");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 11);
|
||||
const auto hasId = [&](const char* id) {
|
||||
return std::any_of(out.value().begin(), out.value().end(),
|
||||
[&](const Set& s) { return s.id == id; });
|
||||
};
|
||||
CHECK(hasId("UnnumberedPromo"));
|
||||
CHECK(hasId("TamamushiCG"));
|
||||
CHECK(hasId("NiviCG"));
|
||||
CHECK(hasId("HanadaCG"));
|
||||
CHECK(hasId("KuchibaCG"));
|
||||
CHECK(hasId("YamabukiCG"));
|
||||
CHECK(hasId("GurenTG"));
|
||||
CHECK(hasId("ExpSheet1"));
|
||||
CHECK(hasId("ExpSheet2"));
|
||||
CHECK(hasId("ExpSheet3"));
|
||||
CHECK(hasId("SouthernIslands"));
|
||||
const Set* unnumbered = nullptr;
|
||||
const Set* tama = nullptr;
|
||||
for (const auto& s : out.value()) {
|
||||
if (s.id == "UnnumberedPromo") unnumbered = &s;
|
||||
if (s.id == "TamamushiCG") tama = &s;
|
||||
}
|
||||
REQUIRE(unnumbered != nullptr);
|
||||
CHECK(unnumbered->name == "Unnumbered Promotional cards");
|
||||
CHECK(unnumbered->releaseDate == "1997/03/06");
|
||||
REQUIRE(tama != nullptr);
|
||||
CHECK(tama->name == "Tamamushi City Gym");
|
||||
CHECK(tama->releaseDate == "1998/07/25");
|
||||
}
|
||||
|
||||
TEST_CASE("does not duplicate classic products already in the list") {
|
||||
const std::string json = R"([
|
||||
{"id":"TamamushiCG","name":"already-present"}
|
||||
])";
|
||||
const auto out = JapanesePokemonSetSource::parseListResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
int tamaCount = 0;
|
||||
for (const auto& s : out.value()) {
|
||||
if (s.id == "TamamushiCG") ++tamaCount;
|
||||
}
|
||||
CHECK(tamaCount == 1);
|
||||
// Curated EN name / release date overwrite a stale upstream label.
|
||||
CHECK(out.value().front().name == "Tamamushi City Gym");
|
||||
CHECK(out.value().front().releaseDate == "1998/07/25");
|
||||
}
|
||||
|
||||
TEST_CASE("empty array still injects classic products") {
|
||||
const auto out = JapanesePokemonSetSource::parseListResponse("[]");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK_FALSE(out.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("non-array is an error") {
|
||||
CHECK(JapanesePokemonSetSource::parseListResponse(R"({"data":[]})").isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON is an error") {
|
||||
CHECK(JapanesePokemonSetSource::parseListResponse("{not json").isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonSetSource::parseReleaseDate") {
|
||||
TEST_CASE("extracts and rewrites releaseDate") {
|
||||
const auto out = JapanesePokemonSetSource::parseReleaseDate(
|
||||
R"({"id":"PMCG1","releaseDate":"1996-10-20"})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "1996/10/20");
|
||||
}
|
||||
|
||||
TEST_CASE("missing releaseDate yields empty string") {
|
||||
const auto out = JapanesePokemonSetSource::parseReleaseDate(R"({"id":"X"})");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonSetSource::fetchAll") {
|
||||
TEST_CASE("enriches from catalog and sorts by release date") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonSetSource::kListEndpoint] = R"([
|
||||
{"id":"SV1a","name":"トリプレットビート"},
|
||||
{"id":"PMCG1","name":"拡張パック"},
|
||||
{"id":"PMCG2","name":"ポケモンジャングル"},
|
||||
{"id":"CS1a","name":"junk"}
|
||||
])";
|
||||
// Catalog supplies dates so detail GETs are skipped.
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {
|
||||
"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック","releaseDate":"1996/10/20"},
|
||||
"PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"},
|
||||
"SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート","releaseDate":"2023/03/10"}
|
||||
},
|
||||
"prints": []
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonSetSource src{http, catalog.value()};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
// CS* dropped; 3 TCGdex + 11 curated injections.
|
||||
REQUIRE(out.value().size() == 14);
|
||||
// Expansion Pack → Jungle → UnnumberedPromo (day after Jungle).
|
||||
CHECK(out.value()[0].id == "PMCG1");
|
||||
CHECK(out.value()[1].id == "PMCG2");
|
||||
CHECK(out.value()[1].name == "Pokémon Jungle");
|
||||
CHECK(out.value()[2].id == "UnnumberedPromo");
|
||||
CHECK(out.value()[2].name == "Unnumbered Promotional cards");
|
||||
CHECK(out.value()[2].releaseDate == "1997/03/06");
|
||||
const Set* pmcg1 = nullptr;
|
||||
bool foundSv = false;
|
||||
bool foundTama = false;
|
||||
for (const auto& s : out.value()) {
|
||||
if (s.id == "PMCG1") {
|
||||
pmcg1 = &s;
|
||||
CHECK(s.name == "Expansion Pack");
|
||||
CHECK(s.releaseDate == "1996/10/20");
|
||||
}
|
||||
if (s.id == "SV1a") {
|
||||
foundSv = true;
|
||||
CHECK(s.name == "Triplet Beat");
|
||||
}
|
||||
if (s.id == "TamamushiCG") {
|
||||
foundTama = true;
|
||||
CHECK(s.name == "Tamamushi City Gym");
|
||||
}
|
||||
}
|
||||
REQUIRE(pmcg1 != nullptr);
|
||||
CHECK(foundSv);
|
||||
CHECK(foundTama);
|
||||
}
|
||||
|
||||
TEST_CASE("fetches set detail when catalog lacks release date") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonSetSource::kListEndpoint] =
|
||||
R"([{"id":"PMCG1","name":"拡張パック"}])";
|
||||
http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG1")] =
|
||||
R"({"id":"PMCG1","releaseDate":"1996-10-20","cards":[]})";
|
||||
JapanesePokemonEnCatalog empty;
|
||||
JapanesePokemonSetSource src{http, empty};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 12); // PMCG1 + 11 curated
|
||||
const Set* pmcg1 = nullptr;
|
||||
for (const auto& s : out.value()) {
|
||||
if (s.id == "PMCG1") {
|
||||
pmcg1 = &s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(pmcg1 != nullptr);
|
||||
CHECK(pmcg1->releaseDate == "1996/10/20");
|
||||
// Without catalog EN, never keep Japanese TCGdex names in Set.name.
|
||||
CHECK(pmcg1->name == "PMCG1");
|
||||
}
|
||||
|
||||
TEST_CASE("CJK set names fall back to set id even without catalog") {
|
||||
RoutingHttpClient http;
|
||||
http.bodies[JapanesePokemonSetSource::kListEndpoint] =
|
||||
R"([{"id":"PMCG3","name":"化石の秘密"}])";
|
||||
http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG3")] =
|
||||
R"({"id":"PMCG3","releaseDate":"1997-06-21"})";
|
||||
JapanesePokemonEnCatalog empty;
|
||||
JapanesePokemonSetSource src{http, empty};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
const Set* pmcg3 = nullptr;
|
||||
for (const auto& s : out.value()) {
|
||||
if (s.id == "PMCG3") {
|
||||
pmcg3 = &s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(pmcg3 != nullptr);
|
||||
CHECK(pmcg3->name == "PMCG3");
|
||||
}
|
||||
|
||||
TEST_CASE("network error on list is surfaced") {
|
||||
RoutingHttpClient http;
|
||||
http.ok = false;
|
||||
JapanesePokemonEnCatalog empty;
|
||||
JapanesePokemonSetSource src{http, empty};
|
||||
CHECK(src.fetchAll().isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("augmentCachedSets injects classic products into a cached list") {
|
||||
RoutingHttpClient http;
|
||||
JapanesePokemonEnCatalog empty;
|
||||
JapanesePokemonSetSource src{http, empty};
|
||||
std::vector<Set> cached;
|
||||
Set pmcg2;
|
||||
pmcg2.id = "PMCG2";
|
||||
pmcg2.name = "Pokémon Jungle";
|
||||
pmcg2.releaseDate = "1997/03/05";
|
||||
cached.push_back(std::move(pmcg2));
|
||||
src.augmentCachedSets(cached);
|
||||
REQUIRE(cached.size() == 12);
|
||||
// Jungle stays first; UnnumberedPromo (1997/03/06) is immediately after.
|
||||
CHECK(cached[0].id == "PMCG2");
|
||||
CHECK(cached[1].id == "UnnumberedPromo");
|
||||
CHECK(cached[1].releaseDate == "1997/03/06");
|
||||
bool foundTama = false;
|
||||
bool foundUnnumbered = false;
|
||||
for (const auto& s : cached) {
|
||||
if (s.id == "TamamushiCG") {
|
||||
foundTama = true;
|
||||
CHECK(s.name == "Tamamushi City Gym");
|
||||
}
|
||||
if (s.id == "UnnumberedPromo") {
|
||||
foundUnnumbered = true;
|
||||
CHECK(s.name == "Unnumbered Promotional cards");
|
||||
}
|
||||
}
|
||||
CHECK(foundTama);
|
||||
CHECK(foundUnnumbered);
|
||||
}
|
||||
|
||||
TEST_CASE("augmentCachedSets restores English names from catalog") {
|
||||
RoutingHttpClient http;
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {
|
||||
"PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"}
|
||||
},
|
||||
"prints": []
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
JapanesePokemonSetSource src{http, catalog.value()};
|
||||
std::vector<Set> cached;
|
||||
Set pmcg2;
|
||||
pmcg2.id = "PMCG2";
|
||||
pmcg2.name = "PMCG2"; // stale cache stored the id as the display name
|
||||
pmcg2.releaseDate = "1997/03/05";
|
||||
cached.push_back(std::move(pmcg2));
|
||||
src.augmentCachedSets(cached);
|
||||
const Set* jungle = nullptr;
|
||||
for (const auto& s : cached) {
|
||||
if (s.id == "PMCG2") {
|
||||
jungle = &s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(jungle != nullptr);
|
||||
CHECK(jungle->name == "Pokémon Jungle");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonSetSource::parseCatalogPackFromSetDetail") {
|
||||
TEST_CASE("builds checklist from cards[] and prefers EN catalog names") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック"}},
|
||||
"prints": [
|
||||
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"},
|
||||
{"set_id":"PMCG1","local_id":"099","name_en":"Catalog Only","name_ja":""}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
Set set;
|
||||
set.id = "PMCG1";
|
||||
set.name = "Expansion Pack";
|
||||
const std::string detail = R"({
|
||||
"id":"PMCG1",
|
||||
"name":"拡張パック",
|
||||
"cards":[
|
||||
{"localId":"001","name":"ヒトカゲ"},
|
||||
{"localId":"002","name":"リザード"}
|
||||
]
|
||||
})";
|
||||
const auto pack = JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
|
||||
detail, set, catalog.value());
|
||||
REQUIRE(pack.isOk());
|
||||
REQUIRE(pack.value().cards.size() == 3);
|
||||
CHECK(pack.value().cards[0].setNo == "001");
|
||||
CHECK(pack.value().cards[0].name == "Charmander");
|
||||
CHECK(pack.value().cards[1].setNo == "002");
|
||||
CHECK(pack.value().cards[1].name == "リザード");
|
||||
CHECK(pack.value().cards[2].setNo == "099");
|
||||
CHECK(pack.value().cards[2].name == "Catalog Only");
|
||||
}
|
||||
|
||||
TEST_CASE("catalogPackFromEnCatalog covers classic-only products") {
|
||||
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
|
||||
"sets": {},
|
||||
"prints": [
|
||||
{"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu"},
|
||||
{"set_id":"UnnumberedPromo","local_id":"002","name_en":"Mewtwo"}
|
||||
]
|
||||
})");
|
||||
REQUIRE(catalog.isOk());
|
||||
Set set;
|
||||
set.id = "UnnumberedPromo";
|
||||
set.name = "Unnumbered Promotional cards";
|
||||
const auto pack =
|
||||
JapanesePokemonSetSource::catalogPackFromEnCatalog(set, catalog.value());
|
||||
REQUIRE(pack.cards.size() == 2);
|
||||
CHECK(pack.cards[0].setNo == "001");
|
||||
CHECK(pack.cards[1].setNo == "002");
|
||||
}
|
||||
}
|
||||
@@ -143,13 +143,57 @@ TEST_SUITE("JsonSetRepository") {
|
||||
CHECK(writeFail.error() == "write failed");
|
||||
}
|
||||
|
||||
TEST_CASE("paths are composed from dataStorage and game dir") {
|
||||
TEST_CASE("paths use region-specific filenames for Pokemon West and Asia") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
|
||||
const std::vector<Set> west = {{"base1", "Base Set", "1999/01/09"}};
|
||||
const std::vector<Set> asia = {{"SV1a", "Triplet Beat", "2023/01/20"}};
|
||||
|
||||
REQUIRE(repo.save(Game::Pokemon, sets).isOk());
|
||||
CHECK(fs.files().count("/data/pokemon/sets.json") == 1);
|
||||
REQUIRE(repo.save(Game::Pokemon, west).isOk());
|
||||
REQUIRE(repo.save(Game::JapanesePokemon, asia).isOk());
|
||||
CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1);
|
||||
CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1);
|
||||
CHECK(fs.files().count("/data/pokemon/sets.json") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("load migrates legacy pokemon/sets.json to sets-west.json") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
const std::vector<Set> sets = {{"base1", "Base Set", "1999/01/09"}};
|
||||
REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(sets).dump(2)).isOk());
|
||||
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
const auto loaded = repo.load(Game::Pokemon);
|
||||
REQUIRE(loaded.isOk());
|
||||
CHECK(loaded.value() == sets);
|
||||
CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("load migrates legacy pokemonjp/sets.json to sets-asia.json") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
const std::vector<Set> sets = {{"SV1a", "Triplet Beat", "2023/01/20"}};
|
||||
REQUIRE(fs.writeText("/data/pokemonjp/sets.json", nlohmann::json(sets).dump(2)).isOk());
|
||||
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
const auto loaded = repo.load(Game::JapanesePokemon);
|
||||
REQUIRE(loaded.isOk());
|
||||
CHECK(loaded.value() == sets);
|
||||
CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("load prefers new path over legacy when both exist") {
|
||||
InMemoryFileSystem fs;
|
||||
auto cfg = makeConfig(fs, "/data");
|
||||
const std::vector<Set> legacy = {{"old", "Old", "1999/01/01"}};
|
||||
const std::vector<Set> neu = {{"new", "New", "2024/01/01"}};
|
||||
REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(legacy).dump(2)).isOk());
|
||||
REQUIRE(fs.writeText("/data/pokemon/sets-west.json", nlohmann::json(neu).dump(2)).isOk());
|
||||
|
||||
JsonSetRepository repo{fs, cfg, dirNameFn};
|
||||
const auto loaded = repo.load(Game::Pokemon);
|
||||
REQUIRE(loaded.isOk());
|
||||
CHECK(loaded.value() == neu);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user