diff --git a/.github/workflows/feature-ci.yml b/.github/workflows/feature-ci.yml index 60f7ed0..57d054d 100644 --- a/.github/workflows/feature-ci.yml +++ b/.github/workflows/feature-ci.yml @@ -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 }} diff --git a/.github/workflows/master-ci.yml b/.github/workflows/master-ci.yml index 5c70a70..647bc59 100644 --- a/.github/workflows/master-ci.yml +++ b/.github/workflows/master-ci.yml @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index 377e2b6..9386bc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `/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 `/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 `/.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.pokemontcg.io`, `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 diff --git a/README.md b/README.md index 992790d..8344da1 100644 --- a/README.md +++ b/README.md @@ -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 +
+Magic The Gathering ![CCM3 Demo - Magic: The Gathering](docs/assets/images/demo-mtg.png) -### Pokemon TCG +
+ +
+Pokemon TCG ![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png) -### Yu-Gi-Oh! +
+ +
+Yu-Gi-Oh! ![CCM3 Demo - YuGiOh](docs/assets/images/demo-ygo.png) +
+ +
+Digimon (Digi-Battle) + +![CCM3 Demo - Digimon Digi-Battle](docs/assets/images/demo-digibattle99.png) + +
+ ## Migrating From CCM1 And CCM2 diff --git a/app/AGENTS.md b/app/AGENTS.md index 3b3a523..4062cb8 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -5,11 +5,11 @@ 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 `$/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 `$/assets/` and copies `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` there so Yu-Gi-Oh! / Digi-Battle preview fallbacks 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`, `JsonCollectionRepository`, `JsonCollectionRepository`, `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`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `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`). @@ -21,7 +21,7 @@ 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`** 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`. - After adding a new game module you **must**: (1) add a `unique_ptr<GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(Mod_.get())`, (4) call `previewSvc_->registerModule(*Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<Card>` + `CollectionService<Card>` if the game has a custom card type, (7) construct a `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. diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 5e7382d..1f2e059 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -19,9 +19,13 @@ 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 (used when network card-back +# URLs fail or no public URL exists). add_custom_command(TARGET ccm POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets" COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png" - "$/assets/ygo_card_back.png") + "$/assets/ygo_card_back.png" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png" + "$/assets/digibattle99_card_back.png") diff --git a/app/main.cpp b/app/main.cpp index 2ba87b1..03d1815 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -2,9 +2,11 @@ // 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/yugioh/YuGiOhGameModule.hpp" @@ -20,6 +22,7 @@ #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 +45,10 @@ 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"; } return "magic"; } @@ -80,6 +84,7 @@ public: magicMod_ = std::make_unique(*http_); pokeMod_ = std::make_unique(*http_); ygoMod_ = std::make_unique(*http_); + digiBattle99Mod_ = std::make_unique(*http_); magicRepo_ = std::make_unique>( *fs_, *config_, &dirNameForGame); @@ -87,6 +92,9 @@ public: *fs_, *config_, &dirNameForGame); ygoRepo_ = std::make_unique>( *fs_, *config_, &dirNameForGame); + digiBattle99Repo_ = + std::make_unique>( + *fs_, *config_, &dirNameForGame); setRepo_ = std::make_unique(*fs_, *config_, &dirNameForGame); imgStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); @@ -97,10 +105,14 @@ public: *pokeRepo_, *imgStore_); ygoCollSvc_ = std::make_unique>( *ygoRepo_, *imgStore_); + digiBattle99CollSvc_ = + std::make_unique>( + *digiBattle99Repo_, *imgStore_); setSvc_ = std::make_unique(*setRepo_); setSvc_->registerModule(magicMod_.get()); setSvc_->registerModule(pokeMod_.get()); setSvc_->registerModule(ygoMod_.get()); + setSvc_->registerModule(digiBattle99Mod_.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 @@ -120,6 +132,7 @@ public: previewSvc_->registerModule(*magicMod_); previewSvc_->registerModule(*pokeMod_); previewSvc_->registerModule(*ygoMod_); + previewSvc_->registerModule(*digiBattle99Mod_); // Per-game UI bundles. Order here is the order shown in the Game menu. magicView_ = std::make_unique( @@ -128,6 +141,9 @@ public: *config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_); ygoView_ = std::make_unique( *config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_); + digiBattle99View_ = std::make_unique( + *config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, + *digiBattle99Mod_); ctx_ = std::make_unique(ccm::ui::AppContext{ *config_, @@ -137,7 +153,8 @@ public: *magicMod_, *pokeMod_, *ygoMod_, - { magicView_.get(), pokeView_.get(), ygoView_.get() }, + *digiBattle99Mod_, + { magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() }, }); auto* frame = new ccm::ui::MainFrame(*ctx_); @@ -158,21 +175,25 @@ private: std::unique_ptr magicMod_; std::unique_ptr pokeMod_; std::unique_ptr ygoMod_; + std::unique_ptr digiBattle99Mod_; std::unique_ptr> magicRepo_; std::unique_ptr> pokeRepo_; std::unique_ptr> ygoRepo_; + std::unique_ptr> digiBattle99Repo_; std::unique_ptr setRepo_; std::unique_ptr imgStore_; std::unique_ptr imgSvc_; std::unique_ptr> magicCollSvc_; std::unique_ptr> pokeCollSvc_; std::unique_ptr> ygoCollSvc_; + std::unique_ptr> digiBattle99CollSvc_; std::unique_ptr setSvc_; std::unique_ptr previewCache_; std::unique_ptr previewSvc_; std::unique_ptr magicView_; std::unique_ptr pokeView_; std::unique_ptr ygoView_; + std::unique_ptr digiBattle99View_; std::unique_ptr ctx_; }; diff --git a/core/AGENTS.md b/core/AGENTS.md index a210e3a..2e74c00 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -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`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `DigiBattle99Card`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. - `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `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` (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` (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/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/`, and `digibattle99/` are the reference implementations — all four expose a fully working set source + card preview source. - `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` 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 (`----.`) 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 `||||<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 `/.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 `.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.pokemontcg.io`, `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 diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 984bdaa..43dce74 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -7,6 +7,7 @@ add_library(ccm_core STATIC src/domain/MagicCard.cpp src/domain/PokemonCard.cpp src/domain/YuGiOhCard.cpp + src/domain/DigiBattle99Card.cpp src/domain/Configuration.cpp src/services/ConfigService.cpp @@ -31,6 +32,9 @@ add_library(ccm_core STATIC 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/util/FsNames.cpp ) diff --git a/core/include/ccm/domain/DigiBattle99Card.hpp b/core/include/ccm/domain/DigiBattle99Card.hpp new file mode 100644 index 0000000..e68f5ed --- /dev/null +++ b/core/include/ccm/domain/DigiBattle99Card.hpp @@ -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 + +#include +#include +#include + +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 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 diff --git a/core/include/ccm/domain/Enums.hpp b/core/include/ccm/domain/Enums.hpp index 22c6cbd..dd3854f 100644 --- a/core/include/ccm/domain/Enums.hpp +++ b/core/include/ccm/domain/Enums.hpp @@ -19,6 +19,7 @@ enum class Game { Magic, Pokemon, YuGiOh, + DigiBattle99, }; enum class Language { @@ -57,7 +58,7 @@ std::optional languageFromString(std::string_view s) noexcept; std::optional conditionFromString(std::string_view s) noexcept; std::optional themeFromString(std::string_view s) noexcept; -const std::array& allGames() noexcept; +const std::array& allGames() noexcept; const std::array& allLanguages() noexcept; const std::array& allConditions() noexcept; const std::array& allThemes() noexcept; diff --git a/core/include/ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp b/core/include/ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp new file mode 100644 index 0000000..3f6c57b --- /dev/null +++ b/core/include/ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp @@ -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 +#include +#include + +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 + fetchImageUrl(std::string_view name, + std::string_view setName, + std::string_view setNo) override; + Result detectFirstPrint(std::string_view name, + std::string_view setName) override; + Result> 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 + parseImageUrlFromSearch(const std::string& body, + std::string_view wantedCardName); + + static Result> + parsePrintVariants(const std::string& body, + std::string_view setName, + std::string_view wantedCardName); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/digibattle99/DigiBattle99GameModule.hpp b/core/include/ccm/games/digibattle99/DigiBattle99GameModule.hpp new file mode 100644 index 0000000..c8346bd --- /dev/null +++ b/core/include/ccm/games/digibattle99/DigiBattle99GameModule.hpp @@ -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 diff --git a/core/include/ccm/games/digibattle99/DigiBattle99SetSource.hpp b/core/include/ccm/games/digibattle99/DigiBattle99SetSource.hpp new file mode 100644 index 0000000..831864d --- /dev/null +++ b/core/include/ccm/games/digibattle99/DigiBattle99SetSource.hpp @@ -0,0 +1,37 @@ +#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. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include + +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"; + + explicit DigiBattle99SetSource(IHttpClient& http); + + Result> fetchAll() override; + + // Pure parser exposed for unit testing without a network round-trip. + static Result> parseResponse(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 diff --git a/core/include/ccm/services/CardFilter.hpp b/core/include/ccm/services/CardFilter.hpp index 4752218..25013f6 100644 --- a/core/include/ccm/services/CardFilter.hpp +++ b/core/include/ccm/services/CardFilter.hpp @@ -19,6 +19,7 @@ // * An empty filter matches every row, exactly as in JS where every string // `.includes("")` returns true. +#include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -41,4 +42,8 @@ namespace ccm { [[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); + } // namespace ccm diff --git a/core/include/ccm/services/CardSorter.hpp b/core/include/ccm/services/CardSorter.hpp index 0fe9dbb..b54f16c 100644 --- a/core/include/ccm/services/CardSorter.hpp +++ b/core/include/ccm/services/CardSorter.hpp @@ -16,6 +16,7 @@ // 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/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -66,6 +67,20 @@ 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, +}; + // Stable in-place sort. `ascending=false` runs the same comparator with // inverted sign, matching `byField(field, asc)` semantics. void sortMagicCards(std::vector& cards, MagicSortColumn column, @@ -74,5 +89,8 @@ void sortPokemonCards(std::vector& cards, PokemonSortColumn column, bool ascending); void sortYuGiOhCards(std::vector& cards, YuGiOhSortColumn column, bool ascending); +void sortDigiBattle99Cards(std::vector& cards, + DigiBattle99SortColumn column, + bool ascending); } // namespace ccm diff --git a/core/src/domain/DigiBattle99Card.cpp b/core/src/domain/DigiBattle99Card.cpp new file mode 100644 index 0000000..0132bbc --- /dev/null +++ b/core/src/domain/DigiBattle99Card.cpp @@ -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 diff --git a/core/src/domain/Enums.cpp b/core/src/domain/Enums.cpp index 55bbab8..bf2406e 100644 --- a/core/src/domain/Enums.cpp +++ b/core/src/domain/Enums.cpp @@ -13,9 +13,10 @@ 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"; } CCM_UNREACHABLE(); } @@ -56,9 +57,10 @@ std::string_view to_string(Theme t) noexcept { } std::optional 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; return std::nullopt; } @@ -91,8 +93,9 @@ std::optional themeFromString(std::string_view s) noexcept { return std::nullopt; } -const std::array& allGames() noexcept { - static constexpr std::array v{Game::Magic, Game::Pokemon, Game::YuGiOh}; +const std::array& allGames() noexcept { + static constexpr std::array v{ + Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99}; return v; } diff --git a/core/src/games/digibattle99/DigiBattle99CardPreviewSource.cpp b/core/src/games/digibattle99/DigiBattle99CardPreviewSource.cpp new file mode 100644 index 0000000..2f2ae37 --- /dev/null +++ b/core/src/games/digibattle99/DigiBattle99CardPreviewSource.cpp @@ -0,0 +1,222 @@ +#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp" + +#include "ccm/util/Rfc3986.hpp" + +#include + +#include +#include +#include +#include + +namespace ccm { + +namespace { + +std::string trim(std::string s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) s.erase(s.begin()); + while (!s.empty() && std::isspace(static_cast(s.back()))) s.pop_back(); + return s; +} + +std::string toLower(std::string s) { + for (char& ch : s) { + ch = static_cast(std::tolower(static_cast(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() == 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(s[i]))) { + s[i] = static_cast(std::toupper(static_cast(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 +DigiBattle99CardPreviewSource::parseImageUrlFromSearch(const std::string& body, + std::string_view wantedCardName) { + using R = Result; + 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 +DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view setName, + std::string_view setNo) { + using R = Result; + 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> DigiBattle99CardPreviewSource::parsePrintVariants( + const std::string& body, + std::string_view setName, + std::string_view wantedCardName) { + using R = Result>; + 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 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 deduped; + deduped.reserve(collected.size()); + std::unordered_set 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 DigiBattle99CardPreviewSource::detectFirstPrint( + std::string_view name, + std::string_view setName) { + auto list = detectPrintVariants(name, setName); + if (!list || list.value().empty()) { + if (!list) return Result::err(list.error()); + return Result::err("Could not auto-detect Digi-Battle set print metadata."); + } + return Result::ok(list.value().front()); +} + +Result> DigiBattle99CardPreviewSource::detectPrintVariants( + std::string_view name, + std::string_view setName) { + using R = Result>; + 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 diff --git a/core/src/games/digibattle99/DigiBattle99GameModule.cpp b/core/src/games/digibattle99/DigiBattle99GameModule.cpp new file mode 100644 index 0000000..a938f0e --- /dev/null +++ b/core/src/games/digibattle99/DigiBattle99GameModule.cpp @@ -0,0 +1,8 @@ +#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp" + +namespace ccm { + +DigiBattle99GameModule::DigiBattle99GameModule(IHttpClient& http) + : setSource_(http), previewSource_(http) {} + +} // namespace ccm diff --git a/core/src/games/digibattle99/DigiBattle99SetSource.cpp b/core/src/games/digibattle99/DigiBattle99SetSource.cpp new file mode 100644 index 0000000..c02b7d3 --- /dev/null +++ b/core/src/games/digibattle99/DigiBattle99SetSource.cpp @@ -0,0 +1,119 @@ +#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp" + +#include + +#include +#include +#include +#include +#include + +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& curatedReleaseDates() { + static const std::unordered_map 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 {}; +} + +} // 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(std::tolower(ch))); + } else { + pendingHyphen = !out.empty(); + } + } + return out; +} + +Result> DigiBattle99SetSource::parseResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (j.is_object() && j.contains("error")) { + return Result>::err( + j.value("error", std::string{"digimoncard.io set search error"})); + } + if (!j.is_array()) { + return Result>::err( + "digimoncard.io Digi-Battle response is not a JSON array."); + } + + // Preserve first-seen order of pack names, then sort by release date. + std::unordered_set seen; + std::vector packNames; + packNames.reserve(16); + for (const auto& entry : j) { + if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue; + for (const auto& pack : entry.at("set_name")) { + if (!pack.is_string()) continue; + const std::string name = pack.get(); + if (name.empty()) continue; + if (seen.insert(name).second) packNames.push_back(name); + } + } + + std::vector 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>::ok(std::move(out)); + } catch (const std::exception& e) { + return Result>::err( + std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()); + } +} + +Result> DigiBattle99SetSource::fetchAll() { + auto resp = http_.get(kEndpoint); + if (!resp) return Result>::err(resp.error()); + return parseResponse(resp.value()); +} + +} // namespace ccm diff --git a/core/src/services/CardFilter.cpp b/core/src/services/CardFilter.cpp index 24d02d7..47ea555 100644 --- a/core/src/services/CardFilter.cpp +++ b/core/src/services/CardFilter.cpp @@ -67,4 +67,19 @@ 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; +} + } // namespace ccm diff --git a/core/src/services/CardSorter.cpp b/core/src/services/CardSorter.cpp index a6584e5..26da5a3 100644 --- a/core/src/services/CardSorter.cpp +++ b/core/src/services/CardSorter.cpp @@ -223,4 +223,74 @@ void sortYuGiOhCards(std::vector& cards, YuGiOhSortColumn column, } } +void sortDigiBattle99Cards(std::vector& 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; + } +} + } // namespace ccm diff --git a/docs/AGENTS.md b/docs/AGENTS.md index ff0f8f0..6a16589 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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, 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`), 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/`). diff --git a/docs/README.md b/docs/README.md index ac791f5..fdf46af 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index cb1c070..6658311 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -78,6 +78,43 @@ 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. +## 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. + +### 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`. + ## Runtime Flow In CCM3 The app uses the same flow for every game that registers a module: @@ -91,15 +128,15 @@ The app uses the same flow for every game that registers a module: 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, 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 `/.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 `.bin` (positive payload) or `.neg` (negative marker) plus a `.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.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, and `images.digimoncard.io` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway. `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 `/.cache/preview-cache/` subdirectory or the umbrella `/.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). @@ -110,6 +147,7 @@ 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` - 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 `/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 `/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 +158,6 @@ All source types return `Result` 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..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 (`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..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`). 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:` 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. diff --git a/docs/assets/images/demo-digibattle99.png b/docs/assets/images/demo-digibattle99.png new file mode 100644 index 0000000..7bb131e Binary files /dev/null and b/docs/assets/images/demo-digibattle99.png differ diff --git a/docs/caching.md b/docs/caching.md index 6d8a2db..c46e599 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -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 `||||<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 `||||<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. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 66fddcb..1f18a9f 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -21,9 +21,11 @@ - `std_file_system_tests.cpp` — `StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests. - `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL. - `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`. +- `digibattle99_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. Drives `fetchAll` via `FixedHttpClient`. +- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`. - `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. - `yugioh_set_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. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index badd60a..7e67aee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -21,6 +21,8 @@ add_executable(ccm_core_tests std_file_system_tests.cpp pokemon_set_source_tests.cpp pokemon_card_preview_source_tests.cpp + digibattle99_set_source_tests.cpp + digibattle99_card_preview_source_tests.cpp icard_preview_source_tests.cpp yugioh_set_source_tests.cpp yugioh_set_lookup_tests.cpp diff --git a/tests/card_filter_tests.cpp b/tests/card_filter_tests.cpp index 9501a99..4927237 100644 --- a/tests/card_filter_tests.cpp +++ b/tests/card_filter_tests.cpp @@ -6,6 +6,7 @@ #include #include "ccm/domain/Enums.hpp" +#include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -238,3 +239,40 @@ 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")); + } +} diff --git a/tests/card_sorter_tests.cpp b/tests/card_sorter_tests.cpp index de5f8d4..e93c4af 100644 --- a/tests/card_sorter_tests.cpp +++ b/tests/card_sorter_tests.cpp @@ -6,6 +6,7 @@ #include #include "ccm/domain/Enums.hpp" +#include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -94,6 +95,30 @@ 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; +} + std::vector ids(const std::vector& v) { std::vector out; out.reserve(v.size()); @@ -115,6 +140,13 @@ std::vector ids(const std::vector& v) { return out; } +std::vector ids(const std::vector& v) { + std::vector 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 +483,35 @@ TEST_SUITE("CardSorter - YuGiOh columns") { CHECK(ids(v) == std::vector{3, 1, 2}); } } + +TEST_SUITE("CardSorter - DigiBattle99 columns") { + TEST_CASE("Holo and FirstEdition sort false before true") { + std::vector 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{2, 3, 1}); + sortDigiBattle99Cards(v, DigiBattle99SortColumn::FirstEdition, /*ascending=*/true); + CHECK(ids(v) == std::vector{3, 1, 2}); + } + + TEST_CASE("Set column sorts by release date") { + std::vector 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{2, 1}); + } + + TEST_CASE("Name sorts case-insensitively") { + std::vector 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{2, 1}); + } +} diff --git a/tests/digibattle99_card_preview_source_tests.cpp b/tests/digibattle99_card_preview_source_tests.cpp new file mode 100644 index 0000000..2e88a92 --- /dev/null +++ b/tests/digibattle99_card_preview_source_tests.cpp @@ -0,0 +1,179 @@ +#include + +#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string lastUrl; + std::string body; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::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"); + } +} diff --git a/tests/digibattle99_set_source_tests.cpp b/tests/digibattle99_set_source_tests.cpp new file mode 100644 index 0000000..831ddd3 --- /dev/null +++ b/tests/digibattle99_set_source_tests.cpp @@ -0,0 +1,112 @@ +#include + +#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 get(std::string_view url) override { + lastUrl = std::string(url); + return ok ? Result::ok(body) + : Result::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); + } +} diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index ec99884..fcd264f 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -1,6 +1,7 @@ #include #include "ccm/domain/Configuration.hpp" +#include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/Enums.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" @@ -23,6 +24,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") { nlohmann::json jYgo = "YuGiOh"; CHECK(jYgo.get() == Game::YuGiOh); + nlohmann::json jDigi = "DigiBattle99"; + CHECK(jDigi.get() == Game::DigiBattle99); + nlohmann::json j3 = Theme::Dark; CHECK(j3.get() == "Dark"); CHECK(j3.get() == Theme::Dark); @@ -153,6 +157,34 @@ TEST_SUITE("PokemonCard JSON") { } } +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(); + CHECK(back == c); + } +} + TEST_SUITE("Configuration JSON matches Rust serde aliases") { TEST_CASE("dataStorage / defaultGame / theme keys are present") { Configuration cfg; @@ -497,6 +529,36 @@ 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()); + } + } + TEST_CASE("Configuration missing required key throws") { const nlohmann::json j = { {"defaultGame", "Magic"}, diff --git a/tests/game_module_tests.cpp b/tests/game_module_tests.cpp index a571206..607fe7e 100644 --- a/tests/game_module_tests.cpp +++ b/tests/game_module_tests.cpp @@ -1,5 +1,6 @@ #include +#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp" #include "ccm/games/magic/MagicGameModule.hpp" #include "ccm/games/pokemon/PokemonGameModule.hpp" #include "ccm/games/yugioh/YuGiOhGameModule.hpp" @@ -51,4 +52,15 @@ TEST_SUITE("game modules expose stable identity and wiring") { CHECK(module.cardPreviewSource() != nullptr); CHECK(static_cast(&module.setSource()) != static_cast(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(&module.setSource()) != static_cast(module.cardPreviewSource())); + } } diff --git a/tests/local_image_store_tests.cpp b/tests/local_image_store_tests.cpp index 98b93ae..3c48fca 100644 --- a/tests/local_image_store_tests.cpp +++ b/tests/local_image_store_tests.cpp @@ -17,6 +17,7 @@ std::string dirNameForGame(Game g) { case Game::Magic: return "magic"; case Game::Pokemon: return "pokemon"; case Game::YuGiOh: return "yugioh"; + case Game::DigiBattle99: return "digibattle99"; } return "magic"; } diff --git a/tests/set_service_tests.cpp b/tests/set_service_tests.cpp index 26656af..7be748a 100644 --- a/tests/set_service_tests.cpp +++ b/tests/set_service_tests.cpp @@ -148,6 +148,37 @@ TEST_SUITE("SetService") { CHECK(yugioh.source.calls == 1); } + TEST_CASE("DigiBattle99 module routes independently when all games are registered") { + InMemSetRepo repo; + SetService svc{repo}; + + FakeGameModule magic{Game::Magic}; + magic.source.result = Result>::ok({{"lea", "Alpha", "1993/08/05"}}); + FakeGameModule pokemon{Game::Pokemon}; + pokemon.source.result = Result>::ok({{"base1", "Base", "1999/01/09"}}); + FakeGameModule yugioh{Game::YuGiOh}; + yugioh.source.result = Result>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}}); + FakeGameModule digi{Game::DigiBattle99}; + digi.source.result = Result>::ok( + {{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"}}); + + svc.registerModule(&magic); + svc.registerModule(&pokemon); + svc.registerModule(&yugioh); + svc.registerModule(&digi); + + REQUIRE(svc.updateSets(Game::Magic).isOk()); + REQUIRE(svc.updateSets(Game::Pokemon).isOk()); + REQUIRE(svc.updateSets(Game::YuGiOh).isOk()); + const auto out = svc.updateSets(Game::DigiBattle99); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "series-1-starter-set"); + CHECK(magic.source.calls == 1); + CHECK(pokemon.source.calls == 1); + CHECK(yugioh.source.calls == 1); + CHECK(digi.source.calls == 1); + } + TEST_CASE("updateSets propagates repository save failures") { InMemSetRepo repo; repo.failSave = true; diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index 0e2aa70..f6cc847 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -9,7 +9,7 @@ - `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. - `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). - `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32. -- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `/assets/ygo_card_back.png` (copied next to the executable by `app/CMakeLists.txt` on link — source file `ui_wx/assets/ygo_card_back.png`). The constructor caches `/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant. +- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant. - `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. - `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`. - `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 lines of hook overrides on top of the matching base template. @@ -67,7 +67,7 @@ - When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`. 15. **Preview fallback behavior (CCM2 parity where applicable):** - Keep unresolved external previews user-visible by showing a per-game card-back image in `BaseSelectedCardPanel` instead of a blank/transparent bitmap. - - Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails — keep that chain working when touching preview code. + - Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails. Digimon Digi-Battle uses a **bundled** PNG (`assets/digibattle99_card_back.png`) — keep those chains working when touching preview code. - If you change fallback sourcing (URLs or bundled asset), keep the "always show a reasonable card-back fallback" behavior intact for **every** game with remote previews. 16. **Per-game auto-detect controls:** - Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game. @@ -76,7 +76,7 @@ ## Required follow-ups -- If you replace **`ui_wx/assets/ygo_card_back.png`**, rebuild the **`ccm`** target so `app/CMakeLists.txt`'s `POST_BUILD` copy refreshes `/assets/`; do not remove the asset without updating `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`. +- If you replace **`ui_wx/assets/ygo_card_back.png`** or **`ui_wx/assets/digibattle99_card_back.png`**, rebuild the **`ccm`** target so `app/CMakeLists.txt`'s `POST_BUILD` copy refreshes `/assets/`; do not remove an asset without updating `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`. - After adding a new dialog/panel `.cpp` you **must** add it to `ui_wx/CMakeLists.txt`. - After adding a new menu action you **must** allocate an `Ids::*` value in `MainFrame.hpp` (don't reuse `wxID_HIGHEST` math inline) and `Bind` it in `buildMenuBar`. The dynamic Game / Sets menus consume the `IdGameMenuBase` / `IdSetsMenuBase` ranges; do not stomp on those id ranges. - After changing `AppContext` you **must** update `app/main.cpp` so the composition root populates the new field. diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt index b8dbf09..5bb90b5 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -19,6 +19,10 @@ add_library(ccm_ui_wx STATIC src/YuGiOhSelectedCardPanel.cpp src/YuGiOhCardEditDialog.cpp src/YuGiOhGameView.cpp + src/DigiBattle99CardListPanel.cpp + src/DigiBattle99SelectedCardPanel.cpp + src/DigiBattle99CardEditDialog.cpp + src/DigiBattle99GameView.cpp src/SettingsDialog.cpp src/SwitchCtrl.cpp diff --git a/ui_wx/assets/digibattle99_card_back.png b/ui_wx/assets/digibattle99_card_back.png new file mode 100644 index 0000000..e6a3d20 Binary files /dev/null and b/ui_wx/assets/digibattle99_card_back.png differ diff --git a/ui_wx/include/ccm/ui/AppContext.hpp b/ui_wx/include/ccm/ui/AppContext.hpp index 3bfd551..be020c4 100644 --- a/ui_wx/include/ccm/ui/AppContext.hpp +++ b/ui_wx/include/ccm/ui/AppContext.hpp @@ -26,6 +26,7 @@ struct AppContext { IGameModule& magicModule; IGameModule& pokemonModule; IGameModule& yuGiOhModule; + IGameModule& digiBattle99Module; // Active per-game UI bundles. The order is the order shown in the // Game menu; the composition root constructs them and hands raw // pointers in. `MainFrame` does not own these — `app/main.cpp` does. diff --git a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp index eaf5eb2..5c6043c 100644 --- a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp @@ -295,9 +295,11 @@ private: case Game::YuGiOh: // Yugipedia English TCG backing (thumbnail — smaller than full scan). return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png"; - default: + case Game::DigiBattle99: + // No stable public Digi-Battle back URL; UI uses bundled PNG. return {}; } + return {}; } void buildInfoGrid(wxBoxSizer* root) { @@ -424,6 +426,16 @@ private: applyFallbackPayload(ss.str()); } } + } else if (game == Game::DigiBattle99) { + namespace fs = std::filesystem; + const fs::path asset = + fs::path(exeDirCopy) / "assets" / "digibattle99_card_back.png"; + std::ifstream in(asset, std::ios::binary); + if (in) { + std::ostringstream ss; + ss << in.rdbuf(); + applyFallbackPayload(ss.str()); + } } else { const std::string fallbackUrl = fallbackImageUrlForGame(game); if (!fallbackUrl.empty()) { diff --git a/ui_wx/include/ccm/ui/DigiBattle99CardEditDialog.hpp b/ui_wx/include/ccm/ui/DigiBattle99CardEditDialog.hpp new file mode 100644 index 0000000..82f0d8f --- /dev/null +++ b/ui_wx/include/ccm/ui/DigiBattle99CardEditDialog.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/services/CardPreviewService.hpp" +#include "ccm/ui/BaseCardEditDialog.hpp" +#include + +#include +#include +#include +#include + +namespace ccm::ui { + +class DigiBattle99CardEditDialog final : public BaseCardEditDialog { +public: + DigiBattle99CardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + CardPreviewService& cardPreview, + EditMode mode, + DigiBattle99Card initial, + const std::vector* preloadedSets = nullptr); + ~DigiBattle99CardEditDialog() override; + +protected: + void buildFlagsRow(wxBoxSizer* flagsBox) override; + void appendExtraRows(wxFlexGridSizer* grid) override; + void readExtraFromCard() override; + void writeExtraToCard() override; + [[nodiscard]] std::string updateMenuName() const override { + return "Update Digimon (Digi-Battle)"; + } + void onCardLookupContextChanged() override; + +private: + struct VariantFetchState { + std::atomic alive{true}; + }; + + void onAutoDetectSetNo(wxCommandEvent&); + void onNextSetNo(wxCommandEvent&); + void onSetSelectionChanged(wxCommandEvent&); + void autoDetectFromApi(); + void clearCachedPrintVariants(); + void requestVariantsAsync(unsigned capturedEpoch, + std::string name, + std::string setName, + bool fillSetNoOnSuccess, + bool showFailureDialog); + void applyDetectedVariants(unsigned capturedEpoch, + Result> detected, + bool fillSetNoOnSuccess, + bool showFailureDialog); + void rebuildVariantRingFromCache(); + void syncRingPositionToControls(); + void refreshVariantNextControls(); + void scheduleDeferredVariantPrefetch(); + void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch); + [[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl); + [[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo); + + EditMode dialogMode_; + unsigned variantFetchEpoch_{0}; + CardPreviewService& cardPreview_; + std::shared_ptr variantFetchState_; + wxTextCtrl* setNoCtrl_{nullptr}; + wxButton* autoSetNoBtn_{nullptr}; + wxButton* nextSetNoBtn_{nullptr}; + wxCheckBox* holoCheck_{nullptr}; + wxCheckBox* firstEditionCheck_{nullptr}; + wxCheckBox* signedCheck_{nullptr}; + wxCheckBox* alteredCheck_{nullptr}; + + std::vector cachedVariants_; + std::vector uniqueSetNos_; + std::size_t setNoRingPos_{0}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/DigiBattle99CardListPanel.hpp b/ui_wx/include/ccm/ui/DigiBattle99CardListPanel.hpp new file mode 100644 index 0000000..88ca286 --- /dev/null +++ b/ui_wx/include/ccm/ui/DigiBattle99CardListPanel.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/services/CardSorter.hpp" +#include "ccm/ui/BaseCardListPanel.hpp" + +namespace ccm::ui { + +class DigiBattle99CardListPanel final + : public BaseCardListPanel { +public: + explicit DigiBattle99CardListPanel(wxWindow* parent); + +protected: + [[nodiscard]] std::vector declareTextColumns() const override; + [[nodiscard]] std::vector declareIconColumns() const override; + [[nodiscard]] std::string renderTextCell(const DigiBattle99Card& card, + std::size_t idx) const override; + [[nodiscard]] bool isIconColumnSet(const DigiBattle99Card& card, + std::size_t idx) const override; + void sortBy(DigiBattle99SortColumn column, bool ascending) override; + [[nodiscard]] bool matchesFilter(const DigiBattle99Card& card, + std::string_view filter) const override; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp new file mode 100644 index 0000000..bc31c47 --- /dev/null +++ b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/games/IGameModule.hpp" +#include "ccm/services/CardPreviewService.hpp" +#include "ccm/services/CollectionService.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/services/ImageService.hpp" +#include "ccm/services/SetService.hpp" +#include "ccm/ui/IGameView.hpp" + +#include +#include +#include + +namespace ccm::ui { + +class DigiBattle99CardListPanel; +class DigiBattle99SelectedCardPanel; + +class DigiBattle99GameView final : public IGameView { +public: + DigiBattle99GameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module); + + [[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; } + [[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; } + + wxPanel* listPanel(wxWindow* parent) override; + wxPanel* selectedPanel(wxWindow* parent) override; + + void refreshCollection() override; + void onAddCard(wxWindow* parentWindow) override; + void onEditCard(wxWindow* parentWindow) override; + void onDeleteCard(wxWindow* parentWindow) override; + std::string onUpdateSets(wxWindow* parentWindow) override; + void setFilter(std::string_view filter) override; + void applyTheme(const ThemePalette& palette) override; + [[nodiscard]] std::string updateSetsMenuLabel() const override { + return "Update Digimon (Digi-Battle)"; + } + +private: + void ensureSetsLoaded(); + const std::vector& setsForDialog(); + + ConfigService& config_; + CollectionService& collection_; + SetService& sets_; + ImageService& images_; + CardPreviewService& cardPreview_; + IGameModule& module_; + + DigiBattle99CardListPanel* listPanel_{nullptr}; + DigiBattle99SelectedCardPanel* selectedPanel_{nullptr}; + std::vector setsCache_; + bool attemptedInitialSetLoad_{false}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/DigiBattle99SelectedCardPanel.hpp b/ui_wx/include/ccm/ui/DigiBattle99SelectedCardPanel.hpp new file mode 100644 index 0000000..378e262 --- /dev/null +++ b/ui_wx/include/ccm/ui/DigiBattle99SelectedCardPanel.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/ui/BaseSelectedCardPanel.hpp" + +namespace ccm::ui { + +class DigiBattle99SelectedCardPanel final : public BaseSelectedCardPanel { +public: + DigiBattle99SelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview); + +protected: + [[nodiscard]] std::vector declareDetailRows() const override; + [[nodiscard]] std::vector declareFlagIcons() const override; + [[nodiscard]] std::string detailValueFor(const DigiBattle99Card& card, + DetailKey key) const override; + [[nodiscard]] bool isFlagSet(const DigiBattle99Card& card, DetailKey key) const override; + [[nodiscard]] std::tuple + previewKey(const DigiBattle99Card& card) const override; + [[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; } +}; + +} // namespace ccm::ui diff --git a/ui_wx/src/DigiBattle99CardEditDialog.cpp b/ui_wx/src/DigiBattle99CardEditDialog.cpp new file mode 100644 index 0000000..17d7683 --- /dev/null +++ b/ui_wx/src/DigiBattle99CardEditDialog.cpp @@ -0,0 +1,255 @@ +#include "ccm/ui/DigiBattle99CardEditDialog.hpp" + +#include "ccm/domain/Enums.hpp" +#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp" +#include +#include +#include +#include + +namespace ccm::ui { + +DigiBattle99CardEditDialog::DigiBattle99CardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + CardPreviewService& cardPreview, + EditMode mode, + DigiBattle99Card initial, + const std::vector* preloadedSets) + : BaseCardEditDialog( + parent, + mode == EditMode::Create ? "Add Digimon (Digi-Battle) Card" + : "Edit Digimon (Digi-Battle) Card", + imageService, setService, mode, std::move(initial), Game::DigiBattle99, + preloadedSets), + dialogMode_(mode), + cardPreview_(cardPreview), + variantFetchState_(std::make_shared()) { + buildAndPopulate(); + if (dialogMode_ == EditMode::Edit) { + scheduleDeferredVariantPrefetch(); + } +} + +DigiBattle99CardEditDialog::~DigiBattle99CardEditDialog() { + if (variantFetchState_) { + variantFetchState_->alive.store(false); + } +} + +void DigiBattle99CardEditDialog::onCardLookupContextChanged() { + clearCachedPrintVariants(); +} + +void DigiBattle99CardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) { + holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo"); + firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition"); + signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed"); + alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered"); + flagsBox->Add(holoCheck_, 0, wxRIGHT, 12); + flagsBox->Add(firstEditionCheck_, 0, wxRIGHT, 12); + flagsBox->Add(signedCheck_, 0, wxRIGHT, 12); + flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12); +} + +void DigiBattle99CardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { + auto* setNoPanel = new wxPanel(this, wxID_ANY); + setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY); + autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect"); + autoSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onAutoDetectSetNo, this); + nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next"); + nextSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onNextSetNo, this); + nextSetNoBtn_->Show(false); + auto* setNoRow = new wxBoxSizer(wxHORIZONTAL); + setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL); + setNoPanel->SetSizer(setNoRow); + + appendRow(grid, "Set #", setNoPanel); + + if (auto* setCombo = setComboControl()) { + setCombo->Bind(wxEVT_COMBOBOX, &DigiBattle99CardEditDialog::onSetSelectionChanged, this); + } +} + +std::string DigiBattle99CardEditDialog::normalizedStoredSetNo(std::string_view setNo) { + return DigiBattle99CardPreviewSource::normalizeCardNumber(setNo); +} + +std::string DigiBattle99CardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) { + if (ctrl == nullptr) return {}; + return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8)); +} + +void DigiBattle99CardEditDialog::readExtraFromCard() { + clearCachedPrintVariants(); + if (setNoCtrl_) { + setNoCtrl_->ChangeValue( + wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str())); + } + if (holoCheck_) holoCheck_->SetValue(constCard().holo); + if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition); + if (signedCheck_) signedCheck_->SetValue(constCard().signed_); + if (alteredCheck_) alteredCheck_->SetValue(constCard().altered); +} + +void DigiBattle99CardEditDialog::writeExtraToCard() { + if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_); + if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked(); + if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked(); + if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked(); + if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked(); +} + +void DigiBattle99CardEditDialog::clearCachedPrintVariants() { + ++variantFetchEpoch_; + cachedVariants_.clear(); + uniqueSetNos_.clear(); + setNoRingPos_ = 0; + refreshVariantNextControls(); +} + +void DigiBattle99CardEditDialog::scheduleDeferredVariantPrefetch() { + const unsigned epoch = variantFetchEpoch_; + wxTheApp->CallAfter([this, epoch]() { + prefetchVariantsForCurrentCardSilent(epoch); + }); +} + +void DigiBattle99CardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) { + if (capturedEpoch != variantFetchEpoch_) return; + if (!cachedVariants_.empty()) return; + const auto& card = constCard(); + // digimoncard.io pack= uses the display set name, not the slug id. + if (card.name.empty() || card.set.name.empty()) return; + + requestVariantsAsync(capturedEpoch, card.name, card.set.name, false, false); +} + +void DigiBattle99CardEditDialog::requestVariantsAsync(unsigned capturedEpoch, + std::string name, + std::string setName, + bool fillSetNoOnSuccess, + bool showFailureDialog) { + if (capturedEpoch != variantFetchEpoch_) return; + + if (fillSetNoOnSuccess && autoSetNoBtn_) { + autoSetNoBtn_->Disable(); + } + + auto state = variantFetchState_; + CardPreviewService* svc = &cardPreview_; + DigiBattle99CardEditDialog* self = this; + std::thread([state, svc, self, capturedEpoch, name = std::move(name), + setName = std::move(setName), fillSetNoOnSuccess, showFailureDialog]() { + auto detected = svc->detectPrintVariants(Game::DigiBattle99, name, setName); + wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected), + fillSetNoOnSuccess, showFailureDialog]() mutable { + if (!state->alive.load()) return; + self->applyDetectedVariants(capturedEpoch, std::move(detected), + fillSetNoOnSuccess, showFailureDialog); + }); + }).detach(); +} + +void DigiBattle99CardEditDialog::applyDetectedVariants( + unsigned capturedEpoch, + Result> detected, + bool fillSetNoOnSuccess, + bool showFailureDialog) { + if (capturedEpoch != variantFetchEpoch_) return; + + if (fillSetNoOnSuccess && autoSetNoBtn_) { + autoSetNoBtn_->Enable(); + } + + if (!detected) { + if (showFailureDialog) { + showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect", + wxOK | wxICON_WARNING); + } + return; + } + + cachedVariants_ = std::move(detected).value(); + if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) { + setNoCtrl_->ChangeValue( + wxString::FromUTF8(cachedVariants_.front().setNo.c_str())); + } + + rebuildVariantRingFromCache(); + syncRingPositionToControls(); + refreshVariantNextControls(); +} + +void DigiBattle99CardEditDialog::rebuildVariantRingFromCache() { + uniqueSetNos_.clear(); + if (cachedVariants_.empty()) return; + + std::unordered_set seen; + seen.reserve(cachedVariants_.size()); + for (const auto& p : cachedVariants_) { + if (p.setNo.empty()) continue; + if (!seen.insert(p.setNo).second) continue; + uniqueSetNos_.push_back(p.setNo); + } +} + +void DigiBattle99CardEditDialog::syncRingPositionToControls() { + if (!setNoCtrl_) return; + const std::string current = storedSetNoFromControls(setNoCtrl_); + setNoRingPos_ = 0; + for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) { + if (uniqueSetNos_[i] == current) { + setNoRingPos_ = i; + break; + } + } +} + +void DigiBattle99CardEditDialog::refreshVariantNextControls() { + if (!nextSetNoBtn_) return; + nextSetNoBtn_->Show(uniqueSetNos_.size() > 1); + Layout(); + if (GetSizer()) Fit(); +} + +void DigiBattle99CardEditDialog::onAutoDetectSetNo(wxCommandEvent&) { + autoDetectFromApi(); +} + +void DigiBattle99CardEditDialog::onNextSetNo(wxCommandEvent&) { + if (uniqueSetNos_.size() <= 1) return; + setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size(); + if (setNoCtrl_) { + setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str())); + } + refreshVariantNextControls(); +} + +void DigiBattle99CardEditDialog::autoDetectFromApi() { + syncCardFromControls(); + const auto& card = constCard(); + if (card.name.empty()) { + showThemedMessageDialog(this, "Enter a card name first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + if (card.set.name.empty()) { + showThemedMessageDialog(this, "Select a set first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + + const unsigned epoch = variantFetchEpoch_; + requestVariantsAsync(epoch, card.name, card.set.name, true, true); +} + +void DigiBattle99CardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) { + clearCachedPrintVariants(); + scheduleDeferredVariantPrefetch(); + ev.Skip(); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/DigiBattle99CardListPanel.cpp b/ui_wx/src/DigiBattle99CardListPanel.cpp new file mode 100644 index 0000000..c48813c --- /dev/null +++ b/ui_wx/src/DigiBattle99CardListPanel.cpp @@ -0,0 +1,71 @@ +#include "ccm/ui/DigiBattle99CardListPanel.hpp" + +#include "ccm/services/CardFilter.hpp" +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +DigiBattle99CardListPanel::DigiBattle99CardListPanel(wxWindow* parent) + : BaseCardListPanel(parent) { + buildLayout(); +} + +std::vector +DigiBattle99CardListPanel::declareTextColumns() const { + return { + {"Name", 220, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Name}, + {"Set", 180, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::SetReleaseDate}, + {"Amount", 70, wxLIST_FORMAT_RIGHT, DigiBattle99SortColumn::Amount}, + {"Condition", 100, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Condition}, + {"Language", 100, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Language}, + {"Note", 220, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Note}, + }; +} + +std::vector +DigiBattle99CardListPanel::declareIconColumns() const { + constexpr int kFlagColWidth = 36; + return { + {kSvgHolo, kFlagColWidth, DigiBattle99SortColumn::Holo}, + {kSvgFirstEdition, kFlagColWidth, DigiBattle99SortColumn::FirstEdition}, + {kSvgSigned, kFlagColWidth, DigiBattle99SortColumn::Signed}, + {kSvgAltered, kFlagColWidth, DigiBattle99SortColumn::Altered}, + }; +} + +std::string DigiBattle99CardListPanel::renderTextCell(const DigiBattle99Card& card, + std::size_t idx) const { + switch (idx) { + case 0: return card.name; + case 1: return card.set.name; + case 2: return std::to_string(card.amount); + case 3: return std::string(to_string(card.condition)); + case 4: return std::string(to_string(card.language)); + case 5: return card.note; + } + return {}; +} + +bool DigiBattle99CardListPanel::isIconColumnSet(const DigiBattle99Card& card, + std::size_t idx) const { + switch (idx) { + case 0: return card.holo; + case 1: return card.firstEdition; + case 2: return card.signed_; + case 3: return card.altered; + } + return false; +} + +void DigiBattle99CardListPanel::sortBy(DigiBattle99SortColumn column, bool ascending) { + sortDigiBattle99Cards(mutableCards(), column, ascending); +} + +bool DigiBattle99CardListPanel::matchesFilter(const DigiBattle99Card& card, + std::string_view filter) const { + return matchesDigiBattle99Filter(card, filter); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/DigiBattle99GameView.cpp b/ui_wx/src/DigiBattle99GameView.cpp new file mode 100644 index 0000000..f572074 --- /dev/null +++ b/ui_wx/src/DigiBattle99GameView.cpp @@ -0,0 +1,216 @@ +#include "ccm/ui/DigiBattle99GameView.hpp" + +#include "ccm/ui/CardEditModalGuard.hpp" +#include "ccm/ui/DigiBattle99CardEditDialog.hpp" +#include "ccm/ui/DigiBattle99CardListPanel.hpp" +#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp" +#include "ccm/ui/Theme.hpp" + +#include +#include + +#include +#include + +namespace ccm::ui { + +DigiBattle99GameView::DigiBattle99GameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module) + : config_(config), + collection_(collection), + sets_(sets), + images_(images), + cardPreview_(cardPreview), + module_(module) {} + +void DigiBattle99GameView::ensureSetsLoaded() { + if (attemptedInitialSetLoad_) return; + attemptedInitialSetLoad_ = true; + + auto cached = sets_.getSets(Game::DigiBattle99); + if (cached) { + setsCache_ = std::move(cached).value(); + if (!setsCache_.empty()) return; + } else { + setsCache_.clear(); + } + + auto refreshed = sets_.updateSets(Game::DigiBattle99); + if (refreshed) { + setsCache_ = std::move(refreshed).value(); + } +} + +wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) { + if (listPanel_ == nullptr) { + listPanel_ = new DigiBattle99CardListPanel(parent); + listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) { + if (selectedPanel_ != nullptr && listPanel_ != nullptr) { + selectedPanel_->setCard(listPanel_->selected()); + } + }); + listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(listPanel_); + onEditCard(owner != nullptr ? owner : static_cast(listPanel_)); + }); + } + return listPanel_; +} + +wxPanel* DigiBattle99GameView::selectedPanel(wxWindow* parent) { + if (selectedPanel_ == nullptr) { + selectedPanel_ = new DigiBattle99SelectedCardPanel(parent, images_, cardPreview_); + } + return selectedPanel_; +} + +void DigiBattle99GameView::refreshCollection() { + if (listPanel_ == nullptr) return; + auto loaded = collection_.list(Game::DigiBattle99); + if (!loaded) { + showThemedMessageDialog( + nullptr, + "Failed to load Digimon (Digi-Battle) collection: " + loaded.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + listPanel_->setCards(std::move(loaded).value()); + listPanel_->activateSelection(); + if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); +} + +const std::vector& DigiBattle99GameView::setsForDialog() { + ensureSetsLoaded(); + if (!setsCache_.empty()) return setsCache_; + auto loaded = sets_.getSets(Game::DigiBattle99); + if (loaded) setsCache_ = std::move(loaded).value(); + else setsCache_.clear(); + return setsCache_; +} + +void DigiBattle99GameView::onAddCard(wxWindow* parentWindow) { + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION); + return; + } + DigiBattle99Card fresh; + fresh.amount = 1; + fresh.language = Language::English; + fresh.condition = Condition::NearMint; + + DigiBattle99CardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, + fresh, &setsForDialog()); + themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; + if (dlg.ShowModal() != wxID_OK) return; + + auto added = collection_.add(Game::DigiBattle99, dlg.card()); + if (!added) { + showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + + DigiBattle99Card persisted = dlg.card(); + persisted.id = added.value(); + auto normalized = images_.normalizeNamesForPersistedCard( + Game::DigiBattle99, persisted.id, persisted.set.name, persisted.name, persisted.images); + if (normalized) { + if (normalized.value() != persisted.images) { + persisted.images = std::move(normalized).value(); + auto updated = collection_.update(Game::DigiBattle99, persisted); + if (!updated) { + showThemedMessageDialog( + parentWindow, + "Card added, but image name normalization failed to persist: " + + updated.error(), + "Warning", wxOK | wxICON_WARNING); + } + } + } else { + showThemedMessageDialog( + parentWindow, + "Card added, but image rename to ID-prefixed format failed: " + normalized.error(), + "Warning", wxOK | wxICON_WARNING); + } + refreshCollection(); +} + +void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) { + if (listPanel_ == nullptr) return; + auto sel = listPanel_->selected(); + if (!sel) { + showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", + wxOK | wxICON_INFORMATION); + return; + } + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION); + return; + } + DigiBattle99CardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, + *sel, &setsForDialog()); + themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; + if (dlg.ShowModal() != wxID_OK) return; + auto updated = collection_.update(Game::DigiBattle99, dlg.card()); + if (!updated) { + showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + refreshCollection(); +} + +void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) { + if (listPanel_ == nullptr) return; + auto sel = listPanel_->selected(); + if (!sel) { + showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", + wxOK | wxICON_INFORMATION); + return; + } + if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + "Confirm") != wxID_YES) { + return; + } + auto removed = collection_.remove(Game::DigiBattle99, sel->id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + refreshCollection(); +} + +std::string DigiBattle99GameView::onUpdateSets(wxWindow* parentWindow) { + auto out = sets_.updateSets(Game::DigiBattle99); + if (!out) { + showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(), + "Error", wxOK | wxICON_ERROR); + return "Update failed"; + } + setsCache_ = out.value(); + showThemedMessageDialog( + parentWindow, + "Updated " + std::to_string(out.value().size()) + " Digimon (Digi-Battle) sets.", + "Sets updated", wxOK | wxICON_INFORMATION); + return "Digimon (Digi-Battle) sets updated."; +} + +void DigiBattle99GameView::setFilter(std::string_view filter) { + if (listPanel_) listPanel_->setFilter(filter); +} + +void DigiBattle99GameView::applyTheme(const ThemePalette& palette) { + if (listPanel_) listPanel_->applyTheme(palette); + if (selectedPanel_) selectedPanel_->applyTheme(palette); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/DigiBattle99SelectedCardPanel.cpp b/ui_wx/src/DigiBattle99SelectedCardPanel.cpp new file mode 100644 index 0000000..e890d1f --- /dev/null +++ b/ui_wx/src/DigiBattle99SelectedCardPanel.cpp @@ -0,0 +1,84 @@ +#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp" + +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +namespace { +enum DigiBattle99DetailKey : int { + kName = 0, + kSet, + kSetNo, + kLanguage, + kCondition, + kAmount, + kHolo, + kFirstEdition, + kSigned, + kAltered, +}; +} // namespace + +DigiBattle99SelectedCardPanel::DigiBattle99SelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview) + : BaseSelectedCardPanel(parent, imageService, cardPreview) { + buildLayout(); +} + +std::vector +DigiBattle99SelectedCardPanel::declareDetailRows() const { + return { + {"Name", kName, "(no card selected)"}, + {"Set", kSet, ""}, + {"Set #", kSetNo, ""}, + {"Language", kLanguage, ""}, + {"Condition", kCondition, ""}, + {"Amount", kAmount, ""}, + }; +} + +std::vector +DigiBattle99SelectedCardPanel::declareFlagIcons() const { + return { + {kSvgHolo, "Holo", kHolo}, + {kSvgFirstEdition, "1. Edition", kFirstEdition}, + {kSvgSigned, "Signed", kSigned}, + {kSvgAltered, "Altered", kAltered}, + }; +} + +std::string DigiBattle99SelectedCardPanel::detailValueFor(const DigiBattle99Card& card, + DetailKey key) const { + switch (key) { + case kName: return card.name; + case kSet: return card.set.name; + case kSetNo: return card.setNo; + case kLanguage: return std::string(to_string(card.language)); + case kCondition: return std::string(to_string(card.condition)); + case kAmount: return std::to_string(card.amount); + case kNoteKey: return card.note; + } + return {}; +} + +bool DigiBattle99SelectedCardPanel::isFlagSet(const DigiBattle99Card& card, + DetailKey key) const { + switch (key) { + case kHolo: return card.holo; + case kFirstEdition: return card.firstEdition; + case kSigned: return card.signed_; + case kAltered: return card.altered; + } + return false; +} + +std::tuple +DigiBattle99SelectedCardPanel::previewKey(const DigiBattle99Card& card) const { + // Middle slot is Set.name (pack display name) for digimoncard.io pack=. + return {card.name, card.set.name, card.setNo}; +} + +} // namespace ccm::ui diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index 4501510..d7d29d3 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -41,8 +41,10 @@ constexpr const char kFilterInputHint[] = "Filter"; std::string dirNameForGame(Game g) { switch (g) { - case Game::Magic: return "magic"; - case Game::Pokemon: return "pokemon"; + case Game::Magic: return "magic"; + case Game::Pokemon: return "pokemon"; + case Game::YuGiOh: return "yugioh"; + case Game::DigiBattle99: return "digibattle99"; } return "magic"; } @@ -67,7 +69,7 @@ void ensureDataStorageScaffold(const Configuration& cfg) { } } - for (Game game : {Game::Magic, Game::Pokemon}) { + for (Game game : allGames()) { const fs::path gameRoot = root / dirNameForGame(game); fs::create_directories(gameRoot / "images", ec); if (ec) continue; diff --git a/ui_wx/src/SettingsDialog.cpp b/ui_wx/src/SettingsDialog.cpp index 1e985a2..a941627 100644 --- a/ui_wx/src/SettingsDialog.cpp +++ b/ui_wx/src/SettingsDialog.cpp @@ -1,5 +1,6 @@ #include "ccm/ui/SettingsDialog.hpp" #include "ccm/ui/Theme.hpp" +#include "ccm/domain/Enums.hpp" #include #include @@ -9,6 +10,20 @@ namespace ccm::ui { +namespace { + +wxString displayLabelForGame(Game g) { + switch (g) { + case Game::Magic: return "Magic"; + case Game::Pokemon: return "Pokemon"; + case Game::YuGiOh: return "Yu-Gi-Oh!"; + case Game::DigiBattle99: return "Digimon (Digi-Battle)"; + } + return wxString::FromUTF8(to_string(g).data()); +} + +} // namespace + SettingsDialog::SettingsDialog(wxWindow* parent, ConfigService& config) : wxDialog(parent, wxID_ANY, "Settings", wxDefaultPosition, wxSize(560, 200), @@ -30,9 +45,14 @@ SettingsDialog::SettingsDialog(wxWindow* parent, ConfigService& config) gameRow->Add(new wxStaticText(this, wxID_ANY, "Default game:"), 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); defaultGameChoice_ = new wxChoice(this, wxID_ANY); - defaultGameChoice_->Append("Magic"); - defaultGameChoice_->Append("Pokemon"); - defaultGameChoice_->SetSelection(config_.current().defaultGame == Game::Magic ? 0 : 1); + int selected = 0; + int idx = 0; + for (const Game g : allGames()) { + defaultGameChoice_->Append(displayLabelForGame(g)); + if (g == config_.current().defaultGame) selected = idx; + ++idx; + } + defaultGameChoice_->SetSelection(selected); gameRow->Add(defaultGameChoice_, 0); root->Add(gameRow, 0, wxEXPAND | wxLEFT | wxRIGHT, 10); @@ -77,7 +97,11 @@ void SettingsDialog::onBrowse(wxCommandEvent&) { void SettingsDialog::onOk(wxCommandEvent& ev) { Configuration next = config_.current(); next.dataStorage = dataDirCtrl_->GetValue().ToStdString(); - next.defaultGame = defaultGameChoice_->GetSelection() == 0 ? Game::Magic : Game::Pokemon; + const int gameSel = defaultGameChoice_->GetSelection(); + const auto& games = allGames(); + if (gameSel >= 0 && static_cast(gameSel) < games.size()) { + next.defaultGame = games[static_cast(gameSel)]; + } switch (themeChoice_->GetSelection()) { case 1: next.theme = Theme::Dark; break; case 0: