From 8a89e79e43b86b465135c646de3066909e1a6de3 Mon Sep 17 00:00:00 2001 From: sdine Date: Wed, 22 Jul 2026 20:03:54 +0200 Subject: [PATCH] start of set completion tracking --- app/AGENTS.md | 2 +- app/main.cpp | 6 +- core/AGENTS.md | 6 +- core/CMakeLists.txt | 3 + .../ccm/domain/DigiBattle99SetCatalog.hpp | 52 ++++ .../digibattle99/DigiBattle99SetSource.hpp | 14 +- .../DigiBattle99SetCatalogService.hpp | 36 +++ .../services/DigiBattle99SetCompletion.hpp | 48 ++++ core/include/ccm/services/SetService.hpp | 4 + core/src/domain/DigiBattle99SetCatalog.cpp | 40 +++ .../digibattle99/DigiBattle99SetSource.cpp | 181 ++++++++++---- .../DigiBattle99SetCatalogService.cpp | 50 ++++ .../services/DigiBattle99SetCompletion.cpp | 98 ++++++++ core/src/services/SetService.cpp | 4 + docs/assets-and-info-apis.md | 13 + tests/AGENTS.md | 3 +- tests/CMakeLists.txt | 1 + tests/digibattle99_set_completion_tests.cpp | 164 +++++++++++++ tests/digibattle99_set_source_tests.cpp | 71 ++++++ tests/domain_json_tests.cpp | 23 ++ ui_wx/AGENTS.md | 7 +- ui_wx/CMakeLists.txt | 1 + ui_wx/include/ccm/ui/DigiBattle99GameView.hpp | 27 ++- .../ccm/ui/DigiBattle99SetCompletionPanel.hpp | 59 +++++ ui_wx/include/ccm/ui/IGameView.hpp | 18 ++ ui_wx/include/ccm/ui/MainFrame.hpp | 2 + ui_wx/src/DigiBattle99GameView.cpp | 196 ++++++++++++++- ui_wx/src/DigiBattle99SetCompletionPanel.cpp | 228 ++++++++++++++++++ ui_wx/src/MainFrame.cpp | 58 ++++- 29 files changed, 1337 insertions(+), 78 deletions(-) create mode 100644 core/include/ccm/domain/DigiBattle99SetCatalog.hpp create mode 100644 core/include/ccm/services/DigiBattle99SetCatalogService.hpp create mode 100644 core/include/ccm/services/DigiBattle99SetCompletion.hpp create mode 100644 core/src/domain/DigiBattle99SetCatalog.cpp create mode 100644 core/src/services/DigiBattle99SetCatalogService.cpp create mode 100644 core/src/services/DigiBattle99SetCompletion.cpp create mode 100644 tests/digibattle99_set_completion_tests.cpp create mode 100644 ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp create mode 100644 ui_wx/src/DigiBattle99SetCompletionPanel.cpp diff --git a/app/AGENTS.md b/app/AGENTS.md index f9e1da4..c4175af 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -9,7 +9,7 @@ The `ccm` executable — composition root only. The single place where concrete ## Conventions -1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. +1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `DigiBattle99SetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. 2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually. 3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set). 4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`). diff --git a/app/main.cpp b/app/main.cpp index bfbfb23..8af37d6 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -21,6 +21,7 @@ #include "ccm/services/CardPreviewService.hpp" #include "ccm/services/CollectionService.hpp" #include "ccm/services/ConfigService.hpp" +#include "ccm/services/DigiBattle99SetCatalogService.hpp" #include "ccm/services/ImageService.hpp" #include "ccm/services/SetService.hpp" #include "ccm/ui/AppContext.hpp" @@ -110,6 +111,8 @@ public: std::make_unique>( *fs_, *config_, &dirNameForGame); setRepo_ = std::make_unique(*fs_, *config_, &dirNameForGame); + digiBattle99CatalogStore_ = + std::make_unique(*fs_, *config_, &dirNameForGame); imgStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); imgSvc_ = std::make_unique(*imgStore_); @@ -163,7 +166,7 @@ public: *config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_); digiBattle99View_ = std::make_unique( *config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, - *digiBattle99Mod_); + *digiBattle99Mod_, *digiBattle99CatalogStore_); ctx_ = std::make_unique(ccm::ui::AppContext{ *config_, @@ -203,6 +206,7 @@ private: std::unique_ptr> ygoRepo_; std::unique_ptr> digiBattle99Repo_; std::unique_ptr setRepo_; + std::unique_ptr digiBattle99CatalogStore_; std::unique_ptr imgStore_; std::unique_ptr imgSvc_; std::unique_ptr> magicCollSvc_; diff --git a/core/AGENTS.md b/core/AGENTS.md index c2a9e7d..57d886d 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -4,11 +4,11 @@ ## Layer pointers -- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. +- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. - `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns. -- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. -- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect. +- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (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), `DigiBattle99SetCompletion` (pure Digi-Battle set-completion / checklist helpers), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`). They depend only on ports / domain. +- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `DigiBattle99SetSource` also exposes `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect. - `include/ccm/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. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index fc99e45..216ec32 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(ccm_core STATIC src/domain/PokemonCard.cpp src/domain/YuGiOhCard.cpp src/domain/DigiBattle99Card.cpp + src/domain/DigiBattle99SetCatalog.cpp src/domain/JapanesePokemonCard.cpp src/domain/Configuration.cpp @@ -17,6 +18,8 @@ add_library(ccm_core STATIC src/services/CardPreviewService.cpp src/services/CardSorter.cpp src/services/CardFilter.cpp + src/services/DigiBattle99SetCompletion.cpp + src/services/DigiBattle99SetCatalogService.cpp src/infra/CprHttpClient.cpp src/infra/StdFileSystem.cpp diff --git a/core/include/ccm/domain/DigiBattle99SetCatalog.hpp b/core/include/ccm/domain/DigiBattle99SetCatalog.hpp new file mode 100644 index 0000000..391b9c5 --- /dev/null +++ b/core/include/ccm/domain/DigiBattle99SetCatalog.hpp @@ -0,0 +1,52 @@ +#pragma once + +// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set +// completion. Filled from digimoncard.io bulk search.php (same payload as the +// set list) and persisted at `/digibattle99/set-catalog.json`. + +#include + +#include +#include +#include +#include + +namespace ccm { + +struct DigiBattle99CatalogCard { + std::string setNo; + std::string name; + + friend bool operator==(const DigiBattle99CatalogCard&, + const DigiBattle99CatalogCard&) = default; +}; + +struct DigiBattle99SetCatalogPack { + std::string setId; + std::string setName; + std::vector cards; + + friend bool operator==(const DigiBattle99SetCatalogPack&, + const DigiBattle99SetCatalogPack&) = default; +}; + +struct DigiBattle99SetCatalog { + std::vector packs; + + [[nodiscard]] const DigiBattle99SetCatalogPack* findPack( + std::string_view setId) const; + + [[nodiscard]] bool empty() const noexcept { return packs.empty(); } + + friend bool operator==(const DigiBattle99SetCatalog&, + const DigiBattle99SetCatalog&) = default; +}; + +void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c); +void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c); +void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p); +void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p); +void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c); +void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c); + +} // namespace ccm diff --git a/core/include/ccm/games/digibattle99/DigiBattle99SetSource.hpp b/core/include/ccm/games/digibattle99/DigiBattle99SetSource.hpp index 831864d..1bb9949 100644 --- a/core/include/ccm/games/digibattle99/DigiBattle99SetSource.hpp +++ b/core/include/ccm/games/digibattle99/DigiBattle99SetSource.hpp @@ -3,12 +3,15 @@ // DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English). // digimoncard.io has no dedicated sets endpoint; we derive unique pack names // from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game. +// The same payload also builds the set-completion catalog (parseCatalog). +#include "ccm/domain/DigiBattle99SetCatalog.hpp" #include "ccm/games/IGameModule.hpp" #include "ccm/ports/IHttpClient.hpp" #include #include +#include namespace ccm { @@ -20,12 +23,21 @@ public: static constexpr const char* kSeries = "Digimon Digi-Battle Card Game"; + struct FetchWithCatalog { + std::vector sets; + DigiBattle99SetCatalog catalog; + }; + explicit DigiBattle99SetSource(IHttpClient& http); Result> fetchAll() override; - // Pure parser exposed for unit testing without a network round-trip. + // One HTTP round-trip producing both the set list and the pack catalog. + Result fetchAllWithCatalog(); + + // Pure parsers exposed for unit testing without a network round-trip. static Result> parseResponse(const std::string& body); + static Result parseCatalog(const std::string& body); // Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-'). static std::string slugifyPackName(std::string_view packName); diff --git a/core/include/ccm/services/DigiBattle99SetCatalogService.hpp b/core/include/ccm/services/DigiBattle99SetCatalogService.hpp new file mode 100644 index 0000000..d0e0906 --- /dev/null +++ b/core/include/ccm/services/DigiBattle99SetCatalogService.hpp @@ -0,0 +1,36 @@ +#pragma once + +// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under +// the configured dataStorage path. + +#include "ccm/domain/DigiBattle99SetCatalog.hpp" +#include "ccm/domain/Enums.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class DigiBattle99SetCatalogService { +public: + using DirNameFn = std::function; + + DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName); + + Result load() const; + Result save(const DigiBattle99SetCatalog& catalog); + + [[nodiscard]] bool exists() const; + +private: + IFileSystem& fs_; + ConfigService& config_; + DirNameFn dirName_; + + [[nodiscard]] std::filesystem::path catalogPath() const; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/DigiBattle99SetCompletion.hpp b/core/include/ccm/services/DigiBattle99SetCompletion.hpp new file mode 100644 index 0000000..ca1abe8 --- /dev/null +++ b/core/include/ccm/services/DigiBattle99SetCompletion.hpp @@ -0,0 +1,48 @@ +#pragma once + +// Pure helpers: Digi-Battle set-completion progress and per-set checklists. +// Ownership counts only when collection card.set.id matches the pack and the +// normalized setNo appears in that pack's catalog. Duplicates / amount do not +// inflate the numerator. + +#include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/DigiBattle99SetCatalog.hpp" + +#include +#include +#include +#include + +namespace ccm { + +struct DigiBattle99SetCompletionProgress { + std::string setId; + std::string setName; + std::size_t ownedUnique{0}; + std::size_t total{0}; + + [[nodiscard]] int percent() const noexcept { + if (total == 0) return 0; + return static_cast((ownedUnique * 100) / total); + } +}; + +struct DigiBattle99ChecklistEntry { + std::string setNo; + std::string name; + bool owned{false}; +}; + +// Packs where the collection owns ≥1 card with matching set.id, ordered by +// setName. Packs absent from the catalog are skipped. +[[nodiscard]] std::vector +computeDigiBattle99SetCompletion(const std::vector& collection, + const DigiBattle99SetCatalog& catalog); + +// Full catalog checklist for one pack; owned flags from the collection. +[[nodiscard]] std::vector +digiBattle99ChecklistForSet(const std::vector& collection, + const DigiBattle99SetCatalog& catalog, + std::string_view setId); + +} // namespace ccm diff --git a/core/include/ccm/services/SetService.hpp b/core/include/ccm/services/SetService.hpp index 80d4772..f3eb8e3 100644 --- a/core/include/ccm/services/SetService.hpp +++ b/core/include/ccm/services/SetService.hpp @@ -27,6 +27,10 @@ public: // repository, and return the new list. Result> updateSets(Game game); + // Persist an already-fetched set list (no HTTP). Used when a game-specific + // Update Sets path fetches sets + side payloads in one round-trip. + Result saveSets(Game game, const std::vector& sets); + // Cached read; returns an error if no local data exists yet. Result> getSets(Game game); diff --git a/core/src/domain/DigiBattle99SetCatalog.cpp b/core/src/domain/DigiBattle99SetCatalog.cpp new file mode 100644 index 0000000..ca3045d --- /dev/null +++ b/core/src/domain/DigiBattle99SetCatalog.cpp @@ -0,0 +1,40 @@ +#include "ccm/domain/DigiBattle99SetCatalog.hpp" + +namespace ccm { + +const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack( + std::string_view setId) const { + for (const auto& pack : packs) { + if (pack.setId == setId) return &pack; + } + return nullptr; +} + +void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) { + j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}}; +} + +void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) { + j.at("setNo").get_to(c.setNo); + j.at("name").get_to(c.name); +} + +void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) { + j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}}; +} + +void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) { + j.at("id").get_to(p.setId); + j.at("name").get_to(p.setName); + j.at("cards").get_to(p.cards); +} + +void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) { + j = nlohmann::json{{"packs", c.packs}}; +} + +void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) { + j.at("packs").get_to(c.packs); +} + +} // namespace ccm diff --git a/core/src/games/digibattle99/DigiBattle99SetSource.cpp b/core/src/games/digibattle99/DigiBattle99SetSource.cpp index c02b7d3..fe1f2e0 100644 --- a/core/src/games/digibattle99/DigiBattle99SetSource.cpp +++ b/core/src/games/digibattle99/DigiBattle99SetSource.cpp @@ -1,5 +1,7 @@ #include "ccm/games/digibattle99/DigiBattle99SetSource.hpp" +#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp" + #include #include @@ -40,6 +42,24 @@ std::string releaseDateForPack(const std::string& packName) { return {}; } +Result parseSearchArray(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (j.is_object() && j.contains("error")) { + return Result::err( + j.value("error", std::string{"digimoncard.io set search error"})); + } + if (!j.is_array()) { + return Result::err( + "digimoncard.io Digi-Battle response is not a JSON array."); + } + return Result::ok(j); + } catch (const std::exception& e) { + return Result::err( + std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()); + } +} + } // namespace DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {} @@ -61,59 +81,126 @@ std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) { } Result> DigiBattle99SetSource::parseResponse(const std::string& body) { - try { - const auto j = nlohmann::json::parse(body); - if (j.is_object() && j.contains("error")) { - return Result>::err( - j.value("error", std::string{"digimoncard.io set search error"})); - } - if (!j.is_array()) { - return Result>::err( - "digimoncard.io Digi-Battle response is not a JSON array."); - } + auto arr = parseSearchArray(body); + if (!arr) return Result>::err(arr.error()); - // Preserve first-seen order of pack names, then sort by release date. - std::unordered_set seen; - std::vector packNames; - packNames.reserve(16); - for (const auto& entry : j) { - if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue; - for (const auto& pack : entry.at("set_name")) { - if (!pack.is_string()) continue; - const std::string name = pack.get(); - if (name.empty()) continue; - if (seen.insert(name).second) packNames.push_back(name); - } + // Preserve first-seen order of pack names, then sort by release date. + std::unordered_set seen; + std::vector packNames; + packNames.reserve(16); + for (const auto& entry : arr.value()) { + if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue; + for (const auto& pack : entry.at("set_name")) { + if (!pack.is_string()) continue; + const std::string name = pack.get(); + if (name.empty()) continue; + if (seen.insert(name).second) packNames.push_back(name); } - - std::vector out; - out.reserve(packNames.size()); - for (const auto& name : packNames) { - Set s; - s.id = slugifyPackName(name); - s.name = name; - s.releaseDate = releaseDateForPack(name); - if (s.id.empty()) continue; - out.push_back(std::move(s)); - } - - std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) { - if (a.releaseDate.empty() && !b.releaseDate.empty()) return false; - if (!a.releaseDate.empty() && b.releaseDate.empty()) return true; - if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate; - return a.name < b.name; - }); - return Result>::ok(std::move(out)); - } catch (const std::exception& e) { - return Result>::err( - std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()); } + + std::vector out; + out.reserve(packNames.size()); + for (const auto& name : packNames) { + Set s; + s.id = slugifyPackName(name); + s.name = name; + s.releaseDate = releaseDateForPack(name); + if (s.id.empty()) continue; + out.push_back(std::move(s)); + } + + std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) { + if (a.releaseDate.empty() && !b.releaseDate.empty()) return false; + if (!a.releaseDate.empty() && b.releaseDate.empty()) return true; + if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate; + return a.name < b.name; + }); + return Result>::ok(std::move(out)); +} + +Result DigiBattle99SetSource::parseCatalog(const std::string& body) { + auto arr = parseSearchArray(body); + if (!arr) return Result::err(arr.error()); + + // pack display name -> (setId, ordered unique cards by first-seen setNo) + struct PackBuild { + std::string setId; + std::string setName; + std::unordered_set seenNos; + std::vector cards; + }; + std::unordered_map byName; + + for (const auto& entry : arr.value()) { + if (!entry.contains("name") || !entry.at("name").is_string()) continue; + if (!entry.contains("id") || !entry.at("id").is_string()) continue; + if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue; + + DigiBattle99CatalogCard card; + card.name = entry.at("name").get(); + card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber( + entry.at("id").get()); + if (card.setNo.empty()) continue; + + for (const auto& pack : entry.at("set_name")) { + if (!pack.is_string()) continue; + const std::string packName = pack.get(); + if (packName.empty()) continue; + + auto& build = byName[packName]; + if (build.setName.empty()) { + build.setName = packName; + build.setId = slugifyPackName(packName); + } + if (build.setId.empty()) continue; + if (!build.seenNos.insert(card.setNo).second) continue; + build.cards.push_back(card); + } + } + + DigiBattle99SetCatalog catalog; + catalog.packs.reserve(byName.size()); + for (auto& [_, build] : byName) { + if (build.setId.empty()) continue; + std::sort(build.cards.begin(), build.cards.end(), + [](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + DigiBattle99SetCatalogPack pack; + pack.setId = std::move(build.setId); + pack.setName = std::move(build.setName); + pack.cards = std::move(build.cards); + catalog.packs.push_back(std::move(pack)); + } + + std::sort(catalog.packs.begin(), catalog.packs.end(), + [](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) { + return a.setName < b.setName; + }); + return Result::ok(std::move(catalog)); +} + +Result +DigiBattle99SetSource::fetchAllWithCatalog() { + auto resp = http_.get(kEndpoint); + if (!resp) return Result::err(resp.error()); + + auto sets = parseResponse(resp.value()); + if (!sets) return Result::err(sets.error()); + auto catalog = parseCatalog(resp.value()); + if (!catalog) return Result::err(catalog.error()); + + FetchWithCatalog out; + out.sets = std::move(sets).value(); + out.catalog = std::move(catalog).value(); + return Result::ok(std::move(out)); } Result> DigiBattle99SetSource::fetchAll() { - auto resp = http_.get(kEndpoint); - if (!resp) return Result>::err(resp.error()); - return parseResponse(resp.value()); + auto both = fetchAllWithCatalog(); + if (!both) return Result>::err(both.error()); + return Result>::ok(std::move(both).value().sets); } } // namespace ccm diff --git a/core/src/services/DigiBattle99SetCatalogService.cpp b/core/src/services/DigiBattle99SetCatalogService.cpp new file mode 100644 index 0000000..30ee87e --- /dev/null +++ b/core/src/services/DigiBattle99SetCatalogService.cpp @@ -0,0 +1,50 @@ +#include "ccm/services/DigiBattle99SetCatalogService.hpp" + +#include + +#include + +namespace ccm { + +namespace fs = std::filesystem; + +DigiBattle99SetCatalogService::DigiBattle99SetCatalogService(IFileSystem& fs, + ConfigService& config, + DirNameFn dirName) + : fs_(fs), config_(config), dirName_(std::move(dirName)) {} + +fs::path DigiBattle99SetCatalogService::catalogPath() const { + return fs::path(config_.current().dataStorage) / dirName_(Game::DigiBattle99) / + "set-catalog.json"; +} + +bool DigiBattle99SetCatalogService::exists() const { + return fs_.exists(catalogPath()); +} + +Result DigiBattle99SetCatalogService::load() const { + const auto p = catalogPath(); + if (!fs_.exists(p)) { + return Result::err( + "Digimon Digi-Battle set catalog not yet downloaded."); + } + auto text = fs_.readText(p); + if (!text) return Result::err(text.error()); + try { + const auto j = nlohmann::json::parse(text.value()); + return Result::ok(j.get()); + } catch (const std::exception& e) { + return Result::err( + std::string("set-catalog.json parse error: ") + e.what()); + } +} + +Result DigiBattle99SetCatalogService::save(const DigiBattle99SetCatalog& catalog) { + const auto p = catalogPath(); + auto dir = fs_.ensureDirectory(p.parent_path()); + if (!dir) return dir; + const nlohmann::json j = catalog; + return fs_.writeText(p, j.dump(2)); +} + +} // namespace ccm diff --git a/core/src/services/DigiBattle99SetCompletion.cpp b/core/src/services/DigiBattle99SetCompletion.cpp new file mode 100644 index 0000000..c338d5f --- /dev/null +++ b/core/src/services/DigiBattle99SetCompletion.cpp @@ -0,0 +1,98 @@ +#include "ccm/services/DigiBattle99SetCompletion.hpp" + +#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp" + +#include +#include +#include + +namespace ccm { + +namespace { + +using OwnedBySet = std::unordered_map>; + +OwnedBySet ownedSetNosBySetId(const std::vector& collection) { + OwnedBySet out; + for (const auto& card : collection) { + if (card.set.id.empty()) continue; + const std::string setNo = + DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo); + if (setNo.empty()) continue; + out[card.set.id].insert(setNo); + } + return out; +} + +} // namespace + +std::vector +computeDigiBattle99SetCompletion(const std::vector& collection, + const DigiBattle99SetCatalog& catalog) { + const OwnedBySet owned = ownedSetNosBySetId(collection); + + std::vector out; + out.reserve(owned.size()); + + for (const auto& [setId, ownedNos] : owned) { + const auto* pack = catalog.findPack(setId); + if (pack == nullptr || pack->cards.empty()) continue; + + std::size_t matched = 0; + for (const auto& card : pack->cards) { + const std::string catalogNo = + DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo); + if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched; + } + + DigiBattle99SetCompletionProgress row; + row.setId = pack->setId; + row.setName = pack->setName; + row.ownedUnique = matched; + row.total = pack->cards.size(); + out.push_back(std::move(row)); + } + + std::sort(out.begin(), out.end(), + [](const DigiBattle99SetCompletionProgress& a, + const DigiBattle99SetCompletionProgress& b) { + return a.setName < b.setName; + }); + return out; +} + +std::vector +digiBattle99ChecklistForSet(const std::vector& collection, + const DigiBattle99SetCatalog& catalog, + std::string_view setId) { + const auto* pack = catalog.findPack(setId); + if (pack == nullptr) return {}; + + std::unordered_set ownedNos; + for (const auto& card : collection) { + if (card.set.id != setId) continue; + const std::string setNo = + DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo); + if (!setNo.empty()) ownedNos.insert(setNo); + } + + std::vector out; + out.reserve(pack->cards.size()); + for (const auto& card : pack->cards) { + DigiBattle99ChecklistEntry entry; + entry.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo); + entry.name = card.name; + entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0; + out.push_back(std::move(entry)); + } + + std::sort(out.begin(), out.end(), + [](const DigiBattle99ChecklistEntry& a, + const DigiBattle99ChecklistEntry& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + return out; +} + +} // namespace ccm diff --git a/core/src/services/SetService.cpp b/core/src/services/SetService.cpp index 209f1eb..5de81f0 100644 --- a/core/src/services/SetService.cpp +++ b/core/src/services/SetService.cpp @@ -20,6 +20,10 @@ Result> SetService::updateSets(Game game) { return fetched; } +Result SetService::saveSets(Game game, const std::vector& sets) { + return repo_.save(game, sets); +} + Result> SetService::getSets(Game game) { auto loaded = repo_.load(game); if (!loaded) return loaded; diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index 22be4df..00f95be 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -100,6 +100,19 @@ and collects unique `set_name[]` pack strings. Each pack becomes a `Set` with: Unknown future packs get an empty release date and sort last. +Cached on disk as `/digibattle99/sets.json` via `SetService` / `JsonSetRepository`. + +### Set-completion catalog (same `search.php` payload) + +**Sets → Update Digimon (Digi-Battle)** uses `DigiBattle99SetSource::fetchAllWithCatalog()` so one HTTP response writes both: + +1. The set list (`sets.json`) as above +2. A pack checklist at `/digibattle99/set-catalog.json` + +Each catalog pack stores `id` (slug), `name` (display), and `cards[]` of `{ setNo, name }` (API `id` normalized like preview — alphabetic prefix uppercased). A card listed in multiple `set_name[]` packs appears under **each** pack. The Digimon **Set Completion** tab reads this file offline (no live HTTP while browsing); ownership for a pack requires matching `card.set.id` plus normalized `setNo`. + +If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Digimon (Digi-Battle). + ### Asset API: CDN images + `search.php` lookup Card scans live at: diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 3cc1220..c4cad59 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -21,7 +21,8 @@ - `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_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`. +- `digibattle99_set_completion_tests.cpp` — `computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`. - `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). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9225ab6..cee6845 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,7 @@ add_executable(ccm_core_tests pokemon_card_preview_source_tests.cpp digibattle99_set_source_tests.cpp digibattle99_card_preview_source_tests.cpp + digibattle99_set_completion_tests.cpp japanese_pokemon_en_catalog_tests.cpp japanese_pokemon_set_source_tests.cpp japanese_pokemon_card_preview_source_tests.cpp diff --git a/tests/digibattle99_set_completion_tests.cpp b/tests/digibattle99_set_completion_tests.cpp new file mode 100644 index 0000000..cad7581 --- /dev/null +++ b/tests/digibattle99_set_completion_tests.cpp @@ -0,0 +1,164 @@ +#include + +#include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/DigiBattle99SetCatalog.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/services/DigiBattle99SetCatalogService.hpp" +#include "ccm/services/DigiBattle99SetCompletion.hpp" +#include "fakes/InMemoryFileSystem.hpp" + +#include + +using namespace ccm; +using ccm::testing::InMemoryFileSystem; + +namespace { + +ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) { + Configuration c; + c.dataStorage = dataDir; + c.defaultGame = Game::Magic; + fs.writeText("/app/config.json", nlohmann::json(c).dump()); + ConfigService cfg{fs, "/app/config.json", dataDir}; + cfg.initialize(); + return cfg; +} + +DigiBattle99Card makeOwned(std::string setId, std::string setName, std::string setNo) { + DigiBattle99Card c; + c.id = 1; + c.name = "Owned"; + c.set.id = std::move(setId); + c.set.name = std::move(setName); + c.setNo = std::move(setNo); + return c; +} + +DigiBattle99SetCatalog sampleCatalog() { + DigiBattle99SetCatalog catalog; + DigiBattle99SetCatalogPack starter; + starter.setId = "series-1-starter-set"; + starter.setName = "Series 1 Starter Set"; + starter.cards = { + {"ST-01", "Agumon"}, + {"ST-02", "Greymon"}, + {"ST-03", "Gabumon"}, + }; + DigiBattle99SetCatalogPack booster; + booster.setId = "series-1-booster-pack"; + booster.setName = "Series 1 Booster Pack"; + booster.cards = { + {"ST-01", "Agumon"}, + {"BO-01", "MetalGreymon"}, + }; + catalog.packs.push_back(std::move(booster)); + catalog.packs.push_back(std::move(starter)); + return catalog; +} + +} // namespace + +TEST_SUITE("computeDigiBattle99SetCompletion") { + TEST_CASE("only packs with owned cards appear") { + const auto catalog = sampleCatalog(); + std::vector collection{ + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"), + }; + const auto rows = computeDigiBattle99SetCompletion(collection, catalog); + REQUIRE(rows.size() == 1); + CHECK(rows[0].setId == "series-1-starter-set"); + CHECK(rows[0].ownedUnique == 1); + CHECK(rows[0].total == 3); + CHECK(rows[0].percent() == 33); + } + + TEST_CASE("unique setNo within a pack; amount does not inflate") { + const auto catalog = sampleCatalog(); + DigiBattle99Card a = makeOwned("series-1-starter-set", "Series 1 Starter Set", "st-01"); + a.amount = 4; + DigiBattle99Card b = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"); + b.id = 2; + DigiBattle99Card c = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"); + c.id = 3; + const auto rows = + computeDigiBattle99SetCompletion({a, b, c}, catalog); + REQUIRE(rows.size() == 1); + CHECK(rows[0].ownedUnique == 2); + CHECK(rows[0].total == 3); + CHECK(rows[0].percent() == 66); + } + + TEST_CASE("ownership on one pack does not complete another pack sharing setNo") { + const auto catalog = sampleCatalog(); + std::vector collection{ + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"), + }; + const auto rows = computeDigiBattle99SetCompletion(collection, catalog); + REQUIRE(rows.size() == 1); + CHECK(rows[0].setId == "series-1-starter-set"); + } + + TEST_CASE("empty catalog yields no rows") { + DigiBattle99SetCatalog empty; + std::vector collection{ + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"), + }; + CHECK(computeDigiBattle99SetCompletion(collection, empty).empty()); + } + + TEST_CASE("owned set missing from catalog is skipped") { + DigiBattle99SetCatalog catalog; + DigiBattle99SetCatalogPack onlyBooster; + onlyBooster.setId = "series-1-booster-pack"; + onlyBooster.setName = "Series 1 Booster Pack"; + onlyBooster.cards = {{"BO-01", "MetalGreymon"}}; + catalog.packs.push_back(std::move(onlyBooster)); + + std::vector collection{ + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"), + }; + CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty()); + } +} + +TEST_SUITE("digiBattle99ChecklistForSet") { + TEST_CASE("greys missing cards and marks owned ones") { + const auto catalog = sampleCatalog(); + std::vector collection{ + makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"), + }; + const auto list = + digiBattle99ChecklistForSet(collection, catalog, "series-1-starter-set"); + REQUIRE(list.size() == 3); + CHECK(list[0].setNo == "ST-01"); + CHECK(list[0].owned == false); + CHECK(list[1].setNo == "ST-02"); + CHECK(list[1].owned == true); + CHECK(list[2].setNo == "ST-03"); + CHECK(list[2].owned == false); + } + + TEST_CASE("unknown set returns empty") { + const auto catalog = sampleCatalog(); + CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty()); + } +} + +TEST_SUITE("DigiBattle99SetCatalogService") { + TEST_CASE("save then load round-trips") { + InMemoryFileSystem fs; + auto config = makeConfig(fs, "/data"); + DigiBattle99SetCatalogService store{fs, config, [](Game) { return "digibattle99"; }}; + + CHECK_FALSE(store.exists()); + CHECK(store.load().isErr()); + + const auto catalog = sampleCatalog(); + REQUIRE(store.save(catalog).isOk()); + CHECK(store.exists()); + + const auto loaded = store.load(); + REQUIRE(loaded.isOk()); + CHECK(loaded.value() == catalog); + } +} diff --git a/tests/digibattle99_set_source_tests.cpp b/tests/digibattle99_set_source_tests.cpp index 831ddd3..f263f18 100644 --- a/tests/digibattle99_set_source_tests.cpp +++ b/tests/digibattle99_set_source_tests.cpp @@ -109,4 +109,75 @@ TEST_SUITE("DigiBattle99SetSource::fetchAll") { CHECK(out.value().front().id == "series-1-starter-set"); CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint); } + + TEST_CASE("fetchAllWithCatalog returns sets and pack cards in one GET") { + FixedHttpClient http; + http.ok = true; + http.body = R"([ + {"name":"Agumon","id":"st-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]}, + {"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]} + ])"; + DigiBattle99SetSource src{http}; + const auto out = src.fetchAllWithCatalog(); + REQUIRE(out.isOk()); + CHECK(out.value().sets.size() == 2); + const auto* starter = out.value().catalog.findPack("series-1-starter-set"); + REQUIRE(starter != nullptr); + REQUIRE(starter->cards.size() == 2); + CHECK(starter->cards[0].setNo == "ST-01"); + CHECK(starter->cards[0].name == "Agumon"); + const auto* booster = out.value().catalog.findPack("series-1-booster-pack"); + REQUIRE(booster != nullptr); + REQUIRE(booster->cards.size() == 1); + CHECK(booster->cards[0].setNo == "ST-01"); + CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint); + } +} + +TEST_SUITE("DigiBattle99SetSource::parseCatalog") { + TEST_CASE("lists a card under every pack in set_name") { + const std::string json = R"([ + {"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]}, + {"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]} + ])"; + const auto out = DigiBattle99SetSource::parseCatalog(json); + REQUIRE(out.isOk()); + REQUIRE(out.value().packs.size() == 2); + + const auto* booster = out.value().findPack("series-1-booster-pack"); + REQUIRE(booster != nullptr); + REQUIRE(booster->cards.size() == 2); + CHECK(booster->cards[0].setNo == "BO-01"); + CHECK(booster->cards[1].setNo == "ST-01"); + + const auto* starter = out.value().findPack("series-1-starter-set"); + REQUIRE(starter != nullptr); + REQUIRE(starter->cards.size() == 1); + CHECK(starter->cards[0].setNo == "ST-01"); + } + + TEST_CASE("dedupes the same setNo within one pack") { + const std::string json = R"([ + {"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]}, + {"name":"Agumon Alt","id":"ST-01","set_name":["Series 1 Starter Set"]} + ])"; + const auto out = DigiBattle99SetSource::parseCatalog(json); + REQUIRE(out.isOk()); + const auto* starter = out.value().findPack("series-1-starter-set"); + REQUIRE(starter != nullptr); + REQUIRE(starter->cards.size() == 1); + CHECK(starter->cards[0].name == "Agumon"); + } + + TEST_CASE("empty array returns an empty catalog") { + const auto out = DigiBattle99SetSource::parseCatalog("[]"); + REQUIRE(out.isOk()); + CHECK(out.value().empty()); + } + + TEST_CASE("error object is an error") { + const auto out = DigiBattle99SetSource::parseCatalog( + R"({"error":"No cards found for this search."})"); + CHECK(out.isErr()); + } } diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index 81c8870..8273031 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -2,6 +2,7 @@ #include "ccm/domain/Configuration.hpp" #include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/DigiBattle99SetCatalog.hpp" #include "ccm/domain/Enums.hpp" #include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" @@ -245,6 +246,28 @@ TEST_SUITE("DigiBattle99Card JSON") { } } +TEST_SUITE("DigiBattle99SetCatalog JSON") { + TEST_CASE("round-trips packs and setNo alias") { + DigiBattle99SetCatalog catalog; + DigiBattle99SetCatalogPack pack; + pack.setId = "series-1-starter-set"; + pack.setName = "Series 1 Starter Set"; + pack.cards.push_back(DigiBattle99CatalogCard{"ST-01", "Agumon"}); + pack.cards.push_back(DigiBattle99CatalogCard{"ST-126", "Agumon"}); + catalog.packs.push_back(std::move(pack)); + + nlohmann::json j = catalog; + CHECK(j.at("packs").is_array()); + CHECK(j.at("packs").at(0).at("id") == "series-1-starter-set"); + CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "ST-01"); + + const DigiBattle99SetCatalog back = j.get(); + CHECK(back == catalog); + CHECK(back.findPack("series-1-starter-set") != nullptr); + CHECK(back.findPack("missing") == nullptr); + } +} + TEST_SUITE("JapanesePokemonCard JSON") { TEST_CASE("uses 'setNo' and 'firstEdition' aliases") { JapanesePokemonCard c; diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index 5693388..2917559 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -5,8 +5,8 @@ ## Layer pointers - `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly). -- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. -- `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/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon own a tabbed layout without changing other games’ splitter mounting. +- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden for Digimon via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic / Pokémon / Yu-Gi-Oh!) or a game’s `IGameView::contentPanel` (Digimon Digi-Battle notebook). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. - `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). - `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32. - `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)` → `Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia). @@ -14,6 +14,7 @@ - `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. - `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`). +- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` notebook (**Single Cards** | **Set Completion**) via `contentPanel`, and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards tab (under the notebook); MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`). - `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up. - `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types. - `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`. @@ -27,7 +28,7 @@ 3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount. 4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`. 5. **wxFont modifications** mutate in place: `font.MakeBold().MakeLarger()` — do not call `Scale` (it does not exist on wxFont 3.2; use `MakeLarger` / `SetPointSize`). -6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the splitter swaps `listPanel()` / `selectedPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. +6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s Single Cards / Set Completion notebook is an in-game mode switch, not multi-game tabs. 7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link. 8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr` holding `std::atomic alive`, `std::atomic currentGen`, and a back-pointer to the panel; spawn a detached `std::thread`, then deliver the result with `wxTheApp->CallAfter([state, gen, ...]() { if (!state->alive) return; if (state->currentGen != gen) return; ... })`. Flip `alive=false` in the panel destructor so late callbacks become no-ops. 9. **Icons come from `SvgIcons.hpp`.** Don't inline new SVG strings in panel sources; add them to `SvgIcons.{hpp,cpp}` so all panels stay in sync. Always pass a runtime fill color (`wxSystemSettings::GetColour(...).GetAsString(wxC2S_HTML_SYNTAX)`); never bake one into the SVG. diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt index f683e0a..b0abb44 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -23,6 +23,7 @@ add_library(ccm_ui_wx STATIC src/DigiBattle99SelectedCardPanel.cpp src/DigiBattle99CardEditDialog.cpp src/DigiBattle99GameView.cpp + src/DigiBattle99SetCompletionPanel.cpp src/SettingsDialog.cpp src/SwitchCtrl.cpp diff --git a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp index bc31c47..04b0a24 100644 --- a/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp +++ b/ui_wx/include/ccm/ui/DigiBattle99GameView.hpp @@ -5,18 +5,27 @@ #include "ccm/services/CardPreviewService.hpp" #include "ccm/services/CollectionService.hpp" #include "ccm/services/ConfigService.hpp" +#include "ccm/services/DigiBattle99SetCatalogService.hpp" #include "ccm/services/ImageService.hpp" #include "ccm/services/SetService.hpp" #include "ccm/ui/IGameView.hpp" +#include #include #include #include +class wxBitmapButton; +class wxBoxSizer; +class wxNotebook; +class wxSplitterWindow; +class wxTextCtrl; + namespace ccm::ui { class DigiBattle99CardListPanel; class DigiBattle99SelectedCardPanel; +class DigiBattle99SetCompletionPanel; class DigiBattle99GameView final : public IGameView { public: @@ -25,13 +34,19 @@ public: SetService& sets, ImageService& images, CardPreviewService& cardPreview, - IGameModule& module); + IGameModule& module, + DigiBattle99SetCatalogService& catalogStore); [[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; + wxPanel* contentPanel(wxWindow* parent) override; + [[nodiscard]] wxPanel* contentPanelIfCreated() const noexcept override { + return contentPanel_; + } + [[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; } void refreshCollection() override; void onAddCard(wxWindow* parentWindow) override; @@ -47,6 +62,9 @@ public: private: void ensureSetsLoaded(); const std::vector& setsForDialog(); + void ensureSingleCardsMounted(wxWindow* splitterParent); + void buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer); + void refreshToolbarIcons(const ThemePalette& palette); ConfigService& config_; CollectionService& collection_; @@ -54,9 +72,16 @@ private: ImageService& images_; CardPreviewService& cardPreview_; IGameModule& module_; + DigiBattle99SetCatalogService& catalogStore_; + wxPanel* contentPanel_{nullptr}; + wxNotebook* notebook_{nullptr}; + wxSplitterWindow* singleSplitter_{nullptr}; DigiBattle99CardListPanel* listPanel_{nullptr}; DigiBattle99SelectedCardPanel* selectedPanel_{nullptr}; + DigiBattle99SetCompletionPanel* setCompletionPanel_{nullptr}; + std::array toolbarButtons_{{nullptr, nullptr, nullptr}}; + wxTextCtrl* filterInput_{nullptr}; std::vector setsCache_; bool attemptedInitialSetLoad_{false}; }; diff --git a/ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp b/ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp new file mode 100644 index 0000000..7daf22a --- /dev/null +++ b/ui_wx/include/ccm/ui/DigiBattle99SetCompletionPanel.hpp @@ -0,0 +1,59 @@ +#pragma once + +// DigiBattle99SetCompletionPanel: Set Completion tab — pack tiles with +// progress bars for sets the user owns ≥1 card of, plus an in-tab checklist +// drill-down (unowned rows greyed). Catalog is offline (set-catalog.json). + +#include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/DigiBattle99SetCatalog.hpp" +#include "ccm/services/DigiBattle99SetCatalogService.hpp" +#include "ccm/ui/Theme.hpp" + +#include + +#include +#include + +class wxBoxSizer; +class wxListCtrl; +class wxScrolledWindow; +class wxSimplebook; +class wxStaticText; + +namespace ccm::ui { + +class DigiBattle99SetCompletionPanel : public wxPanel { +public: + DigiBattle99SetCompletionPanel(wxWindow* parent, DigiBattle99SetCatalogService& catalogStore); + + void setCollection(std::vector cards); + void reloadFromStore(); + void applyTheme(const ThemePalette& palette); + +private: + void showGridPage(); + void showChecklistPage(const std::string& setId, const std::string& setName); + void rebuildGrid(); + void rebuildChecklist(const std::string& setId); + void setEmptyMessage(const wxString& message); + void clearGridTiles(); + + DigiBattle99SetCatalogService& catalogStore_; + DigiBattle99SetCatalog catalog_; + bool catalogLoaded_{false}; + std::vector collection_; + ThemePalette palette_{}; + + wxSimplebook* book_{nullptr}; + wxPanel* gridPage_{nullptr}; + wxScrolledWindow* scroll_{nullptr}; + wxBoxSizer* gridSizer_{nullptr}; + wxStaticText* emptyLabel_{nullptr}; + + wxPanel* detailPage_{nullptr}; + wxStaticText* detailTitle_{nullptr}; + wxListCtrl* checklist_{nullptr}; + std::string detailSetId_; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/IGameView.hpp b/ui_wx/include/ccm/ui/IGameView.hpp index cb145ff..be53851 100644 --- a/ui_wx/include/ccm/ui/IGameView.hpp +++ b/ui_wx/include/ccm/ui/IGameView.hpp @@ -35,6 +35,24 @@ public: virtual wxPanel* listPanel(wxWindow* parent) = 0; virtual wxPanel* selectedPanel(wxWindow* parent) = 0; + // When non-null, MainFrame mounts this as the sole content under the + // toolbar instead of the shared selected|list splitter. Digimon uses this + // for its Single Cards / Set Completion notebook. Default: no custom host. + virtual wxPanel* contentPanel(wxWindow* parent) { + (void)parent; + return nullptr; + } + + // Non-constructing accessor so MainFrame can hide a previously mounted + // content panel without forcing lazy creation for inactive games. + [[nodiscard]] virtual wxPanel* contentPanelIfCreated() const noexcept { + return nullptr; + } + + // Games that own their layout via contentPanel must not have their + // list/selected panels parented onto MainFrame's shared splitter. + [[nodiscard]] virtual bool hostsOwnLayout() const noexcept { return false; } + // Reload the active collection from disk and refresh the panels. The // selected card is preserved when possible. virtual void refreshCollection() = 0; diff --git a/ui_wx/include/ccm/ui/MainFrame.hpp b/ui_wx/include/ccm/ui/MainFrame.hpp index f4323cb..6f9451a 100644 --- a/ui_wx/include/ccm/ui/MainFrame.hpp +++ b/ui_wx/include/ccm/ui/MainFrame.hpp @@ -59,6 +59,8 @@ private: Game activeGame_{Game::Magic}; wxSplitterWindow* splitter_{nullptr}; + wxPanel* contentHost_{nullptr}; + wxPanel* toolbarPanel_{nullptr}; wxTextCtrl* filterInput_{nullptr}; wxPanel* menuStrip_{nullptr}; wxStaticText* statusText_{nullptr}; diff --git a/ui_wx/src/DigiBattle99GameView.cpp b/ui_wx/src/DigiBattle99GameView.cpp index f572074..89df750 100644 --- a/ui_wx/src/DigiBattle99GameView.cpp +++ b/ui_wx/src/DigiBattle99GameView.cpp @@ -1,31 +1,45 @@ #include "ccm/ui/DigiBattle99GameView.hpp" +#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp" #include "ccm/ui/CardEditModalGuard.hpp" #include "ccm/ui/DigiBattle99CardEditDialog.hpp" #include "ccm/ui/DigiBattle99CardListPanel.hpp" #include "ccm/ui/DigiBattle99SelectedCardPanel.hpp" +#include "ccm/ui/DigiBattle99SetCompletionPanel.hpp" +#include "ccm/ui/SvgIcons.hpp" #include "ccm/ui/Theme.hpp" -#include +#include +#include +#include +#include +#include +#include #include -#include #include namespace ccm::ui { +namespace { +constexpr int kDigiToolbarIconPx = 18; +constexpr const char kDigiFilterHint[] = "Filter"; +} // namespace + DigiBattle99GameView::DigiBattle99GameView(ConfigService& config, CollectionService& collection, SetService& sets, ImageService& images, CardPreviewService& cardPreview, - IGameModule& module) + IGameModule& module, + DigiBattle99SetCatalogService& catalogStore) : config_(config), collection_(collection), sets_(sets), images_(images), cardPreview_(cardPreview), - module_(module) {} + module_(module), + catalogStore_(catalogStore) {} void DigiBattle99GameView::ensureSetsLoaded() { if (attemptedInitialSetLoad_) return; @@ -45,6 +59,101 @@ void DigiBattle99GameView::ensureSetsLoaded() { } } +void DigiBattle99GameView::ensureSingleCardsMounted(wxWindow* splitterParent) { + if (singleSplitter_ == nullptr) { + singleSplitter_ = new wxSplitterWindow(splitterParent, wxID_ANY, wxDefaultPosition, + wxDefaultSize, wxSP_LIVE_UPDATE); + singleSplitter_->SetMinimumPaneSize(280); + } + auto* list = listPanel(singleSplitter_); + auto* selected = selectedPanel(singleSplitter_); + if (!singleSplitter_->IsSplit()) { + singleSplitter_->SplitVertically(selected, list, 360); + } +} + +void DigiBattle99GameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) { + auto* toolbar = new wxBoxSizer(wxHORIZONTAL); + auto makeToolBtn = [&](const char* svg, const wxString& tip) { + wxBitmap bmp = svgIconBitmap(svg, kDigiToolbarIconPx, "#000000"); + auto* b = new wxBitmapButton(parent, wxID_ANY, bmp, wxDefaultPosition, wxDefaultSize, + wxBU_EXACTFIT); + b->SetToolTip(tip); + return b; + }; + toolbarButtons_[0] = makeToolBtn(kSvgToolbarAdd, "Add Card"); + toolbarButtons_[1] = makeToolBtn(kSvgToolbarEdit, "Edit"); + toolbarButtons_[2] = makeToolBtn(kSvgToolbarDelete, "Delete"); + toolbar->AddSpacer(4); + toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); + toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); + toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); + toolbar->AddStretchSpacer(1); + filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1)); + filterInput_->SetHint(kDigiFilterHint); + toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4); + pageSizer->Add(toolbar, 0, wxEXPAND); + + toolbarButtons_[0]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(contentPanel_); + onAddCard(owner != nullptr ? owner : contentPanel_); + }); + toolbarButtons_[1]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(contentPanel_); + onEditCard(owner != nullptr ? owner : contentPanel_); + }); + toolbarButtons_[2]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(contentPanel_); + onDeleteCard(owner != nullptr ? owner : contentPanel_); + }); + filterInput_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) { + if (filterInput_ == nullptr) return; + setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8)); + }); +} + +void DigiBattle99GameView::refreshToolbarIcons(const ThemePalette& palette) { + const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); + if (toolbarButtons_[0]) { + toolbarButtons_[0]->SetBitmap( + svgIconBitmap(kSvgToolbarAdd, kDigiToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[1]) { + toolbarButtons_[1]->SetBitmap( + svgIconBitmap(kSvgToolbarEdit, kDigiToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[2]) { + toolbarButtons_[2]->SetBitmap( + svgIconBitmap(kSvgToolbarDelete, kDigiToolbarIconPx, tbHex.c_str())); + } +} + +wxPanel* DigiBattle99GameView::contentPanel(wxWindow* parent) { + if (contentPanel_ == nullptr) { + contentPanel_ = new wxPanel(parent); + auto* root = new wxBoxSizer(wxVERTICAL); + + notebook_ = new wxNotebook(contentPanel_, wxID_ANY); + auto* singlePage = new wxPanel(notebook_); + auto* singleSizer = new wxBoxSizer(wxVERTICAL); + buildSingleCardsToolbar(singlePage, singleSizer); + ensureSingleCardsMounted(singlePage); + singleSizer->Add(singleSplitter_, 1, wxEXPAND); + singlePage->SetSizer(singleSizer); + notebook_->AddPage(singlePage, "Single Cards"); + + setCompletionPanel_ = new DigiBattle99SetCompletionPanel(notebook_, catalogStore_); + setCompletionPanel_->reloadFromStore(); + notebook_->AddPage(setCompletionPanel_, "Set Completion"); + + root->Add(notebook_, 1, wxEXPAND); + contentPanel_->SetSizer(root); + + refreshToolbarIcons(paletteForTheme(config_.current().theme)); + } + return contentPanel_; +} + wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) { if (listPanel_ == nullptr) { listPanel_ = new DigiBattle99CardListPanel(parent); @@ -69,7 +178,10 @@ wxPanel* DigiBattle99GameView::selectedPanel(wxWindow* parent) { } void DigiBattle99GameView::refreshCollection() { - if (listPanel_ == nullptr) return; + // Ensure the Digimon host (and list panel) exist even when MainFrame mounts + // via contentPanel before an explicit listPanel call. + if (contentPanel_ == nullptr && listPanel_ == nullptr) return; + auto loaded = collection_.list(Game::DigiBattle99); if (!loaded) { showThemedMessageDialog( @@ -78,9 +190,15 @@ void DigiBattle99GameView::refreshCollection() { "Error", wxOK | wxICON_ERROR); return; } - listPanel_->setCards(std::move(loaded).value()); - listPanel_->activateSelection(); - if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); + auto cards = std::move(loaded).value(); + if (listPanel_ != nullptr) { + listPanel_->setCards(cards); + listPanel_->activateSelection(); + if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); + } + if (setCompletionPanel_ != nullptr) { + setCompletionPanel_->setCollection(std::move(cards)); + } } const std::vector& DigiBattle99GameView::setsForDialog() { @@ -190,27 +308,79 @@ void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) { } std::string DigiBattle99GameView::onUpdateSets(wxWindow* parentWindow) { - auto out = sets_.updateSets(Game::DigiBattle99); - if (!out) { - showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(), + auto* digiSrc = dynamic_cast(&module_.setSource()); + if (digiSrc == nullptr) { + showThemedMessageDialog(parentWindow, "Digimon Digi-Battle set source unavailable.", "Error", wxOK | wxICON_ERROR); return "Update failed"; } - setsCache_ = out.value(); + + auto both = digiSrc->fetchAllWithCatalog(); + if (!both) { + showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(), + "Error", wxOK | wxICON_ERROR); + return "Update failed"; + } + + auto savedSets = sets_.saveSets(Game::DigiBattle99, both.value().sets); + if (!savedSets) { + showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(), + "Error", wxOK | wxICON_ERROR); + return "Update failed"; + } + + auto savedCatalog = catalogStore_.save(both.value().catalog); + if (!savedCatalog) { + showThemedMessageDialog(parentWindow, + "Sets saved, but set catalog failed: " + savedCatalog.error(), + "Warning", wxOK | wxICON_WARNING); + } + + setsCache_ = both.value().sets; + if (setCompletionPanel_ != nullptr) { + setCompletionPanel_->reloadFromStore(); + if (auto loaded = collection_.list(Game::DigiBattle99)) { + setCompletionPanel_->setCollection(std::move(loaded).value()); + } + } + + const std::size_t setCount = both.value().sets.size(); + const std::size_t packCount = both.value().catalog.packs.size(); showThemedMessageDialog( parentWindow, - "Updated " + std::to_string(out.value().size()) + " Digimon (Digi-Battle) sets.", + "Updated " + std::to_string(setCount) + " Digimon (Digi-Battle) sets and " + + std::to_string(packCount) + " set checklists.", "Sets updated", wxOK | wxICON_INFORMATION); return "Digimon (Digi-Battle) sets updated."; } void DigiBattle99GameView::setFilter(std::string_view filter) { + if (filterInput_ != nullptr) { + const wxString wanted = wxString::FromUTF8(std::string(filter).c_str()); + if (filterInput_->GetValue() != wanted) { + filterInput_->ChangeValue(wanted); + if (filter.empty()) { + filterInput_->SetHint(kDigiFilterHint); + filterInput_->Refresh(); + } + } + } if (listPanel_) listPanel_->setFilter(filter); } void DigiBattle99GameView::applyTheme(const ThemePalette& palette) { + if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme); if (listPanel_) listPanel_->applyTheme(palette); if (selectedPanel_) selectedPanel_->applyTheme(palette); + if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette); + refreshToolbarIcons(palette); + if (filterInput_ != nullptr) { + filterInput_->SetBackgroundColour(palette.inputBg); + filterInput_->SetForegroundColour(palette.inputText); + filterInput_->SetOwnBackgroundColour(palette.inputBg); + filterInput_->SetOwnForegroundColour(palette.inputText); + filterInput_->Refresh(); + } } } // namespace ccm::ui diff --git a/ui_wx/src/DigiBattle99SetCompletionPanel.cpp b/ui_wx/src/DigiBattle99SetCompletionPanel.cpp new file mode 100644 index 0000000..ab1ebba --- /dev/null +++ b/ui_wx/src/DigiBattle99SetCompletionPanel.cpp @@ -0,0 +1,228 @@ +#include "ccm/ui/DigiBattle99SetCompletionPanel.hpp" + +#include "ccm/services/DigiBattle99SetCompletion.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ccm::ui { + +namespace { + +wxColour mutedTextColour(const ThemePalette& palette) { + // Blend text toward panel background so missing checklist rows read as greyed. + const auto blend = [](unsigned char a, unsigned char b) -> unsigned char { + return static_cast((static_cast(a) * 2 + static_cast(b)) / 3); + }; + return wxColour(blend(palette.text.Red(), palette.panelBg.Red()), + blend(palette.text.Green(), palette.panelBg.Green()), + blend(palette.text.Blue(), palette.panelBg.Blue())); +} + +} // namespace + +DigiBattle99SetCompletionPanel::DigiBattle99SetCompletionPanel( + wxWindow* parent, DigiBattle99SetCatalogService& catalogStore) + : wxPanel(parent), catalogStore_(catalogStore) { + palette_ = paletteForTheme(inferThemeFromWindow(this)); + + book_ = new wxSimplebook(this, wxID_ANY); + + gridPage_ = new wxPanel(book_); + auto* gridRoot = new wxBoxSizer(wxVERTICAL); + emptyLabel_ = new wxStaticText(gridPage_, wxID_ANY, ""); + emptyLabel_->Wrap(480); + gridRoot->Add(emptyLabel_, 0, wxALL | wxEXPAND, 12); + + scroll_ = new wxScrolledWindow(gridPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize, + wxVSCROLL | wxTAB_TRAVERSAL); + scroll_->SetScrollRate(0, 16); + gridSizer_ = new wxBoxSizer(wxVERTICAL); + scroll_->SetSizer(gridSizer_); + gridRoot->Add(scroll_, 1, wxEXPAND); + gridPage_->SetSizer(gridRoot); + book_->AddPage(gridPage_, "Grid"); + + detailPage_ = new wxPanel(book_); + auto* detailRoot = new wxBoxSizer(wxVERTICAL); + auto* topRow = new wxBoxSizer(wxHORIZONTAL); + auto* backBtn = new wxButton(detailPage_, wxID_ANY, "Back"); + backBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { showGridPage(); }); + detailTitle_ = new wxStaticText(detailPage_, wxID_ANY, ""); + auto titleFont = detailTitle_->GetFont(); + titleFont.MakeBold().MakeLarger(); + detailTitle_->SetFont(titleFont); + topRow->Add(backBtn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + topRow->Add(detailTitle_, 1, wxALIGN_CENTER_VERTICAL); + detailRoot->Add(topRow, 0, wxEXPAND | wxALL, 8); + + checklist_ = new wxListCtrl(detailPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize, + wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER); + checklist_->AppendColumn("Card", wxLIST_FORMAT_LEFT, 520); + detailRoot->Add(checklist_, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8); + detailPage_->SetSizer(detailRoot); + book_->AddPage(detailPage_, "Detail"); + + auto* root = new wxBoxSizer(wxVERTICAL); + root->Add(book_, 1, wxEXPAND); + SetSizer(root); + + showGridPage(); +} + +void DigiBattle99SetCompletionPanel::setCollection(std::vector cards) { + collection_ = std::move(cards); + if (book_->GetSelection() == 1 && !detailSetId_.empty()) { + rebuildChecklist(detailSetId_); + } else { + rebuildGrid(); + } +} + +void DigiBattle99SetCompletionPanel::reloadFromStore() { + catalogLoaded_ = false; + catalog_ = {}; + if (catalogStore_.exists()) { + if (auto loaded = catalogStore_.load()) { + catalog_ = std::move(loaded).value(); + catalogLoaded_ = true; + } + } + showGridPage(); + rebuildGrid(); +} + +void DigiBattle99SetCompletionPanel::applyTheme(const ThemePalette& palette) { + palette_ = palette; + applyThemeToWindowTree(this, palette, inferThemeFromWindow(this)); + if (book_->GetSelection() == 1 && !detailSetId_.empty()) { + rebuildChecklist(detailSetId_); + } else { + rebuildGrid(); + } +} + +void DigiBattle99SetCompletionPanel::showGridPage() { + detailSetId_.clear(); + book_->SetSelection(0); +} + +void DigiBattle99SetCompletionPanel::showChecklistPage(const std::string& setId, + const std::string& setName) { + detailSetId_ = setId; + detailTitle_->SetLabelText(wxString::FromUTF8(setName.c_str())); + rebuildChecklist(setId); + book_->SetSelection(1); +} + +void DigiBattle99SetCompletionPanel::setEmptyMessage(const wxString& message) { + clearGridTiles(); + emptyLabel_->SetLabelText(message); + emptyLabel_->Wrap(480); + emptyLabel_->Show(); + scroll_->Hide(); + gridPage_->Layout(); +} + +void DigiBattle99SetCompletionPanel::clearGridTiles() { + if (gridSizer_ == nullptr) return; + gridSizer_->Clear(true); +} + +void DigiBattle99SetCompletionPanel::rebuildGrid() { + if (!catalogLoaded_) { + setEmptyMessage(wxString::FromUTF8( + "Set checklists are not downloaded yet.\n" + "Run Sets → Update Digimon (Digi-Battle) to enable Set Completion.")); + return; + } + + const auto rows = computeDigiBattle99SetCompletion(collection_, catalog_); + if (rows.empty()) { + setEmptyMessage(wxString::FromUTF8( + "No Digimon (Digi-Battle) sets in progress yet.\n" + "Add cards on the Single Cards tab to track set completion here.")); + return; + } + + emptyLabel_->Hide(); + scroll_->Show(); + clearGridTiles(); + + for (const auto& row : rows) { + auto* tile = new wxPanel(scroll_, wxID_ANY, wxDefaultPosition, wxDefaultSize, + wxBORDER_SIMPLE); + tile->SetBackgroundColour(palette_.panelBg); + auto* tileSizer = new wxBoxSizer(wxVERTICAL); + + auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(row.setName.c_str())); + auto nameFont = nameLbl->GetFont(); + nameFont.MakeBold(); + nameLbl->SetFont(nameFont); + nameLbl->SetForegroundColour(palette_.text); + + const std::string counts = + std::to_string(row.ownedUnique) + " / " + std::to_string(row.total) + " (" + + std::to_string(row.percent()) + "%)"; + auto* countLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(counts.c_str())); + countLbl->SetForegroundColour(palette_.text); + + auto* gauge = new wxGauge(tile, wxID_ANY, 100, wxDefaultPosition, wxSize(-1, 14), + wxGA_HORIZONTAL | wxGA_SMOOTH); + gauge->SetValue(row.percent()); + + tileSizer->Add(nameLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 10); + tileSizer->Add(countLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 6); + tileSizer->Add(gauge, 0, wxEXPAND | wxALL, 10); + tile->SetSizer(tileSizer); + + const std::string setId = row.setId; + const std::string setName = row.setName; + auto openDetail = [this, setId, setName](wxMouseEvent&) { + showChecklistPage(setId, setName); + }; + tile->Bind(wxEVT_LEFT_UP, openDetail); + nameLbl->Bind(wxEVT_LEFT_UP, openDetail); + countLbl->Bind(wxEVT_LEFT_UP, openDetail); + gauge->Bind(wxEVT_LEFT_UP, openDetail); + tile->SetCursor(wxCursor(wxCURSOR_HAND)); + nameLbl->SetCursor(wxCursor(wxCURSOR_HAND)); + countLbl->SetCursor(wxCursor(wxCURSOR_HAND)); + + gridSizer_->Add(tile, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8); + } + gridSizer_->AddStretchSpacer(1); + scroll_->FitInside(); + gridPage_->Layout(); + Layout(); +} + +void DigiBattle99SetCompletionPanel::rebuildChecklist(const std::string& setId) { + checklist_->DeleteAllItems(); + const auto entries = digiBattle99ChecklistForSet(collection_, catalog_, setId); + const wxColour muted = mutedTextColour(palette_); + + long idx = 0; + for (const auto& entry : entries) { + const std::string line = entry.setNo + " — " + entry.name; + const long row = checklist_->InsertItem(idx++, wxString::FromUTF8(line.c_str())); + if (row < 0) continue; + if (entry.owned) { + checklist_->SetItemTextColour(row, palette_.text); + } else { + checklist_->SetItemTextColour(row, muted); + } + } + checklist_->SetColumnWidth(0, wxLIST_AUTOSIZE); + detailPage_->Layout(); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index c6841d0..0c7987c 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -133,10 +133,11 @@ void MainFrame::buildLayout() { menuStrip_->SetSizer(menuSizer); root->Add(menuStrip_, 0, wxEXPAND); + toolbarPanel_ = new wxPanel(this, wxID_ANY); auto* toolbar = new wxBoxSizer(wxHORIZONTAL); auto makeToolBtn = [&](int id, const char* svg, const wxString& tip) { wxBitmap bmp = svgIconBitmap(svg, kToolbarIconPx, "#000000"); - auto* b = new wxBitmapButton(this, id, bmp, wxDefaultPosition, + auto* b = new wxBitmapButton(toolbarPanel_, id, bmp, wxDefaultPosition, wxDefaultSize, wxBU_EXACTFIT); b->SetToolTip(tip); @@ -150,16 +151,21 @@ void MainFrame::buildLayout() { toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4); toolbar->AddStretchSpacer(1); - filterInput_ = new wxTextCtrl(this, wxID_ANY, "", wxDefaultPosition, + filterInput_ = new wxTextCtrl(toolbarPanel_, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1)); filterInput_->SetHint(kFilterInputHint); toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4); - root->Add(toolbar, 0, wxEXPAND); + toolbarPanel_->SetSizer(toolbar); + root->Add(toolbarPanel_, 0, wxEXPAND); - splitter_ = new wxSplitterWindow(this, wxID_ANY, wxDefaultPosition, + contentHost_ = new wxPanel(this, wxID_ANY); + auto* hostSizer = new wxBoxSizer(wxVERTICAL); + splitter_ = new wxSplitterWindow(contentHost_, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxSP_LIVE_UPDATE); splitter_->SetMinimumPaneSize(280); - root->Add(splitter_, 1, wxEXPAND); + hostSizer->Add(splitter_, 1, wxEXPAND); + contentHost_->SetSizer(hostSizer); + root->Add(contentHost_, 1, wxEXPAND); auto* statusPanel = new wxPanel(this, wxID_ANY); auto* statusSizer = new wxBoxSizer(wxHORIZONTAL); @@ -199,15 +205,53 @@ IGameView* MainFrame::activeView() { void MainFrame::mountActiveView() { auto* view = activeView(); - if (view == nullptr || splitter_ == nullptr) return; + if (view == nullptr || splitter_ == nullptr || contentHost_ == nullptr) return; + + auto* hostSizer = contentHost_->GetSizer(); + if (hostSizer == nullptr) return; // Hide every other view's panels so wx doesn't double-paint them. for (auto* other : ctx_.gameViews) { if (other == nullptr || other == view) continue; + if (other->hostsOwnLayout()) { + if (auto* cp = other->contentPanelIfCreated()) cp->Hide(); + continue; + } if (auto* lp = other->listPanel(splitter_)) lp->Hide(); if (auto* sp = other->selectedPanel(splitter_)) sp->Hide(); } + const ThemePalette palette = paletteForTheme(ctx_.config.current().theme); + + if (toolbarPanel_ != nullptr) { + if (view->hostsOwnLayout()) toolbarPanel_->Hide(); + else toolbarPanel_->Show(); + Layout(); + } + + if (view->hostsOwnLayout()) { + auto* custom = view->contentPanel(contentHost_); + if (custom == nullptr) return; + + splitter_->Hide(); + hostSizer->Clear(false); + custom->Show(); + hostSizer->Add(custom, 1, wxEXPAND); + contentHost_->Layout(); + + view->applyTheme(palette); + applyThemeToWindowTree(custom, palette, ctx_.config.current().theme); + return; + } + + if (auto* previousCustom = view->contentPanelIfCreated()) { + previousCustom->Hide(); + } + // Re-seat the shared splitter if a contentPanel game was showing. + hostSizer->Clear(false); + splitter_->Show(); + hostSizer->Add(splitter_, 1, wxEXPAND); + auto* listPanel = view->listPanel(splitter_); auto* selectedPanel = view->selectedPanel(splitter_); if (listPanel == nullptr || selectedPanel == nullptr) return; @@ -221,7 +265,7 @@ void MainFrame::mountActiveView() { splitter_->SplitVertically(selectedPanel, listPanel, 360); } - const ThemePalette palette = paletteForTheme(ctx_.config.current().theme); + contentHost_->Layout(); view->applyTheme(palette); applyThemeToWindowTree(selectedPanel, palette, ctx_.config.current().theme); applyThemeToWindowTree(listPanel, palette, ctx_.config.current().theme);