mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
minor: New Game Digimon Digi-Battle (#17)
* digimon digi battle added to supported games * sonarqube update * readme update --------- Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
@@ -58,7 +58,7 @@ jobs:
|
||||
--exclude "^tests/"
|
||||
|
||||
- name: SonarQube Cloud scan
|
||||
uses: SonarSource/sonarqube-scan-action@v5
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
--exclude "^tests/"
|
||||
|
||||
- name: SonarQube Cloud scan
|
||||
uses: SonarSource/sonarqube-scan-action@v5
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -5,7 +5,7 @@ C++ desktop implementation (originally based on a Tauri Rust+TS version) — sin
|
||||
## Project structure
|
||||
|
||||
- `core/` — `ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` (Yu-Gi-Oh! offline preview fallback); `app/CMakeLists.txt` copies it to `<exeDir>/assets/` when linking `ccm`.
|
||||
- `ui_wx/` — `ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` (offline preview fallbacks); `app/CMakeLists.txt` copies them to `<exeDir>/assets/` when linking `ccm`.
|
||||
- `app/` — `ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`.
|
||||
- `tests/` — `ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`.
|
||||
- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`.
|
||||
@@ -84,7 +84,7 @@ Run from the **workspace root**.
|
||||
- Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically.
|
||||
- `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically.
|
||||
- `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `<exeDir>/.cache/preview-cache/` — **next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path.
|
||||
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview.
|
||||
- `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.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
|
||||
|
||||
|
||||
@@ -9,21 +9,38 @@ Currently, the application supports the following TCGs:
|
||||
- Magic the Gathering
|
||||
- Pokemon TCG
|
||||
- Yu-Gi-Oh!
|
||||
- Digimon (Digi-Battle)
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Magic The Gathering
|
||||
<details open>
|
||||
<summary>Magic The Gathering</summary>
|
||||
|
||||

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

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

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

|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Migrating From CCM1 And CCM2
|
||||
|
||||
|
||||
+3
-3
@@ -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 `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` there so Yu-Gi-Oh! preview fallbacks work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`).
|
||||
- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` 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<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
|
||||
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `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<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`).
|
||||
- After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`.
|
||||
- After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list.
|
||||
|
||||
+6
-2
@@ -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 "$<TARGET_FILE_DIR:ccm>/assets"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png")
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png"
|
||||
"$<TARGET_FILE_DIR:ccm>/assets/digibattle99_card_back.png")
|
||||
|
||||
+25
-4
@@ -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<ccm::MagicGameModule>(*http_);
|
||||
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
|
||||
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
|
||||
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
|
||||
|
||||
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
@@ -87,6 +92,9 @@ public:
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
ygoRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhCard>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
digiBattle99Repo_ =
|
||||
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
|
||||
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
|
||||
|
||||
@@ -97,10 +105,14 @@ public:
|
||||
*pokeRepo_, *imgStore_);
|
||||
ygoCollSvc_ = std::make_unique<ccm::CollectionService<ccm::YuGiOhCard>>(
|
||||
*ygoRepo_, *imgStore_);
|
||||
digiBattle99CollSvc_ =
|
||||
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
|
||||
*digiBattle99Repo_, *imgStore_);
|
||||
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
|
||||
setSvc_->registerModule(magicMod_.get());
|
||||
setSvc_->registerModule(pokeMod_.get());
|
||||
setSvc_->registerModule(ygoMod_.get());
|
||||
setSvc_->registerModule(digiBattle99Mod_.get());
|
||||
|
||||
// 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<ccm::ui::MagicGameView>(
|
||||
@@ -128,6 +141,9 @@ public:
|
||||
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
|
||||
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
|
||||
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
|
||||
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
|
||||
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*digiBattle99Mod_);
|
||||
|
||||
ctx_ = std::make_unique<ccm::ui::AppContext>(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<ccm::MagicGameModule> magicMod_;
|
||||
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
|
||||
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
|
||||
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
|
||||
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
|
||||
std::unique_ptr<ccm::LocalImageStore> imgStore_;
|
||||
std::unique_ptr<ccm::ImageService> imgSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
|
||||
std::unique_ptr<ccm::SetService> setSvc_;
|
||||
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
|
||||
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
|
||||
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
|
||||
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
|
||||
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
|
||||
std::unique_ptr<ccm::ui::DigiBattle99GameView> digiBattle99View_;
|
||||
std::unique_ptr<ccm::ui::AppContext> ctx_;
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -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<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source.
|
||||
- `include/ccm/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<std::string>` is a binary buffer, not text, so callers can use it for image payloads directly.
|
||||
9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `<setNo>||<rarity>||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface.
|
||||
10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `<exeDir>/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `<hash>.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path.
|
||||
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
|
||||
11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99Card - Digimon Digi-Battle (1999 English) card model.
|
||||
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/Set.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99Card {
|
||||
std::uint32_t id{0};
|
||||
std::uint8_t amount{1};
|
||||
std::string name;
|
||||
Set set;
|
||||
std::string setNo;
|
||||
std::string note;
|
||||
std::vector<std::string> images;
|
||||
Language language{Language::English};
|
||||
Condition condition{Condition::NearMint};
|
||||
bool firstEdition{false};
|
||||
bool holo{false};
|
||||
bool signed_{false};
|
||||
bool altered{false};
|
||||
|
||||
friend bool operator==(const DigiBattle99Card&, const DigiBattle99Card&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99Card& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99Card& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -19,6 +19,7 @@ enum class Game {
|
||||
Magic,
|
||||
Pokemon,
|
||||
YuGiOh,
|
||||
DigiBattle99,
|
||||
};
|
||||
|
||||
enum class Language {
|
||||
@@ -57,7 +58,7 @@ std::optional<Language> languageFromString(std::string_view s) noexcept;
|
||||
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
|
||||
std::optional<Theme> themeFromString(std::string_view s) noexcept;
|
||||
|
||||
const std::array<Game, 3>& allGames() noexcept;
|
||||
const std::array<Game, 4>& allGames() noexcept;
|
||||
const std::array<Language, 8>& allLanguages() noexcept;
|
||||
const std::array<Condition, 7>& allConditions() noexcept;
|
||||
const std::array<Theme, 2>& allThemes() noexcept;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99CardPreviewSource: digimoncard.io search + CDN card images for
|
||||
// Digimon Digi-Battle (1999 English).
|
||||
//
|
||||
// Preview key middle slot is Set.name (pack display name) so search.php?pack=
|
||||
// works without a reverse slug map. When setNo is present, the CDN URL is
|
||||
// built directly — no search round-trip.
|
||||
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99CardPreviewSource final : public ICardPreviewSource {
|
||||
public:
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
static constexpr const char* kImageBase =
|
||||
"https://images.digimoncard.io/images/cards/";
|
||||
|
||||
explicit DigiBattle99CardPreviewSource(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) override;
|
||||
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
|
||||
std::string_view setName) override;
|
||||
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
|
||||
std::string_view setName) override;
|
||||
|
||||
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
|
||||
// Does not invent zero-padding — CDN keys match API ids literally.
|
||||
static std::string normalizeCardNumber(std::string_view setNo);
|
||||
|
||||
// CDN preview URL for a normalized card id (.jpg — wxImage registers
|
||||
// JPEG/PNG only; digimoncard.io also serves .webp but we cannot decode it).
|
||||
static std::string buildImageUrl(std::string_view setNo);
|
||||
|
||||
// digimoncard.io search URL: n= / pack= / series= / optional card=.
|
||||
// setName is the pack display name (Set.name), not the slug id.
|
||||
static std::string buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo);
|
||||
|
||||
// Parse a digimoncard.io search.php body into a CDN image URL for the
|
||||
// first exact name match (optional pack filter applied by the request).
|
||||
static Result<std::string, PreviewLookupError>
|
||||
parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
static Result<std::vector<AutoDetectedPrint>>
|
||||
parsePrintVariants(const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName);
|
||||
|
||||
private:
|
||||
IHttpClient& http_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99GameModule: Digimon Digi-Battle (1999 English) via digimoncard.io.
|
||||
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99GameModule final : public IGameModule {
|
||||
public:
|
||||
explicit DigiBattle99GameModule(IHttpClient& http);
|
||||
|
||||
[[nodiscard]] Game id() const noexcept override { return Game::DigiBattle99; }
|
||||
[[nodiscard]] std::string dirName() const override { return "digibattle99"; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
|
||||
|
||||
ISetSource& setSource() override { return setSource_; }
|
||||
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
|
||||
|
||||
private:
|
||||
DigiBattle99SetSource setSource_;
|
||||
DigiBattle99CardPreviewSource previewSource_;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,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 <string>
|
||||
#include <string_view>
|
||||
|
||||
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<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
|
||||
// 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
|
||||
@@ -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
|
||||
|
||||
@@ -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<MagicCard>& cards, MagicSortColumn column,
|
||||
@@ -74,5 +89,8 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
|
||||
bool ascending);
|
||||
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
bool ascending);
|
||||
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
DigiBattle99SortColumn column,
|
||||
bool ascending);
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -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
|
||||
@@ -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<Game> gameFromString(std::string_view s) noexcept {
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "Magic") return Game::Magic;
|
||||
if (s == "Pokemon") return Game::Pokemon;
|
||||
if (s == "YuGiOh") return Game::YuGiOh;
|
||||
if (s == "DigiBattle99") return Game::DigiBattle99;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -91,8 +93,9 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::array<Game, 3>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
|
||||
const std::array<Game, 4>& allGames() noexcept {
|
||||
static constexpr std::array<Game, 4> v{
|
||||
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99};
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include "ccm/util/Rfc3986.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string toLower(std::string s) {
|
||||
for (char& ch : s) {
|
||||
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool cardInPack(const nlohmann::json& card, std::string_view packName) {
|
||||
if (packName.empty()) return true;
|
||||
if (!card.contains("set_name") || !card.at("set_name").is_array()) return false;
|
||||
for (const auto& pack : card.at("set_name")) {
|
||||
if (pack.is_string() && pack.get<std::string>() == packName) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
|
||||
: http_(http) {}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::normalizeCardNumber(std::string_view setNo) {
|
||||
std::string s = trim(std::string(setNo));
|
||||
if (s.empty()) return s;
|
||||
// Uppercase leading alphabetic prefix (ST / BO / MO / Fx-style).
|
||||
std::size_t i = 0;
|
||||
while (i < s.size() && std::isalpha(static_cast<unsigned char>(s[i]))) {
|
||||
s[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(s[i])));
|
||||
++i;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildImageUrl(std::string_view setNo) {
|
||||
const std::string id = normalizeCardNumber(setNo);
|
||||
return std::string(kImageBase) + id + ".jpg";
|
||||
}
|
||||
|
||||
std::string DigiBattle99CardPreviewSource::buildSearchUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
std::string url = "https://digimoncard.io/api-public/search.php?series=";
|
||||
url += rfc3986PercentEncode(kSeries);
|
||||
if (!name.empty()) {
|
||||
url += "&n=";
|
||||
url += rfc3986PercentEncode(name);
|
||||
}
|
||||
if (!setName.empty()) {
|
||||
url += "&pack=";
|
||||
url += rfc3986PercentEncode(setName);
|
||||
}
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
url += "&card=";
|
||||
url += rfc3986PercentEncode(num);
|
||||
}
|
||||
url += "&sort=name&sortdirection=asc";
|
||||
return url;
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(const std::string& body,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err({K::NotFound, j.value("error", std::string{"No cards found."})});
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return R::err({K::Transient, "digimoncard.io Digi-Battle response is not a JSON array."});
|
||||
}
|
||||
if (j.empty()) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
|
||||
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
|
||||
const nlohmann::json* chosen = nullptr;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedLower) continue;
|
||||
}
|
||||
chosen = &card;
|
||||
break;
|
||||
}
|
||||
if (chosen == nullptr) {
|
||||
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
|
||||
}
|
||||
const std::string id = normalizeCardNumber(chosen->value("id", ""));
|
||||
if (id.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle card has no id / card number."});
|
||||
}
|
||||
return R::ok(buildImageUrl(id));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err({K::Transient,
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()});
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::string, PreviewLookupError>
|
||||
DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
|
||||
std::string_view setName,
|
||||
std::string_view setNo) {
|
||||
using R = Result<std::string, PreviewLookupError>;
|
||||
using K = PreviewLookupError::Kind;
|
||||
|
||||
const std::string num = normalizeCardNumber(setNo);
|
||||
if (!num.empty()) {
|
||||
return R::ok(buildImageUrl(num));
|
||||
}
|
||||
if (name.empty()) {
|
||||
return R::err({K::NotFound, "Digi-Battle preview requires a card name or set number."});
|
||||
}
|
||||
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (!resp) return R::err({K::Transient, resp.error()});
|
||||
return parseImageUrlFromSearch(resp.value(), name);
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
const std::string& body,
|
||||
std::string_view setName,
|
||||
std::string_view wantedCardName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return R::err(j.value("error", std::string{"No cards found."}));
|
||||
}
|
||||
if (!j.is_array() || j.empty()) {
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
const std::string wantedPack = trim(std::string(setName));
|
||||
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
|
||||
|
||||
std::vector<AutoDetectedPrint> collected;
|
||||
for (const auto& card : j) {
|
||||
if (!wantedNameLower.empty()) {
|
||||
const std::string cardName = trim(card.value("name", ""));
|
||||
if (toLower(cardName) != wantedNameLower) continue;
|
||||
}
|
||||
if (!cardInPack(card, wantedPack)) continue;
|
||||
AutoDetectedPrint out;
|
||||
out.setNo = normalizeCardNumber(card.value("id", ""));
|
||||
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
|
||||
if (out.setNo.empty()) continue;
|
||||
collected.push_back(std::move(out));
|
||||
}
|
||||
|
||||
if (collected.empty()) {
|
||||
if (!wantedNameLower.empty() && !wantedPack.empty()) {
|
||||
return R::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
|
||||
}
|
||||
|
||||
std::vector<AutoDetectedPrint> deduped;
|
||||
deduped.reserve(collected.size());
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(collected.size() * 2);
|
||||
for (auto& p : collected) {
|
||||
if (seen.insert(p.setNo).second) deduped.push_back(std::move(p));
|
||||
}
|
||||
return R::ok(std::move(deduped));
|
||||
} catch (const std::exception& e) {
|
||||
return R::err(std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectFirstPrint(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
auto list = detectPrintVariants(name, setName);
|
||||
if (!list || list.value().empty()) {
|
||||
if (!list) return Result<AutoDetectedPrint>::err(list.error());
|
||||
return Result<AutoDetectedPrint>::err("Could not auto-detect Digi-Battle set print metadata.");
|
||||
}
|
||||
return Result<AutoDetectedPrint>::ok(list.value().front());
|
||||
}
|
||||
|
||||
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrintVariants(
|
||||
std::string_view name,
|
||||
std::string_view setName) {
|
||||
using R = Result<std::vector<AutoDetectedPrint>>;
|
||||
const std::string url = buildSearchUrl(name, setName, "");
|
||||
auto resp = http_.get(url);
|
||||
if (resp) {
|
||||
return parsePrintVariants(resp.value(), setName, name);
|
||||
}
|
||||
// Retry name-only; still filter by pack in parsePrintVariants.
|
||||
const std::string fallbackUrl = buildSearchUrl(name, "", "");
|
||||
auto fallback = http_.get(fallbackUrl);
|
||||
if (!fallback) return R::err(fallback.error());
|
||||
return parsePrintVariants(fallback.value(), setName, name);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,8 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
DigiBattle99GameModule::DigiBattle99GameModule(IHttpClient& http)
|
||||
: setSource_(http), previewSource_(http) {}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,119 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
// Curated EN release dates for the vintage Digi-Battle product line.
|
||||
// Series 1 Starter is verified 1999-06-01; other entries use digimoncard.io /
|
||||
// checklist years (day unknown -> YYYY/01/01 or mid-year anchors for ordering).
|
||||
const std::unordered_map<std::string, std::string>& curatedReleaseDates() {
|
||||
static const std::unordered_map<std::string, std::string> kDates{
|
||||
{"Series 1 Starter Set", "1999/06/01"},
|
||||
{"Series 1 Booster Pack", "1999/06/01"},
|
||||
{"Series 2 Booster Pack", "1999/09/01"},
|
||||
{"Series 3 Booster Pack", "2000/01/01"},
|
||||
{"Series 4 Booster Pack", "2000/06/01"},
|
||||
{"Series 5 Booster Pack", "2000/10/01"},
|
||||
{"Series 6 Booster Pack", "2001/01/01"},
|
||||
{"Street Starter Set 1", "2001/01/01"},
|
||||
{"Street Starter Set 2", "2001/02/01"},
|
||||
{"Street Starter Set 3", "2001/03/01"},
|
||||
{"Street Starter Set 4", "2001/04/01"},
|
||||
{"Digimon The Movie Promo Cards", "2000/10/01"},
|
||||
};
|
||||
return kDates;
|
||||
}
|
||||
|
||||
std::string releaseDateForPack(const std::string& packName) {
|
||||
const auto& dates = curatedReleaseDates();
|
||||
const auto it = dates.find(packName);
|
||||
if (it != dates.end()) return it->second;
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
|
||||
|
||||
std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
|
||||
std::string out;
|
||||
out.reserve(packName.size());
|
||||
bool pendingHyphen = false;
|
||||
for (unsigned char ch : packName) {
|
||||
if (std::isalnum(ch)) {
|
||||
if (pendingHyphen && !out.empty()) out.push_back('-');
|
||||
pendingHyphen = false;
|
||||
out.push_back(static_cast<char>(std::tolower(ch)));
|
||||
} else {
|
||||
pendingHyphen = !out.empty();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
j.value("error", std::string{"digimoncard.io set search error"}));
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"digimoncard.io Digi-Battle response is not a JSON array.");
|
||||
}
|
||||
|
||||
// Preserve first-seen order of pack names, then sort by release date.
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<std::string> packNames;
|
||||
packNames.reserve(16);
|
||||
for (const auto& entry : j) {
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string name = pack.get<std::string>();
|
||||
if (name.empty()) continue;
|
||||
if (seen.insert(name).second) packNames.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Set> out;
|
||||
out.reserve(packNames.size());
|
||||
for (const auto& name : packNames) {
|
||||
Set s;
|
||||
s.id = slugifyPackName(name);
|
||||
s.name = name;
|
||||
s.releaseDate = releaseDateForPack(name);
|
||||
if (s.id.empty()) continue;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
|
||||
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
|
||||
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
|
||||
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -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
|
||||
|
||||
@@ -223,4 +223,74 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
|
||||
}
|
||||
}
|
||||
|
||||
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
|
||||
DigiBattle99SortColumn column,
|
||||
bool ascending) {
|
||||
switch (column) {
|
||||
case DigiBattle99SortColumn::Name:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.name) < asciiLower(b.name);
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::SetReleaseDate:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.set.releaseDate) <
|
||||
asciiLower(b.set.releaseDate);
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Language:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(to_string(a.language)) <
|
||||
asciiLower(to_string(b.language));
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Condition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(to_string(a.condition)) <
|
||||
asciiLower(to_string(b.condition));
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Amount:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.amount < b.amount;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Holo:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.holo < b.holo;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::FirstEdition:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.firstEdition < b.firstEdition;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Signed:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.signed_ < b.signed_;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Altered:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return a.altered < b.altered;
|
||||
}, ascending));
|
||||
break;
|
||||
case DigiBattle99SortColumn::Note:
|
||||
std::stable_sort(cards.begin(), cards.end(), directional(
|
||||
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
|
||||
return asciiLower(a.note) < asciiLower(b.note);
|
||||
}, ascending));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
+3
-3
@@ -11,13 +11,13 @@ Long-form contributor documentation that lives outside the source tree.
|
||||
- `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting.
|
||||
- `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs.
|
||||
- `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests.
|
||||
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP).
|
||||
- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, 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/`).
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ This folder contains contributor documentation for Card Collection Manager 3. St
|
||||
|
||||
- [adding-a-new-game.md](adding-a-new-game.md): canonical end-to-end procedure for adding a new game module across `core/`, `ui_wx/`, and `app/`.
|
||||
|
||||
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, and Yu-Gi-Oh! modules, preview fallback URLs / bundled YGO card-back asset, and shared HTTP behavior (`CprHttpClient`).
|
||||
- [assets-and-info-apis.md](assets-and-info-apis.md): external info and asset APIs used by Magic, Pokémon, Yu-Gi-Oh!, and Digimon Digi-Battle modules, preview fallback URLs / bundled card-back assets, and shared HTTP behavior (`CprHttpClient`).
|
||||
|
||||
## Performance & Caching
|
||||
|
||||
|
||||
@@ -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 `<exeDir>/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `<hash>.bin` (positive payload) or `<hash>.neg` (negative marker) plus a `<hash>.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path.
|
||||
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, and `ms.yugipedia.com` skip the TLS handshake. A `std::mutex` serializes callers — libcurl easy handles are not thread-safe, and the preview pipeline is single-flight per panel anyway.
|
||||
- **Persistent HTTP session** (`CprHttpClient`). The adapter owns one long-lived `cpr::Session` (libcurl easy handle) for the lifetime of the app. Per-request configuration is limited to `SetUrl(...)`; headers, timeout, and redirect policy are configured once in the constructor. Default **`Accept: */*`** keeps JSON responses and raw image bodies working on the same session (avoid tying every GET to `application/json`). libcurl's connection pool keeps the TLS connection to each host warm, so repeat calls to `api.scryfall.com`, `api.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 `<exeDir>/.cache/preview-cache/` subdirectory or the umbrella `<exeDir>/.cache/` folder. Note: the in-app "Reset" / data-storage-relocation flow does **not** touch this directory — the cache is install-scoped, not collection-scoped, so it is preserved across data-dir moves and only cleared by deleting the directory above explicitly (or by reinstalling / relocating the executable).
|
||||
|
||||
@@ -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 `<exeDir>/assets/ygo_card_back.png` (shipped from `ui_wx/assets/ygo_card_back.png` at link time). `fallbackImageUrlForGame(Game::YuGiOh)` returns the thumbnail URL for helpers that only consult a single string.
|
||||
- Digimon (Digi-Battle): no stable public back URL; load `<exeDir>/assets/digibattle99_card_back.png` (shipped from `ui_wx/assets/digibattle99_card_back.png` at link time).
|
||||
|
||||
If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available."
|
||||
|
||||
@@ -120,6 +158,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
|
||||
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
|
||||
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
|
||||
|
||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
|
||||
|
||||
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
+1
-1
@@ -68,7 +68,7 @@ There is no explicit "refresh" or "invalidate" API on `CardPreviewService` — b
|
||||
|
||||
### 1. Edit-driven invalidation (record changed → fresh lookup, automatic)
|
||||
|
||||
The cache key for the preview path is `(game, name, setId, setNo)`. For Yu-Gi-Oh! the third slot also encodes rarity and edition, packed by `YuGiOhSelectedCardPanel::previewKey()` as `<setNo>||<rarity>||<1E|UE>`. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means:
|
||||
The cache key for the preview path is `(game, name, setId, setNo)`. For Yu-Gi-Oh! the third slot also encodes rarity and edition, packed by `YuGiOhSelectedCardPanel::previewKey()` as `<setNo>||<rarity>||<1E|UE>`. For Digimon Digi-Battle the middle slot is the pack **display name** (`Set.name`), not the slug id, so `pack=` search and the CDN path stay aligned. The user editing **any** lookup-relevant field of a card record produces a **different cache key** for the resulting selection, which means:
|
||||
|
||||
- Memory and disk lookups for the new key **miss** the old entry (positive or negative).
|
||||
- A fresh `ICardPreviewSource::fetchImageUrl` call runs.
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#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<std::uint32_t> ids(const std::vector<MagicCard>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
@@ -115,6 +140,13 @@ std::vector<std::uint32_t> ids(const std::vector<YuGiOhCard>& v) {
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::uint32_t> ids(const std::vector<DigiBattle99Card>& v) {
|
||||
std::vector<std::uint32_t> out;
|
||||
out.reserve(v.size());
|
||||
for (const auto& c : v) out.push_back(c.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("CardSorter - Magic columns") {
|
||||
@@ -451,3 +483,35 @@ TEST_SUITE("CardSorter - YuGiOh columns") {
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("CardSorter - DigiBattle99 columns") {
|
||||
TEST_CASE("Holo and FirstEdition sort false before true") {
|
||||
std::vector<DigiBattle99Card> v = {
|
||||
db(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false),
|
||||
db(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true),
|
||||
db(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false),
|
||||
};
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::Holo, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 3, 1});
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::FirstEdition, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{3, 1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("Set column sorts by release date") {
|
||||
std::vector<DigiBattle99Card> v = {
|
||||
db(1, "x", "Late", "2001/01/01"),
|
||||
db(2, "y", "Early", "1999/06/01"),
|
||||
};
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::SetReleaseDate, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
|
||||
TEST_CASE("Name sorts case-insensitively") {
|
||||
std::vector<DigiBattle99Card> v = {
|
||||
db(1, "greymon", "X", "2000/01/01"),
|
||||
db(2, "Agumon", "X", "2000/01/01"),
|
||||
};
|
||||
sortDigiBattle99Cards(v, DigiBattle99SortColumn::Name, /*ascending=*/true);
|
||||
CHECK(ids(v) == std::vector<std::uint32_t>{2, 1});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::normalizeCardNumber") {
|
||||
TEST_CASE("uppercases alphabetic prefix without zero-padding") {
|
||||
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber("bo-88") == "BO-88");
|
||||
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber("st-01") == "ST-01");
|
||||
CHECK(DigiBattle99CardPreviewSource::normalizeCardNumber(" MO-06 ") == "MO-06");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::buildImageUrl") {
|
||||
TEST_CASE("builds CDN jpeg URL from card id") {
|
||||
CHECK(DigiBattle99CardPreviewSource::buildImageUrl("ST-01") ==
|
||||
"https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
CHECK(DigiBattle99CardPreviewSource::buildImageUrl("bo-115") ==
|
||||
"https://images.digimoncard.io/images/cards/BO-115.jpg");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::buildSearchUrl") {
|
||||
TEST_CASE("percent-encodes name pack and series") {
|
||||
const auto url = DigiBattle99CardPreviewSource::buildSearchUrl(
|
||||
"Agumon", "Series 1 Starter Set", "");
|
||||
CHECK(url.find("https://digimoncard.io/api-public/search.php?series=") == 0);
|
||||
CHECK(url.find("Digimon%20Digi-Battle%20Card%20Game") != std::string::npos);
|
||||
CHECK(url.find("&n=Agumon") != std::string::npos);
|
||||
CHECK(url.find("&pack=Series%201%20Starter%20Set") != std::string::npos);
|
||||
CHECK(url.find("&card=") == std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("includes card= when setNo is present") {
|
||||
const auto url = DigiBattle99CardPreviewSource::buildSearchUrl(
|
||||
"Agumon", "Series 1 Starter Set", "st-01");
|
||||
CHECK(url.find("&card=ST-01") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("empty pack omits pack clause") {
|
||||
const auto url =
|
||||
DigiBattle99CardPreviewSource::buildSearchUrl("Agumon", "", "ST-01");
|
||||
CHECK(url.find("&pack=") == std::string::npos);
|
||||
CHECK(url.find("&card=ST-01") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::parseImageUrlFromSearch") {
|
||||
TEST_CASE("returns CDN URL for first exact name match") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]}
|
||||
])";
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(json, "Agumon");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
}
|
||||
|
||||
TEST_CASE("empty array is NotFound") {
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch("[]", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("API error object is NotFound") {
|
||||
const auto out = DigiBattle99CardPreviewSource::parseImageUrlFromSearch(
|
||||
R"({"error":"No cards found for this search."})", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("non-array is Transient") {
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(R"({"meta":{}})", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
|
||||
TEST_CASE("malformed JSON is Transient") {
|
||||
const auto out =
|
||||
DigiBattle99CardPreviewSource::parseImageUrlFromSearch("{not json", "Agumon");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::fetchImageUrl") {
|
||||
TEST_CASE("setNo present skips HTTP and returns CDN URL") {
|
||||
FixedHttpClient http;
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "st-01");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
CHECK(http.lastUrl.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("empty setNo searches and parses") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"([{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}])";
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value() == "https://images.digimoncard.io/images/cards/ST-01.jpg");
|
||||
CHECK(http.lastUrl.find("search.php") != std::string::npos);
|
||||
CHECK(http.lastUrl.find("n=Agumon") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("HTTP failure is Transient") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.fetchImageUrl("Agumon", "Series 1 Starter Set", "");
|
||||
REQUIRE(out.isErr());
|
||||
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::parsePrintVariants") {
|
||||
TEST_CASE("collects distinct card ids for name+pack") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]},
|
||||
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
const auto out = DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
json, "Series 1 Starter Set", "Agumon");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 1);
|
||||
CHECK(out.value()[0].setNo == "ST-01");
|
||||
}
|
||||
|
||||
TEST_CASE("pack miss with exact name returns error") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"BO-115","set_name":["Series 1 Booster Pack"]}
|
||||
])";
|
||||
const auto out = DigiBattle99CardPreviewSource::parsePrintVariants(
|
||||
json, "Series 1 Starter Set", "Agumon");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99CardPreviewSource::detectPrintVariants") {
|
||||
TEST_CASE("round-trips through FixedHttpClient") {
|
||||
FixedHttpClient http;
|
||||
http.body = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon","id":"ST-126","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
DigiBattle99CardPreviewSource src{http};
|
||||
const auto out = src.detectPrintVariants("Agumon", "Series 1 Starter Set");
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 2);
|
||||
CHECK(out.value()[0].setNo == "ST-01");
|
||||
CHECK(out.value()[1].setNo == "ST-126");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
using namespace ccm;
|
||||
|
||||
namespace {
|
||||
|
||||
class FixedHttpClient final : public IHttpClient {
|
||||
public:
|
||||
std::string lastUrl;
|
||||
std::string body;
|
||||
bool ok = true;
|
||||
Result<std::string> get(std::string_view url) override {
|
||||
lastUrl = std::string(url);
|
||||
return ok ? Result<std::string>::ok(body)
|
||||
: Result<std::string>::err("offline");
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::slugifyPackName") {
|
||||
TEST_CASE("slugifies pack display names") {
|
||||
CHECK(DigiBattle99SetSource::slugifyPackName("Series 1 Starter Set") ==
|
||||
"series-1-starter-set");
|
||||
CHECK(DigiBattle99SetSource::slugifyPackName("Digimon The Movie Promo Cards") ==
|
||||
"digimon-the-movie-promo-cards");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::parseResponse") {
|
||||
TEST_CASE("derives unique packs with curated release dates") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]},
|
||||
{"name":"Agumon","id":"ST-126","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Promo","id":"MO-06","set_name":["Digimon The Movie Promo Cards"]}
|
||||
])";
|
||||
|
||||
const auto out = DigiBattle99SetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 3);
|
||||
// Same curated date for Series 1 products → secondary sort by name:
|
||||
// "Booster" before "Starter".
|
||||
CHECK(out.value()[0].id == "series-1-booster-pack");
|
||||
CHECK(out.value()[0].name == "Series 1 Booster Pack");
|
||||
CHECK(out.value()[0].releaseDate == "1999/06/01");
|
||||
CHECK(out.value()[1].id == "series-1-starter-set");
|
||||
CHECK(out.value()[1].name == "Series 1 Starter Set");
|
||||
CHECK(out.value()[1].releaseDate == "1999/06/01");
|
||||
CHECK(out.value()[2].id == "digimon-the-movie-promo-cards");
|
||||
CHECK(out.value()[2].releaseDate == "2000/10/01");
|
||||
}
|
||||
|
||||
TEST_CASE("sorts by release date then name") {
|
||||
const std::string json = R"([
|
||||
{"name":"A","id":"ST-1","set_name":["Street Starter Set 2"]},
|
||||
{"name":"B","id":"ST-2","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"C","id":"ST-3","set_name":["Street Starter Set 1"]}
|
||||
])";
|
||||
const auto out = DigiBattle99SetSource::parseResponse(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().size() == 3);
|
||||
CHECK(out.value()[0].name == "Series 1 Starter Set");
|
||||
CHECK(out.value()[1].name == "Street Starter Set 1");
|
||||
CHECK(out.value()[2].name == "Street Starter Set 2");
|
||||
}
|
||||
|
||||
TEST_CASE("empty array returns an empty list") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse("[]");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("non-array object with error is an error") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse(
|
||||
R"({"error":"No cards found for this search."})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("missing top-level array returns an error") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse(R"({"meta":{}})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("invalid JSON returns an error") {
|
||||
const auto out = DigiBattle99SetSource::parseResponse("{not json");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::fetchAll") {
|
||||
TEST_CASE("network error is surfaced as a Result error") {
|
||||
FixedHttpClient http;
|
||||
http.ok = false;
|
||||
DigiBattle99SetSource src{http};
|
||||
CHECK(src.fetchAll().isErr());
|
||||
}
|
||||
|
||||
TEST_CASE("network success hits the Digi-Battle search endpoint") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"([{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}])";
|
||||
DigiBattle99SetSource src{http};
|
||||
const auto out = src.fetchAll();
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().front().id == "series-1-starter-set");
|
||||
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#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>() == Game::YuGiOh);
|
||||
|
||||
nlohmann::json jDigi = "DigiBattle99";
|
||||
CHECK(jDigi.get<Game>() == Game::DigiBattle99);
|
||||
|
||||
nlohmann::json j3 = Theme::Dark;
|
||||
CHECK(j3.get<std::string>() == "Dark");
|
||||
CHECK(j3.get<Theme>() == 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<DigiBattle99Card>();
|
||||
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<DigiBattle99Card>());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Configuration missing required key throws") {
|
||||
const nlohmann::json j = {
|
||||
{"defaultGame", "Magic"},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
|
||||
#include "ccm/games/magic/MagicGameModule.hpp"
|
||||
#include "ccm/games/pokemon/PokemonGameModule.hpp"
|
||||
#include "ccm/games/yugioh/YuGiOhGameModule.hpp"
|
||||
@@ -51,4 +52,15 @@ TEST_SUITE("game modules expose stable identity and wiring") {
|
||||
CHECK(module.cardPreviewSource() != nullptr);
|
||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||
}
|
||||
|
||||
TEST_CASE("DigiBattle99 module reports canonical metadata") {
|
||||
NoopHttpClient http;
|
||||
DigiBattle99GameModule module(http);
|
||||
|
||||
CHECK(module.id() == Game::DigiBattle99);
|
||||
CHECK(module.dirName() == "digibattle99");
|
||||
CHECK(module.displayName() == "Digimon (Digi-Battle)");
|
||||
CHECK(module.cardPreviewSource() != nullptr);
|
||||
CHECK(static_cast<void*>(&module.setSource()) != static_cast<void*>(module.cardPreviewSource()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<std::vector<Set>>::ok({{"lea", "Alpha", "1993/08/05"}});
|
||||
FakeGameModule pokemon{Game::Pokemon};
|
||||
pokemon.source.result = Result<std::vector<Set>>::ok({{"base1", "Base", "1999/01/09"}});
|
||||
FakeGameModule yugioh{Game::YuGiOh};
|
||||
yugioh.source.result = Result<std::vector<Set>>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}});
|
||||
FakeGameModule digi{Game::DigiBattle99};
|
||||
digi.source.result = Result<std::vector<Set>>::ok(
|
||||
{{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"}});
|
||||
|
||||
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;
|
||||
|
||||
+3
-3
@@ -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<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
|
||||
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
|
||||
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon 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 `<exeDir>/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 `<exeDir>/` 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<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon 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 `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant.
|
||||
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` 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 `<exeDir>/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 `<exeDir>/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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 160 B |
@@ -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.
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 <wx/button.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class DigiBattle99CardEditDialog final : public BaseCardEditDialog<DigiBattle99Card> {
|
||||
public:
|
||||
DigiBattle99CardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
CardPreviewService& cardPreview,
|
||||
EditMode mode,
|
||||
DigiBattle99Card initial,
|
||||
const std::vector<Set>* 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<bool> 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<std::vector<AutoDetectedPrint>> 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> variantFetchState_;
|
||||
wxTextCtrl* setNoCtrl_{nullptr};
|
||||
wxButton* autoSetNoBtn_{nullptr};
|
||||
wxButton* nextSetNoBtn_{nullptr};
|
||||
wxCheckBox* holoCheck_{nullptr};
|
||||
wxCheckBox* firstEditionCheck_{nullptr};
|
||||
wxCheckBox* signedCheck_{nullptr};
|
||||
wxCheckBox* alteredCheck_{nullptr};
|
||||
|
||||
std::vector<AutoDetectedPrint> cachedVariants_;
|
||||
std::vector<std::string> uniqueSetNos_;
|
||||
std::size_t setNoRingPos_{0};
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -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<DigiBattle99Card, DigiBattle99SortColumn> {
|
||||
public:
|
||||
explicit DigiBattle99CardListPanel(wxWindow* parent);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
|
||||
[[nodiscard]] std::vector<IconColumnSpec> 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
|
||||
@@ -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 <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class DigiBattle99CardListPanel;
|
||||
class DigiBattle99SelectedCardPanel;
|
||||
|
||||
class DigiBattle99GameView final : public IGameView {
|
||||
public:
|
||||
DigiBattle99GameView(ConfigService& config,
|
||||
CollectionService<DigiBattle99Card>& 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<Set>& setsForDialog();
|
||||
|
||||
ConfigService& config_;
|
||||
CollectionService<DigiBattle99Card>& collection_;
|
||||
SetService& sets_;
|
||||
ImageService& images_;
|
||||
CardPreviewService& cardPreview_;
|
||||
IGameModule& module_;
|
||||
|
||||
DigiBattle99CardListPanel* listPanel_{nullptr};
|
||||
DigiBattle99SelectedCardPanel* selectedPanel_{nullptr};
|
||||
std::vector<Set> setsCache_;
|
||||
bool attemptedInitialSetLoad_{false};
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/ui/BaseSelectedCardPanel.hpp"
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class DigiBattle99SelectedCardPanel final : public BaseSelectedCardPanel<DigiBattle99Card> {
|
||||
public:
|
||||
DigiBattle99SelectedCardPanel(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
CardPreviewService& cardPreview);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] std::vector<DetailRowSpec> declareDetailRows() const override;
|
||||
[[nodiscard]] std::vector<FlagIconSpec> 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<std::string, std::string, std::string>
|
||||
previewKey(const DigiBattle99Card& card) const override;
|
||||
[[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; }
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,255 @@
|
||||
#include "ccm/ui/DigiBattle99CardEditDialog.hpp"
|
||||
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
#include <wx/app.h>
|
||||
#include <wx/panel.h>
|
||||
#include <thread>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
DigiBattle99CardEditDialog::DigiBattle99CardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
CardPreviewService& cardPreview,
|
||||
EditMode mode,
|
||||
DigiBattle99Card initial,
|
||||
const std::vector<Set>* preloadedSets)
|
||||
: BaseCardEditDialog<DigiBattle99Card>(
|
||||
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<VariantFetchState>()) {
|
||||
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<std::vector<AutoDetectedPrint>> 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<std::string> 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
|
||||
@@ -0,0 +1,71 @@
|
||||
#include "ccm/ui/DigiBattle99CardListPanel.hpp"
|
||||
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
DigiBattle99CardListPanel::DigiBattle99CardListPanel(wxWindow* parent)
|
||||
: BaseCardListPanel<DigiBattle99Card, DigiBattle99SortColumn>(parent) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99CardListPanel::TextColumnSpec>
|
||||
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::IconColumnSpec>
|
||||
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
|
||||
@@ -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 <wx/msgdlg.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
DigiBattle99GameView::DigiBattle99GameView(ConfigService& config,
|
||||
CollectionService<DigiBattle99Card>& 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<wxWindow*>(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<Set>& 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
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp"
|
||||
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
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<DigiBattle99Card>(parent, imageService, cardPreview) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99SelectedCardPanel::DetailRowSpec>
|
||||
DigiBattle99SelectedCardPanel::declareDetailRows() const {
|
||||
return {
|
||||
{"Name", kName, "(no card selected)"},
|
||||
{"Set", kSet, ""},
|
||||
{"Set #", kSetNo, ""},
|
||||
{"Language", kLanguage, ""},
|
||||
{"Condition", kCondition, ""},
|
||||
{"Amount", kAmount, ""},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99SelectedCardPanel::FlagIconSpec>
|
||||
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<std::string, std::string, std::string>
|
||||
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
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "ccm/ui/SettingsDialog.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
|
||||
#include <wx/button.h>
|
||||
#include <wx/dirdlg.h>
|
||||
@@ -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<std::size_t>(gameSel) < games.size()) {
|
||||
next.defaultGame = games[static_cast<std::size_t>(gameSel)];
|
||||
}
|
||||
switch (themeChoice_->GetSelection()) {
|
||||
case 1: next.theme = Theme::Dark; break;
|
||||
case 0:
|
||||
|
||||
Reference in New Issue
Block a user