diff --git a/app/AGENTS.md b/app/AGENTS.md index e596fac..438ea68 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`, `YuGiOhSetCatalogService`, `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. +1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. 2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually. 3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set). 4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). 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 206fc7e..cfb68a2 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -22,6 +22,7 @@ #include "ccm/services/CollectionService.hpp" #include "ccm/services/ConfigService.hpp" #include "ccm/services/DigiBattle99SetCatalogService.hpp" +#include "ccm/services/PokemonSetCatalogService.hpp" #include "ccm/services/YuGiOhSetCatalogService.hpp" #include "ccm/services/ImageService.hpp" #include "ccm/services/SetService.hpp" @@ -116,6 +117,8 @@ public: std::make_unique(*fs_, *config_, &dirNameForGame); ygoCatalogStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); + pokeCatalogStore_ = + std::make_unique(*fs_, *config_, &dirNameForGame); imgStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); imgSvc_ = std::make_unique(*imgStore_); @@ -164,7 +167,8 @@ public: magicView_ = std::make_unique( *config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_); pokeView_ = std::make_unique( - *config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_); + *config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_, *jpPokeMod_, + *pokeCatalogStore_); ygoView_ = std::make_unique( *config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_, *ygoCatalogStore_); @@ -212,6 +216,7 @@ private: std::unique_ptr setRepo_; std::unique_ptr digiBattle99CatalogStore_; std::unique_ptr ygoCatalogStore_; + std::unique_ptr pokeCatalogStore_; std::unique_ptr imgStore_; std::unique_ptr imgSvc_; std::unique_ptr> magicCollSvc_; diff --git a/core/AGENTS.md b/core/AGENTS.md index c286ec5..c220ca9 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`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `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/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. - `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. -- `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), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `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. `YuGiOhSetSource` and `DigiBattle99SetSource` also expose `parseCatalog` / `fetchAllWithCatalog` for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect. +- `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), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain. +- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `parseCatalog` / `fetchAllWithCatalog` (or Asia equivalents) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill. - `include/ccm/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 244467b..f9e1647 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -10,6 +10,7 @@ add_library(ccm_core STATIC src/domain/DigiBattle99Card.cpp src/domain/DigiBattle99SetCatalog.cpp src/domain/YuGiOhSetCatalog.cpp + src/domain/PokemonSetCatalog.cpp src/domain/JapanesePokemonCard.cpp src/domain/Configuration.cpp @@ -23,6 +24,8 @@ add_library(ccm_core STATIC src/services/DigiBattle99SetCatalogService.cpp src/services/YuGiOhSetCompletion.cpp src/services/YuGiOhSetCatalogService.cpp + src/services/PokemonSetCompletion.cpp + src/services/PokemonSetCatalogService.cpp src/infra/CprHttpClient.cpp src/infra/StdFileSystem.cpp diff --git a/core/include/ccm/domain/PokemonSetCatalog.hpp b/core/include/ccm/domain/PokemonSetCatalog.hpp new file mode 100644 index 0000000..bdae386 --- /dev/null +++ b/core/include/ccm/domain/PokemonSetCatalog.hpp @@ -0,0 +1,52 @@ +#pragma once + +// PokemonSetCatalog: offline pack → card checklist for Pokemon set +// completion. West and Asia each persist their own file under +// `/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`). + +#include + +#include +#include +#include +#include + +namespace ccm { + +struct PokemonCatalogCard { + std::string setNo; + std::string name; + + friend bool operator==(const PokemonCatalogCard&, + const PokemonCatalogCard&) = default; +}; + +struct PokemonSetCatalogPack { + std::string setId; + std::string setName; + std::vector cards; + + friend bool operator==(const PokemonSetCatalogPack&, + const PokemonSetCatalogPack&) = default; +}; + +struct PokemonSetCatalog { + std::vector packs; + + [[nodiscard]] const PokemonSetCatalogPack* findPack( + std::string_view setId) const; + + [[nodiscard]] bool empty() const noexcept { return packs.empty(); } + + friend bool operator==(const PokemonSetCatalog&, + const PokemonSetCatalog&) = default; +}; + +void to_json(nlohmann::json& j, const PokemonCatalogCard& c); +void from_json(const nlohmann::json& j, PokemonCatalogCard& c); +void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p); +void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p); +void to_json(nlohmann::json& j, const PokemonSetCatalog& c); +void from_json(const nlohmann::json& j, PokemonSetCatalog& c); + +} // namespace ccm diff --git a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp index 4dd9721..2b6c764 100644 --- a/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp +++ b/core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp @@ -1,11 +1,11 @@ #pragma once // PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon -// TCG. Calls the Pokemon TCG search endpoint at -// https://api.pokemontcg.io/v2/cards?q=name:"" set.id: number: -// and returns `data[0].images.large` (with `images.small` as a graceful -// fallback). Mirrors the established `getImage` flow in -// `src/components/pokemon/SelectedPokemonPanel.tsx`. +// TCG. When set id + collector number are both known, prefers +// GET https://api.pokemontcg.io/v2/cards/{setId}-{number} +// then falls back to a name-less search `set.id:… number:…`. Name-based +// search is kept for lookups that lack a set number (or set id). Returns +// `images.large` (with `images.small` as a graceful fallback). #include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/ports/IHttpClient.hpp" @@ -32,24 +32,37 @@ public: std::string_view setId) override; // Build the fully URL-encoded Pokemon TCG search URL for the given card. + // When both setId and setNo are non-empty, omits the name: clause so the + // Lucene query cannot miss on name∩number intersections. // Exposed for unit testing and to keep encoding rules in one place. static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo); + // Direct card endpoint: /v2/cards/{setId}-{normalizedNumber}. + static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo); + + // Strip everything after the first '/' (e.g. "4/102" -> "4"). Used by + // preview lookups, auto-detect, and set-completion ownership matching. + static std::string normalizeCollectorNumber(std::string_view setNo); + // Slimmer search URL for auto-detect: omits the number clause and asks the // API for only the fields the print-variant parser needs. static std::string buildDetectSearchUrl(std::string_view name, std::string_view setId); - // Parse a Pokemon TCG /v2/cards response body and pull out the image URL - // for the first matching card. Prefers `images.large`, falls back to - // `images.small`. Errors are classified: + // Parse a Pokemon TCG /v2/cards *search* response body (`data` array) and + // pull out the image URL for the first matching card. Prefers + // `images.large`, falls back to `images.small`. Errors are classified: // - JSON parse failure or missing/non-array `data` => Transient. // - Empty `data` array or missing image variants => NotFound. static Result parseResponse(const std::string& body); + // Parse a Pokemon TCG /v2/cards/{id} response (`data` object). + static Result + parseCardByIdResponse(const std::string& body); + // Enumerate distinct collector numbers (and rarities) for an exact card // name inside the chosen set. Exposed for unit testing without HTTP. static Result> diff --git a/core/include/ccm/games/pokemon/PokemonSetSource.hpp b/core/include/ccm/games/pokemon/PokemonSetSource.hpp index 7222638..1aae657 100644 --- a/core/include/ccm/games/pokemon/PokemonSetSource.hpp +++ b/core/include/ccm/games/pokemon/PokemonSetSource.hpp @@ -6,23 +6,56 @@ // The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format, // so no rewriting is needed (unlike Scryfall's `released_at`). // Behavior matches `pokemon/set_services.rs::update_sets`. +// Set-completion catalog is built from a paginated /v2/cards dump. +#include "ccm/domain/PokemonSetCatalog.hpp" +#include "ccm/domain/Set.hpp" #include "ccm/games/IGameModule.hpp" #include "ccm/ports/IHttpClient.hpp" +#include +#include + namespace ccm { class PokemonSetSource final : public ISetSource { public: static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets"; + static constexpr const char* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards"; + static constexpr int kCardsPageSize = 250; + + struct FetchWithCatalog { + std::vector sets; + PokemonSetCatalog catalog; + }; explicit PokemonSetSource(IHttpClient& http); Result> fetchAll() override; + // Sets endpoint + paginated cards dump for the offline checklist. + Result fetchAllWithCatalog(); + // Pure parser exposed for unit testing without a network round-trip. static Result> parseResponse(const std::string& body); + // Build / merge checklist packs from one /v2/cards page body. Pass an + // accumulating catalog; returns page count metadata for pagination. + struct CardsPageMeta { + int page{1}; + int pageSize{kCardsPageSize}; + int count{0}; + int totalCount{0}; + }; + static Result mergeCardsPage(const std::string& body, + PokemonSetCatalog& catalog, + const std::vector& sets); + + static Result parseCatalog(const std::string& body, + const std::vector& sets); + + static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize); + private: IHttpClient& http_; }; diff --git a/core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp b/core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp index 05f93c1..fc91f99 100644 --- a/core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp +++ b/core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp @@ -54,6 +54,10 @@ public: [[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept; + // All prints for a set (catalog gap-fill / set-completion checklists). + [[nodiscard]] std::vector + printsForSet(std::string_view setId) const; + // TCGPlayer product-image CDN URL for classic JA gap-fill. [[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId); diff --git a/core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp b/core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp index 8055734..ad126ed 100644 --- a/core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp +++ b/core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp @@ -1,22 +1,37 @@ #pragma once // JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release -// dates. English display names come from JapanesePokemonEnCatalog when present. +// dates and set-completion checklists. English display names come from +// JapanesePokemonEnCatalog when present. +#include "ccm/domain/PokemonSetCatalog.hpp" +#include "ccm/domain/Set.hpp" #include "ccm/games/IGameModule.hpp" #include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" #include "ccm/ports/IHttpClient.hpp" +#include +#include +#include + namespace ccm { class JapanesePokemonSetSource final : public ISetSource { public: static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets"; + struct FetchWithCatalog { + std::vector sets; + PokemonSetCatalog catalog; + }; + JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog); Result> fetchAll() override; + // List + per-set detail (cards + release date) + EN catalog gap-fill. + Result fetchAllWithCatalog(); + void augmentCachedSets(std::vector& sets) const override; // Pure parsers for hermetic tests. @@ -28,6 +43,16 @@ public: static std::string rewriteReleaseDate(std::string_view isoDate); static std::string buildSetDetailUrl(std::string_view setId); + // Build one pack checklist from a set-detail body, then gap-fill from catalog. + static Result parseCatalogPackFromSetDetail( + const std::string& detailBody, + const Set& set, + const JapanesePokemonEnCatalog& enCatalog); + + // Catalog-only pack (classic products with no TCGdex detail). + static PokemonSetCatalogPack catalogPackFromEnCatalog( + const Set& set, const JapanesePokemonEnCatalog& enCatalog); + // Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id. static void appendMissingClassicProducts(std::vector& sets); diff --git a/core/include/ccm/services/PokemonSetCatalogService.hpp b/core/include/ccm/services/PokemonSetCatalogService.hpp new file mode 100644 index 0000000..72c66f8 --- /dev/null +++ b/core/include/ccm/services/PokemonSetCatalogService.hpp @@ -0,0 +1,36 @@ +#pragma once + +// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and +// pokemon/set-catalog-asia.json under the configured dataStorage path. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/PokemonSetCatalog.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class PokemonSetCatalogService { +public: + using DirNameFn = std::function; + + PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName); + + Result load(PokemonRegion region) const; + Result save(PokemonRegion region, const PokemonSetCatalog& catalog); + + [[nodiscard]] bool exists(PokemonRegion region) const; + +private: + IFileSystem& fs_; + ConfigService& config_; + DirNameFn dirName_; + + [[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/PokemonSetCompletion.hpp b/core/include/ccm/services/PokemonSetCompletion.hpp new file mode 100644 index 0000000..56a551d --- /dev/null +++ b/core/include/ccm/services/PokemonSetCompletion.hpp @@ -0,0 +1,71 @@ +#pragma once + +// Pure helpers: Pokemon set-completion progress and per-set checklists. +// Ownership requires matching PokemonRegion for the pack (West vs Asia), +// matching set.id, and a normalized collector number / localId. Duplicates / +// amount / holo / firstEdition do not inflate the numerator. Optional +// regionFilter and languageFilter restrict which cards count (packs with +// zero matches are omitted). + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/PokemonSetCatalog.hpp" + +#include +#include +#include +#include +#include + +namespace ccm { + +struct PokemonSetCompletionProgress { + PokemonRegion region{PokemonRegion::West}; + 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 PokemonChecklistEntry { + std::string setNo; + std::string name; + bool owned{false}; +}; + +// Distinct languages present in the collection (optionally region-scoped), +// in allLanguages() order. +[[nodiscard]] std::vector +pokemonLanguagesInCollection(const std::vector& collection, + std::optional regionFilter = std::nullopt); + +// Distinct regions that have ≥1 owned card matching a catalog pack. +[[nodiscard]] std::vector +pokemonRegionsInCollection(const std::vector& collection, + const PokemonSetCatalog& westCatalog, + const PokemonSetCatalog& asiaCatalog); + +// Packs where the collection owns ≥1 matching card, ordered by setName then +// region. When regionFilter is set, only that region's catalog/cards count. +[[nodiscard]] std::vector +computePokemonSetCompletion(const std::vector& collection, + const PokemonSetCatalog& westCatalog, + const PokemonSetCatalog& asiaCatalog, + std::optional regionFilter = std::nullopt, + std::optional languageFilter = std::nullopt); + +// Full catalog checklist for one pack; owned flags from the collection. +[[nodiscard]] std::vector +pokemonChecklistForSet(const std::vector& collection, + const PokemonSetCatalog& westCatalog, + const PokemonSetCatalog& asiaCatalog, + PokemonRegion region, + std::string_view setId, + std::optional languageFilter = std::nullopt); + +} // namespace ccm diff --git a/core/src/domain/PokemonSetCatalog.cpp b/core/src/domain/PokemonSetCatalog.cpp new file mode 100644 index 0000000..7a2016c --- /dev/null +++ b/core/src/domain/PokemonSetCatalog.cpp @@ -0,0 +1,39 @@ +#include "ccm/domain/PokemonSetCatalog.hpp" + +namespace ccm { + +const PokemonSetCatalogPack* PokemonSetCatalog::findPack(std::string_view setId) const { + for (const auto& pack : packs) { + if (pack.setId == setId) return &pack; + } + return nullptr; +} + +void to_json(nlohmann::json& j, const PokemonCatalogCard& c) { + j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}}; +} + +void from_json(const nlohmann::json& j, PokemonCatalogCard& c) { + j.at("setNo").get_to(c.setNo); + j.at("name").get_to(c.name); +} + +void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p) { + j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}}; +} + +void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p) { + j.at("id").get_to(p.setId); + j.at("name").get_to(p.setName); + j.at("cards").get_to(p.cards); +} + +void to_json(nlohmann::json& j, const PokemonSetCatalog& c) { + j = nlohmann::json{{"packs", c.packs}}; +} + +void from_json(const nlohmann::json& j, PokemonSetCatalog& c) { + j.at("packs").get_to(c.packs); +} + +} // namespace ccm diff --git a/core/src/games/pokemon/PokemonCardPreviewSource.cpp b/core/src/games/pokemon/PokemonCardPreviewSource.cpp index cf88579..e3ac69c 100644 --- a/core/src/games/pokemon/PokemonCardPreviewSource.cpp +++ b/core/src/games/pokemon/PokemonCardPreviewSource.cpp @@ -13,18 +13,6 @@ namespace ccm { namespace { -// Strip everything after the first '/' in a Pokemon collector number. -// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as -// `4/102`. Without this, no API match is found. -std::string normalizeNumber(std::string_view setNo) { - std::string s(setNo); - const auto slash = s.find('/'); - if (slash != std::string::npos) { - s = s.substr(0, slash); - } - return s; -} - std::string trim(std::string s) { while (!s.empty() && std::isspace(static_cast(s.front()))) s.erase(s.begin()); while (!s.empty() && std::isspace(static_cast(s.back()))) s.pop_back(); @@ -38,31 +26,74 @@ std::string toLower(std::string s) { return s; } +Result imageUrlFromCardObject(const nlohmann::json& card) { + using R = Result; + using K = PreviewLookupError::Kind; + if (!card.contains("images") || !card.at("images").is_object()) { + return R::err({K::NotFound, "Card has no 'images' object."}); + } + const auto& images = card.at("images"); + if (images.contains("large") && images.at("large").is_string()) { + return R::ok(images.at("large").get()); + } + if (images.contains("small") && images.at("small").is_string()) { + return R::ok(images.at("small").get()); + } + return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."}); +} + } // namespace PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {} +std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) { + // Pokemon TCG search uses an unquoted `number:` clause (e.g. number:4 or + // number:TG14). Cards are commonly stored as `4/102`; strip the suffix. + std::string s(setNo); + const auto slash = s.find('/'); + if (slash != std::string::npos) { + s = s.substr(0, slash); + } + return s; +} + std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo) { - // Build the unencoded query first so the output matches what the Pokemon - // TCG search syntax expects: name:"" set.id: number:. - std::string query = "name:\""; - query += std::string(name); - query += "\""; - if (!setId.empty()) { - query += " set.id:"; + // When both set id and collector number are known, omit name: — Lucene + // name∩number intersections can miss even when the print is real, and + // collector numbers are unique within a set. + const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo); + std::string query; + if (!setId.empty() && !num.empty()) { + query = "set.id:"; query += std::string(setId); - } - const std::string num = normalizeNumber(setNo); - if (!num.empty()) { query += " number:"; query += num; + } else { + query = "name:\""; + query += std::string(name); + query += "\""; + if (!setId.empty()) { + query += " set.id:"; + query += std::string(setId); + } + if (!num.empty()) { + query += " number:"; + query += num; + } } return std::string("https://api.pokemontcg.io/v2/cards?q=") + rfc3986PercentEncode(query); } +std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId, + std::string_view setNo) { + const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo); + std::string id = std::string(setId) + "-" + num; + return std::string("https://api.pokemontcg.io/v2/cards/") + rfc3986PercentEncode(id); +} + std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name, std::string_view setId) { std::string url = buildSearchUrl(name, setId, ""); @@ -84,18 +115,23 @@ PokemonCardPreviewSource::parseResponse(const std::string& body) { if (data.empty()) { return R::err({K::NotFound, "Pokemon TCG returned no matching cards."}); } - const auto& first = data.at(0); - if (!first.contains("images") || !first.at("images").is_object()) { - return R::err({K::NotFound, "Card has no 'images' object."}); + return imageUrlFromCardObject(data.at(0)); + } catch (const std::exception& e) { + return R::err({K::Transient, + std::string("Pokemon TCG JSON parse error: ") + e.what()}); + } +} + +Result +PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) { + using R = Result; + using K = PreviewLookupError::Kind; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_object()) { + return R::err({K::Transient, "Pokemon TCG card response missing 'data' object."}); } - const auto& images = first.at("images"); - if (images.contains("large") && images.at("large").is_string()) { - return R::ok(images.at("large").get()); - } - if (images.contains("small") && images.at("small").is_string()) { - return R::ok(images.at("small").get()); - } - return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."}); + return imageUrlFromCardObject(j.at("data")); } catch (const std::exception& e) { return R::err({K::Transient, std::string("Pokemon TCG JSON parse error: ") + e.what()}); @@ -108,6 +144,18 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name, std::string_view setNo) { using R = Result; using K = PreviewLookupError::Kind; + + const std::string num = normalizeCollectorNumber(setNo); + if (!setId.empty() && !num.empty()) { + auto byId = http_.get(buildCardByIdUrl(setId, num)); + if (byId) { + auto img = parseCardByIdResponse(byId.value()); + if (img) return img; + // NotFound (no images) or Transient (schema): fall through to search. + } + // HTTP failure (404/5xx/offline): fall through to search. + } + const std::string url = buildSearchUrl(name, setId, setNo); auto resp = http_.get(url); if (!resp) return R::err({K::Transient, resp.error()}); diff --git a/core/src/games/pokemon/PokemonSetSource.cpp b/core/src/games/pokemon/PokemonSetSource.cpp index 6df985c..7e23c5a 100644 --- a/core/src/games/pokemon/PokemonSetSource.cpp +++ b/core/src/games/pokemon/PokemonSetSource.cpp @@ -1,12 +1,35 @@ #include "ccm/games/pokemon/PokemonSetSource.hpp" +#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" + #include #include #include +#include +#include +#include namespace ccm { +namespace { + +void finalizeCatalog(PokemonSetCatalog& catalog) { + for (auto& pack : catalog.packs) { + std::sort(pack.cards.begin(), pack.cards.end(), + [](const PokemonCatalogCard& a, const PokemonCatalogCard& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + } + std::sort(catalog.packs.begin(), catalog.packs.end(), + [](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) { + return a.setName < b.setName; + }); +} + +} // namespace + PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {} Result> PokemonSetSource::parseResponse(const std::string& body) { @@ -36,10 +59,137 @@ Result> PokemonSetSource::parseResponse(const std::string& body } } +std::string PokemonSetSource::buildCardsPageUrl(int page, int pageSize) { + return std::string(kCardsEndpoint) + "?select=name,number,set&pageSize=" + + std::to_string(pageSize) + "&page=" + std::to_string(page); +} + +Result +PokemonSetSource::mergeCardsPage(const std::string& body, + PokemonSetCatalog& catalog, + const std::vector& sets) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("data") || !j.at("data").is_array()) { + return Result::err( + "Pokemon TCG cards response missing 'data' array."); + } + + std::unordered_map idToName; + idToName.reserve(sets.size()); + for (const auto& set : sets) { + if (!set.id.empty()) idToName.emplace(set.id, set.name); + } + + // Index existing packs for multi-page merges. + std::unordered_map packIndex; + for (std::size_t i = 0; i < catalog.packs.size(); ++i) { + packIndex.emplace(catalog.packs[i].setId, i); + } + std::vector> seenByPack(catalog.packs.size()); + for (std::size_t i = 0; i < catalog.packs.size(); ++i) { + for (const auto& card : catalog.packs[i].cards) { + seenByPack[i].insert(card.setNo); + } + } + + for (const auto& entry : j.at("data")) { + const std::string name = entry.value("name", ""); + const std::string number = + PokemonCardPreviewSource::normalizeCollectorNumber(entry.value("number", "")); + if (name.empty() || number.empty()) continue; + + std::string setId; + std::string setName; + if (entry.contains("set") && entry.at("set").is_object()) { + setId = entry.at("set").value("id", ""); + setName = entry.at("set").value("name", ""); + } + if (setId.empty()) continue; + if (const auto it = idToName.find(setId); it != idToName.end() && !it->second.empty()) { + setName = it->second; + } + if (setName.empty()) setName = setId; + + auto pit = packIndex.find(setId); + if (pit == packIndex.end()) { + PokemonSetCatalogPack pack; + pack.setId = setId; + pack.setName = setName; + pack.cards.push_back(PokemonCatalogCard{number, name}); + packIndex.emplace(setId, catalog.packs.size()); + seenByPack.emplace_back(std::unordered_set{number}); + catalog.packs.push_back(std::move(pack)); + continue; + } + + const std::size_t idx = pit->second; + if (!seenByPack[idx].insert(number).second) continue; + if (catalog.packs[idx].setName.empty() && !setName.empty()) { + catalog.packs[idx].setName = setName; + } + catalog.packs[idx].cards.push_back(PokemonCatalogCard{number, name}); + } + + CardsPageMeta meta; + meta.page = j.value("page", 1); + meta.pageSize = j.value("pageSize", kCardsPageSize); + meta.count = j.value("count", static_cast(j.at("data").size())); + meta.totalCount = j.value("totalCount", meta.count); + return Result::ok(meta); + } catch (const std::exception& e) { + return Result::err( + std::string("Pokemon TCG cards JSON parse error: ") + e.what()); + } +} + +Result PokemonSetSource::parseCatalog(const std::string& body, + const std::vector& sets) { + PokemonSetCatalog catalog; + auto meta = mergeCardsPage(body, catalog, sets); + if (!meta) return Result::err(meta.error()); + finalizeCatalog(catalog); + return Result::ok(std::move(catalog)); +} + Result> PokemonSetSource::fetchAll() { auto resp = http_.get(kEndpoint); if (!resp) return Result>::err(resp.error()); return parseResponse(resp.value()); } +Result PokemonSetSource::fetchAllWithCatalog() { + auto setsResp = http_.get(kEndpoint); + if (!setsResp) return Result::err(setsResp.error()); + auto sets = parseResponse(setsResp.value()); + if (!sets) return Result::err(sets.error()); + + PokemonSetCatalog catalog; + int page = 1; + int totalCount = 0; + int fetched = 0; + for (;;) { + auto cardsResp = http_.get(buildCardsPageUrl(page)); + if (!cardsResp) return Result::err(cardsResp.error()); + auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value()); + if (!meta) return Result::err(meta.error()); + + fetched += meta.value().count; + totalCount = meta.value().totalCount; + if (meta.value().count <= 0 || fetched >= totalCount) break; + ++page; + // Safety: avoid unbounded loops if the API lies about totals. + if (page > 10000) { + return Result::err( + "Pokemon TCG cards pagination exceeded safety limit."); + } + } + + finalizeCatalog(catalog); + FetchWithCatalog out; + out.sets = std::move(sets).value(); + out.catalog = std::move(catalog); + return Result::ok(std::move(out)); +} + } // namespace ccm diff --git a/core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp b/core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp index 160551a..bcc7464 100644 --- a/core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp +++ b/core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp @@ -144,6 +144,20 @@ bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noe return it != printKeysBySet_.end() && !it->second.empty(); } +std::vector +JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const { + std::vector out; + const auto keysIt = printKeysBySet_.find(std::string(setId)); + if (keysIt == printKeysBySet_.end()) return out; + out.reserve(keysIt->second.size()); + for (const auto& key : keysIt->second) { + const auto pit = printsByKey_.find(key); + if (pit == printsByKey_.end()) continue; + out.push_back(pit->second); + } + return out; +} + std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) { if (productId.empty()) return {}; return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") + diff --git a/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp b/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp index a04b211..27bcca9 100644 --- a/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp +++ b/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp @@ -1,5 +1,6 @@ #include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" #include "ccm/util/Rfc3986.hpp" #include @@ -8,6 +9,8 @@ #include #include #include +#include +#include namespace ccm { @@ -55,6 +58,41 @@ const std::unordered_map& setNameJaOverrides() { return false; } +void gapFillFromEnCatalog(PokemonSetCatalogPack& pack, + const JapanesePokemonEnCatalog& enCatalog) { + std::unordered_set seen; + for (const auto& card : pack.cards) { + seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo)); + } + for (const auto& print : enCatalog.printsForSet(pack.setId)) { + const std::string localId = + JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId); + if (localId.empty() || !seen.insert(localId).second) continue; + std::string name = print.nameEn; + if (name.empty()) name = print.nameJa; + if (name.empty()) name = localId; + pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)}); + } +} + +void sortPackCards(PokemonSetCatalogPack& pack) { + std::sort(pack.cards.begin(), pack.cards.end(), + [](const PokemonCatalogCard& a, const PokemonCatalogCard& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); +} + +void applyEnglishSetName(Set& s, const JapanesePokemonEnCatalog& catalog) { + if (auto en = catalog.findSet(s.id)) { + if (!en->nameEn.empty()) s.name = en->nameEn; + if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate; + } + if (s.name.empty() || containsCjk(s.name)) { + s.name = s.id; + } +} + } // namespace JapanesePokemonSetSource::JapanesePokemonSetSource( @@ -152,6 +190,70 @@ JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) { } } +PokemonSetCatalogPack JapanesePokemonSetSource::catalogPackFromEnCatalog( + const Set& set, const JapanesePokemonEnCatalog& enCatalog) { + PokemonSetCatalogPack pack; + pack.setId = set.id; + pack.setName = set.name.empty() ? set.id : set.name; + for (const auto& print : enCatalog.printsForSet(set.id)) { + const std::string localId = + JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId); + if (localId.empty()) continue; + std::string name = print.nameEn; + if (name.empty()) name = print.nameJa; + if (name.empty()) name = localId; + pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)}); + } + sortPackCards(pack); + return pack; +} + +Result JapanesePokemonSetSource::parseCatalogPackFromSetDetail( + const std::string& detailBody, + const Set& set, + const JapanesePokemonEnCatalog& enCatalog) { + auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody); + if (!rows) { + // Transient/NotFound from parse — treat empty cards as catalog-only. + if (rows.error().kind == PreviewLookupError::Kind::NotFound) { + auto pack = catalogPackFromEnCatalog(set, enCatalog); + if (pack.cards.empty()) { + return Result::err( + "No cards for set " + set.id); + } + return Result::ok(std::move(pack)); + } + return Result::err(rows.error().message); + } + + PokemonSetCatalogPack pack; + pack.setId = set.id; + pack.setName = set.name.empty() ? set.id : set.name; + + std::unordered_set seen; + for (const auto& row : rows.value()) { + const std::string localId = + JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId); + if (localId.empty() || !seen.insert(localId).second) continue; + + std::string name; + if (auto print = enCatalog.findPrint(set.id, localId)) { + name = print->nameEn; + if (name.empty()) name = print->nameJa; + } + if (name.empty()) name = row.nameJa; + if (name.empty()) name = localId; + pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)}); + } + + gapFillFromEnCatalog(pack, enCatalog); + sortPackCards(pack); + if (pack.cards.empty()) { + return Result::err("No cards for set " + set.id); + } + return Result::ok(std::move(pack)); +} + Result> JapanesePokemonSetSource::fetchAll() { auto listResp = http_.get(kListEndpoint); if (!listResp) return Result>::err(listResp.error()); @@ -163,13 +265,7 @@ Result> JapanesePokemonSetSource::fetchAll() { for (auto& s : out) { // Prefer catalog English; never leave Japanese TCGdex names in Set.name // (the set picker must stay English-only). - if (auto en = catalog_.findSet(s.id)) { - if (!en->nameEn.empty()) s.name = en->nameEn; - if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate; - } - if (s.name.empty() || containsCjk(s.name)) { - s.name = s.id; - } + applyEnglishSetName(s, catalog_); if (!s.releaseDate.empty()) continue; auto detail = http_.get(buildSetDetailUrl(s.id)); @@ -185,19 +281,67 @@ Result> JapanesePokemonSetSource::fetchAll() { return Result>::ok(std::move(out)); } +Result +JapanesePokemonSetSource::fetchAllWithCatalog() { + auto listResp = http_.get(kListEndpoint); + if (!listResp) return Result::err(listResp.error()); + + auto parsed = parseListResponse(listResp.value()); + if (!parsed) return Result::err(parsed.error()); + + std::vector sets = std::move(parsed).value(); + PokemonSetCatalog catalog; + catalog.packs.reserve(sets.size()); + + for (auto& s : sets) { + applyEnglishSetName(s, catalog_); + + auto detail = http_.get(buildSetDetailUrl(s.id)); + if (!detail) { + // Classic / catalog-only products often have no TCGdex detail. + auto pack = catalogPackFromEnCatalog(s, catalog_); + if (!pack.cards.empty()) { + catalog.packs.push_back(std::move(pack)); + } + continue; + } + + if (s.releaseDate.empty()) { + auto date = parseReleaseDate(detail.value()); + if (date && !date.value().empty()) { + s.releaseDate = std::move(date).value(); + } + } + + auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_); + if (pack) { + catalog.packs.push_back(std::move(pack).value()); + } else { + auto fallback = catalogPackFromEnCatalog(s, catalog_); + if (!fallback.cards.empty()) { + catalog.packs.push_back(std::move(fallback)); + } + } + } + + std::sort(sets.begin(), sets.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + std::sort(catalog.packs.begin(), catalog.packs.end(), + [](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) { + return a.setName < b.setName; + }); + + FetchWithCatalog out; + out.sets = std::move(sets); + out.catalog = std::move(catalog); + return Result::ok(std::move(out)); +} + void JapanesePokemonSetSource::augmentCachedSets(std::vector& sets) const { // Stale caches may store set ids (or Japanese) as Set.name — re-apply the // bundled EN catalog so names like "Pokémon Jungle" are searchable again. for (auto& s : sets) { - if (auto en = catalog_.findSet(s.id)) { - if (!en->nameEn.empty()) s.name = en->nameEn; - if (!en->releaseDate.empty() && s.releaseDate.empty()) { - s.releaseDate = en->releaseDate; - } - } - if (s.name.empty() || containsCjk(s.name)) { - s.name = s.id; - } + applyEnglishSetName(s, catalog_); } appendMissingClassicProducts(sets); std::sort(sets.begin(), sets.end(), diff --git a/core/src/services/PokemonSetCatalogService.cpp b/core/src/services/PokemonSetCatalogService.cpp new file mode 100644 index 0000000..1c9452d --- /dev/null +++ b/core/src/services/PokemonSetCatalogService.cpp @@ -0,0 +1,54 @@ +#include "ccm/services/PokemonSetCatalogService.hpp" + +#include + +#include + +namespace ccm { + +namespace fs = std::filesystem; + +PokemonSetCatalogService::PokemonSetCatalogService(IFileSystem& fs, + ConfigService& config, + DirNameFn dirName) + : fs_(fs), config_(config), dirName_(std::move(dirName)) {} + +fs::path PokemonSetCatalogService::catalogPath(PokemonRegion region) const { + const char* file = region == PokemonRegion::Asia ? "set-catalog-asia.json" + : "set-catalog-west.json"; + return fs::path(config_.current().dataStorage) / dirName_(Game::Pokemon) / file; +} + +bool PokemonSetCatalogService::exists(PokemonRegion region) const { + return fs_.exists(catalogPath(region)); +} + +Result PokemonSetCatalogService::load(PokemonRegion region) const { + const auto p = catalogPath(region); + if (!fs_.exists(p)) { + return Result::err( + region == PokemonRegion::Asia + ? "Asia Pokemon set catalog not yet downloaded." + : "West Pokemon set catalog not yet downloaded."); + } + auto text = fs_.readText(p); + if (!text) return Result::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 PokemonSetCatalogService::save(PokemonRegion region, + const PokemonSetCatalog& catalog) { + const auto p = catalogPath(region); + auto dir = fs_.ensureDirectory(p.parent_path()); + if (!dir) return dir; + const nlohmann::json j = catalog; + return fs_.writeText(p, j.dump(2)); +} + +} // namespace ccm diff --git a/core/src/services/PokemonSetCompletion.cpp b/core/src/services/PokemonSetCompletion.cpp new file mode 100644 index 0000000..959334e --- /dev/null +++ b/core/src/services/PokemonSetCompletion.cpp @@ -0,0 +1,190 @@ +#include "ccm/services/PokemonSetCompletion.hpp" + +#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" + +#include +#include +#include +#include + +namespace ccm { + +namespace { + +using OwnedBySet = std::unordered_map>; + +bool passesLanguageFilter(const PokemonCard& card, std::optional languageFilter) { + return !languageFilter.has_value() || card.language == *languageFilter; +} + +bool passesRegionFilter(const PokemonCard& card, std::optional regionFilter) { + return !regionFilter.has_value() || card.region == *regionFilter; +} + +std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) { + if (region == PokemonRegion::Asia) { + return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo); + } + return PokemonCardPreviewSource::normalizeCollectorNumber(setNo); +} + +OwnedBySet ownedSetNosBySetId(const std::vector& collection, + PokemonRegion region, + std::optional languageFilter) { + OwnedBySet out; + for (const auto& card : collection) { + if (card.region != region) continue; + if (!passesLanguageFilter(card, languageFilter)) continue; + if (card.set.id.empty()) continue; + const std::string setNo = normalizeForRegion(region, card.setNo); + if (setNo.empty()) continue; + out[card.set.id].insert(setNo); + } + return out; +} + +std::vector +computeForCatalog(const std::vector& collection, + const PokemonSetCatalog& catalog, + PokemonRegion region, + std::optional languageFilter) { + const OwnedBySet owned = ownedSetNosBySetId(collection, region, languageFilter); + + 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 = normalizeForRegion(region, card.setNo); + if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched; + } + + PokemonSetCompletionProgress row; + row.region = region; + row.setId = pack->setId; + row.setName = pack->setName; + row.ownedUnique = matched; + row.total = pack->cards.size(); + out.push_back(std::move(row)); + } + return out; +} + +} // namespace + +std::vector +pokemonLanguagesInCollection(const std::vector& collection, + std::optional regionFilter) { + const auto& langs = allLanguages(); + std::array present{}; + for (const auto& card : collection) { + if (!passesRegionFilter(card, regionFilter)) continue; + for (std::size_t i = 0; i < langs.size(); ++i) { + if (langs[i] == card.language) { + present[i] = true; + break; + } + } + } + + std::vector out; + for (std::size_t i = 0; i < langs.size(); ++i) { + if (present[i]) out.push_back(langs[i]); + } + return out; +} + +std::vector +pokemonRegionsInCollection(const std::vector& collection, + const PokemonSetCatalog& westCatalog, + const PokemonSetCatalog& asiaCatalog) { + std::vector out; + const auto westRows = + computeForCatalog(collection, westCatalog, PokemonRegion::West, std::nullopt); + if (!westRows.empty()) out.push_back(PokemonRegion::West); + const auto asiaRows = + computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, std::nullopt); + if (!asiaRows.empty()) out.push_back(PokemonRegion::Asia); + return out; +} + +std::vector +computePokemonSetCompletion(const std::vector& collection, + const PokemonSetCatalog& westCatalog, + const PokemonSetCatalog& asiaCatalog, + std::optional regionFilter, + std::optional languageFilter) { + std::vector out; + + const bool includeWest = + !regionFilter.has_value() || *regionFilter == PokemonRegion::West; + const bool includeAsia = + !regionFilter.has_value() || *regionFilter == PokemonRegion::Asia; + + if (includeWest) { + auto west = computeForCatalog(collection, westCatalog, PokemonRegion::West, + languageFilter); + out.insert(out.end(), std::make_move_iterator(west.begin()), + std::make_move_iterator(west.end())); + } + if (includeAsia) { + auto asia = computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, + languageFilter); + out.insert(out.end(), std::make_move_iterator(asia.begin()), + std::make_move_iterator(asia.end())); + } + + std::sort(out.begin(), out.end(), + [](const PokemonSetCompletionProgress& a, + const PokemonSetCompletionProgress& b) { + if (a.setName != b.setName) return a.setName < b.setName; + return static_cast(a.region) < static_cast(b.region); + }); + return out; +} + +std::vector +pokemonChecklistForSet(const std::vector& collection, + const PokemonSetCatalog& westCatalog, + const PokemonSetCatalog& asiaCatalog, + PokemonRegion region, + std::string_view setId, + std::optional languageFilter) { + const PokemonSetCatalog& catalog = + region == PokemonRegion::Asia ? asiaCatalog : westCatalog; + const auto* pack = catalog.findPack(setId); + if (pack == nullptr) return {}; + + std::unordered_set ownedNos; + for (const auto& card : collection) { + if (card.region != region) continue; + if (!passesLanguageFilter(card, languageFilter)) continue; + if (card.set.id != setId) continue; + const std::string setNo = normalizeForRegion(region, card.setNo); + if (!setNo.empty()) ownedNos.insert(setNo); + } + + std::vector out; + out.reserve(pack->cards.size()); + for (const auto& card : pack->cards) { + PokemonChecklistEntry entry; + entry.setNo = normalizeForRegion(region, card.setNo); + entry.name = card.name; + entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0; + out.push_back(std::move(entry)); + } + + std::sort(out.begin(), out.end(), + [](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + return out; +} + +} // namespace ccm diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index 7611b6d..d79fe17 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -25,14 +25,25 @@ Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects **Info API:** `https://api.pokemontcg.io/v2/sets` Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date. -**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...` +**Asset API:** `https://api.pokemontcg.io/v2/cards` (by id) and `https://api.pokemontcg.io/v2/cards?q=...` (search) Used by `PokemonCardPreviewSource` in two ways: -1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed. +1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/cards/{setId}-{number}` (single-card `data` object) — same idea as Asia’s direct localId fetch — so Lucene `name:` ∩ `number:` misses cannot blank the preview after Auto-detect fills Set #. On HTTP failure or missing images, falls back to a name-less search `set.id:… number:…` (collector numbers are unique within a set). When Set # or set id is missing, keeps the older `name:"…"` search with optional `set.id` / `number`. The parser takes `images.large` first and falls back to `images.small`. -2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:""` and `set.id:` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the picker’s **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the card’s `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save. +2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the search endpoint with `name:""` and `set.id:` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the picker’s **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the card’s `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save. -The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component. +The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query and card-id path expect only the printed-number component (unquoted `number:4` / `number:TG14`; do not wrap alphanumeric numbers in Lucene quotes when combining with other clauses — that has been observed to 500 on the live API). + +### Set-completion catalog (West) + +**Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes: + +1. The set list (`pokemon/sets-west.json`) from `/v2/sets` (same as before) +2. A pack checklist at `/pokemon/set-catalog-west.json` from a paginated `/v2/cards?select=name,number,set&pageSize=250` dump + +Each catalog pack stores `id` (pokemontcg.io set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by the API `number` field (normalized by stripping anything after `/`). Duplicate collector numbers within a pack collapse to one checklist row. The Pokemon **Set Completion** tab reads this file offline; ownership for a West pack requires `PokemonRegion::West`, matching `card.set.id`, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts. + +If `set-catalog-west.json` is missing (and the active region filter is West or All with no Asia catalog either), the Set Completion tab prompts the user to run Update Pokemon. ## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck) @@ -170,6 +181,19 @@ Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemo Seed data lives in `tools/pokemon_jp/classic_missing_sets.json` + `classic_missing_prints.json` (merged into the EN catalog via `merge_classic_missing.py`). LocalIds for these products are sequential `001`… within each product (cards were unnumbered in print). Refresh `UnnumberedPromo` prints from Bulbapedia with `python tools/pokemon_jp/harvest_unnumbered_promos.py`, then fill preview images with `python tools/pokemon_jp/enrich_unnumbered_promo_images.py` (prefers Unnumbered / Japanese reprint-gallery scans over English Wizards `|image=` primaries; EN-only Bulbapedia pages leave `image_url` empty), then re-run `merge_classic_missing.py`. Numbered Japanese promo eras (`SV-P`, `S-P`, …) remain out of scope — TCGdex does not expose them, and they are not part of this curated set. +### Set-completion catalog (Asia) + +**Sets → Update Pokemon** uses `JapanesePokemonSetSource::fetchAllWithCatalog()` so the Asia path writes: + +1. The set list (`pokemon/sets-asia.json`) as above (EN names + classic product injection) +2. A pack checklist at `/pokemon/set-catalog-asia.json` + +For each set, the source `GET`s `/v2/ja/sets/{id}` and builds checklist rows from `cards[]` (`localId` → `setNo`, display name prefers EN catalog `nameEn`, else TCGdex Japanese `name`). Prints present in the bundled EN catalog but missing from TCGdex `cards[]` are **gap-filled** into the pack (covers UnnumberedPromo / City Gym / Expansion Sheets / Southern Islands and sparse classic sets). Catalog-only products with no TCGdex detail become packs entirely from `JapanesePokemonEnCatalog::printsForSet`. + +The Pokemon **Set Completion** tab also loads this file offline; ownership for an Asia pack requires `PokemonRegion::Asia`, matching `card.set.id`, and `normalizeLocalId` on `setNo`. Region and language filters on the tab restrict which packs/cards count. West and Asia never cross-count. + +If `set-catalog-asia.json` is missing (and the active region filter needs it), the Set Completion tab prompts the user to run Update Pokemon. + ### Sets without printed collector numbers (`UnnumberedPromo`) Physically unnumbered Japanese promos (and the other classic catalog-only products above) have **no printed set number**. The app still stores a synthetic `setNo` / catalog `local_id` (`001`, `002`, …) so preview and collection JSON stay keyed by `(setId, localId)` — but that value must not be treated as something the user can read off the card. @@ -297,6 +321,6 @@ All source types return `Result` errors so failures cross bounda - info API failures (bad set payload, schema mismatch, endpoint/network failure), and - asset API failures (query mismatch, no matching card, missing image fields, image download failure). -When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages..imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text. +When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon West (`GET /v2/cards/{setId}-{number}` → `data` object, or search `data[]`; `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages..imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text. For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 124def0..23c6728 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -19,11 +19,12 @@ - `card_preview_service_tests.cpp` — `CardPreviewService` registry/orchestration through `registerModule(IGameModule&)` with an inline `FakeGameModule` returning a `FakeSource : ICardPreviewSource` (which carries a `PreviewLookupError::Kind` knob so tests can drive both transient and not-found paths) and a `FixedHttpClient`. Both fakes count `calls` so cache-hit assertions are precise. Pin-downs include: "module returning nullptr is silently skipped", the per-game `detectFirstPrint` / `detectPrintVariants` opt-in guards, and the LRU bytes cache (repeat `fetchPreviewBytes` for the same `(game, name, setId, setNo)` returns the cached payload without touching the source or HTTP; different cards get separate cache slots; transient errors are **not** cached so a flaky connection recovers; `fetchImageBytesByUrl` is keyed by URL and serves the per-game card-back fallback from the same LRU). Production `fetchAndCache` rejects empty HTTP bodies (not exercised by these fakes unless a test sets an empty `body` deliberately). The negative-cache behavior is also pinned down: a `NotFound` source error writes through to the persistent cache *and* short-circuits the next lookup (source not re-invoked); editing a lookup-relevant field invalidates the negative entry automatically; warm-restart (a fresh service over the same cache fake) honors a previously stored negative entry; and a later positive result for the same key replaces the negative entry. The persistent-tier wiring uses an inline `InMemoryByteCache : IPreviewByteCache` fake whose `Entry { negative, payload }` carries the kind explicitly. - `local_preview_byte_cache_tests.cpp` — `LocalPreviewByteCache` adapter against `StdFileSystem` (real disk under a unique `temp_directory_path()/ccm_preview_cache_test_*` per case, RAII `TempDir` cleanup; see also `std_file_system_tests.cpp`). Pin-downs: store/load round-trips bytes verbatim; missing key is a clean miss; empty payload is silently skipped; sidecar mismatch (faked hash collision) is treated as a miss so we never serve the wrong card's bytes (or wrong card's negative verdict); the cache survives an adapter restart over the same directory; total-size eviction drops the oldest `.bin` by mtime when a `store` would exceed the cap; a `load` touches the entry's mtime so frequently-viewed cards survive eviction. Negative-entry coverage: `storeNegative` round-trips as `NegativeHit` (not a miss, not a payload, and not counted against the byte cap); negatives survive an adapter restart; a later positive `store` overwrites a previous negative and a later `storeNegative` overwrites a previous positive (releasing its bytes from the cap); and the sidecar collision check applies to negative entries too. - `std_file_system_tests.cpp` — `StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests. -- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL. -- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`. +- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. `parseCatalog` / `mergeCardsPage` for set-completion checklists. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL. +- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (name-less `set.id`+`number` when both present; `name:` when Set # empty; collector-number `4/102` -> `4` normalization), `buildCardByIdUrl`, `parseResponse` / `parseCardByIdResponse`, and `fetchImageUrl` (card-by-id first, search fallback) via `FixedHttpClient`. - `digibattle99_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`. - `digibattle99_set_completion_tests.cpp` — `computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`. - `yugioh_set_completion_tests.cpp` — `computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`. +- `pokemon_set_completion_tests.cpp` — `computePokemonSetCompletion` / `pokemonChecklistForSet` West/Asia ownership isolation + region/language filters + `PokemonSetCatalogService` dual-path FS round-trip. - `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`. - `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. Also `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist from `cardinfo.php`. - `yugioh_set_lookup_tests.cpp` — `lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous). diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 40388f2..6fd00c1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(ccm_core_tests digibattle99_card_preview_source_tests.cpp digibattle99_set_completion_tests.cpp yugioh_set_completion_tests.cpp + pokemon_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/domain_json_tests.cpp b/tests/domain_json_tests.cpp index 8d452c3..9106c94 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -3,6 +3,7 @@ #include "ccm/domain/Configuration.hpp" #include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/DigiBattle99SetCatalog.hpp" +#include "ccm/domain/PokemonSetCatalog.hpp" #include "ccm/domain/YuGiOhSetCatalog.hpp" #include "ccm/domain/Enums.hpp" #include "ccm/domain/JapanesePokemonCard.hpp" @@ -291,6 +292,28 @@ TEST_SUITE("YuGiOhSetCatalog JSON") { } } +TEST_SUITE("PokemonSetCatalog JSON") { + TEST_CASE("round-trips packs and setNo alias") { + PokemonSetCatalog catalog; + PokemonSetCatalogPack pack; + pack.setId = "base1"; + pack.setName = "Base"; + pack.cards.push_back(PokemonCatalogCard{"4", "Charizard"}); + pack.cards.push_back(PokemonCatalogCard{"58", "Growlithe"}); + catalog.packs.push_back(std::move(pack)); + + nlohmann::json j = catalog; + CHECK(j.at("packs").is_array()); + CHECK(j.at("packs").at(0).at("id") == "base1"); + CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "4"); + + const PokemonSetCatalog back = j.get(); + CHECK(back == catalog); + CHECK(back.findPack("base1") != nullptr); + CHECK(back.findPack("missing") == nullptr); + } +} + TEST_SUITE("JapanesePokemonCard JSON") { TEST_CASE("uses 'setNo' and 'firstEdition' aliases") { JapanesePokemonCard c; diff --git a/tests/japanese_pokemon_en_catalog_tests.cpp b/tests/japanese_pokemon_en_catalog_tests.cpp index 06cd155..f8678a3 100644 --- a/tests/japanese_pokemon_en_catalog_tests.cpp +++ b/tests/japanese_pokemon_en_catalog_tests.cpp @@ -139,6 +139,21 @@ TEST_SUITE("JapanesePokemonEnCatalog") { CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1")); } + TEST_CASE("printsForSet returns all prints for a set id") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"A","local_id":"1","name_en":"One"}, + {"set_id":"A","local_id":"2","name_en":"Two"}, + {"set_id":"B","local_id":"1","name_en":"Other"} + ] + })"); + REQUIRE(catalog.isOk()); + const auto prints = catalog.value().printsForSet("A"); + REQUIRE(prints.size() == 2); + CHECK(catalog.value().printsForSet("missing").empty()); + } + TEST_CASE("missing set/print returns nullopt") { JapanesePokemonEnCatalog empty; CHECK_FALSE(empty.findSet("X").has_value()); diff --git a/tests/japanese_pokemon_set_source_tests.cpp b/tests/japanese_pokemon_set_source_tests.cpp index 001c13c..816f809 100644 --- a/tests/japanese_pokemon_set_source_tests.cpp +++ b/tests/japanese_pokemon_set_source_tests.cpp @@ -315,3 +315,56 @@ TEST_SUITE("JapanesePokemonSetSource::fetchAll") { CHECK(jungle->name == "Pokémon Jungle"); } } + +TEST_SUITE("JapanesePokemonSetSource::parseCatalogPackFromSetDetail") { + TEST_CASE("builds checklist from cards[] and prefers EN catalog names") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック"}}, + "prints": [ + {"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"}, + {"set_id":"PMCG1","local_id":"099","name_en":"Catalog Only","name_ja":""} + ] + })"); + REQUIRE(catalog.isOk()); + Set set; + set.id = "PMCG1"; + set.name = "Expansion Pack"; + const std::string detail = R"({ + "id":"PMCG1", + "name":"拡張パック", + "cards":[ + {"localId":"001","name":"ヒトカゲ"}, + {"localId":"002","name":"リザード"} + ] + })"; + const auto pack = JapanesePokemonSetSource::parseCatalogPackFromSetDetail( + detail, set, catalog.value()); + REQUIRE(pack.isOk()); + REQUIRE(pack.value().cards.size() == 3); + CHECK(pack.value().cards[0].setNo == "001"); + CHECK(pack.value().cards[0].name == "Charmander"); + CHECK(pack.value().cards[1].setNo == "002"); + CHECK(pack.value().cards[1].name == "リザード"); + CHECK(pack.value().cards[2].setNo == "099"); + CHECK(pack.value().cards[2].name == "Catalog Only"); + } + + TEST_CASE("catalogPackFromEnCatalog covers classic-only products") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu"}, + {"set_id":"UnnumberedPromo","local_id":"002","name_en":"Mewtwo"} + ] + })"); + REQUIRE(catalog.isOk()); + Set set; + set.id = "UnnumberedPromo"; + set.name = "Unnumbered Promotional cards"; + const auto pack = + JapanesePokemonSetSource::catalogPackFromEnCatalog(set, catalog.value()); + REQUIRE(pack.cards.size() == 2); + CHECK(pack.cards[0].setNo == "001"); + CHECK(pack.cards[1].setNo == "002"); + } +} diff --git a/tests/pokemon_card_preview_source_tests.cpp b/tests/pokemon_card_preview_source_tests.cpp index 9ca7c5c..2970fae 100644 --- a/tests/pokemon_card_preview_source_tests.cpp +++ b/tests/pokemon_card_preview_source_tests.cpp @@ -34,10 +34,13 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") { CHECK(url.find("number") == std::string::npos); } - TEST_CASE("setNo is appended as a number: clause") { + TEST_CASE("setId plus setNo omits name to avoid Lucene name-number misses") { const auto url = PokemonCardPreviewSource::buildSearchUrl( "Charizard", "base1", "4"); CHECK(url.find("number%3A4") != std::string::npos); + CHECK(url.find("set.id%3Abase1") != std::string::npos); + CHECK(url.find("name") == std::string::npos); + CHECK(url.find("Charizard") == std::string::npos); } TEST_CASE("setNo with a slash is normalized to the printed number") { @@ -47,6 +50,7 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") { "Charizard", "base1", "4/102"); CHECK(url.find("number%3A4") != std::string::npos); CHECK(url.find("102") == std::string::npos); + CHECK(url.find("name") == std::string::npos); } TEST_CASE("name with spaces is percent-encoded") { @@ -55,14 +59,34 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") { CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos); } - TEST_CASE("empty setId omits the set.id clause") { + TEST_CASE("empty setId keeps name and appends number") { const auto url = PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25"); CHECK(url.find("set.id") == std::string::npos); + CHECK(url.find("%22Pikachu%22") != std::string::npos); CHECK(url.find("number%3A25") != std::string::npos); } } +TEST_SUITE("PokemonCardPreviewSource::buildCardByIdUrl") { + TEST_CASE("joins setId and normalized number with a hyphen") { + const auto url = PokemonCardPreviewSource::buildCardByIdUrl("base1", "4"); + CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4"); + } + + TEST_CASE("percent-encodes alphanumeric collector numbers") { + const auto url = + PokemonCardPreviewSource::buildCardByIdUrl("swsh12tg", "TG14"); + CHECK(url == "https://api.pokemontcg.io/v2/cards/swsh12tg-TG14"); + } + + TEST_CASE("strips slash form before building the id") { + const auto url = + PokemonCardPreviewSource::buildCardByIdUrl("base1", "4/102"); + CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4"); + } +} + TEST_SUITE("PokemonCardPreviewSource::parseResponse") { TEST_CASE("returns images.large when present") { const std::string json = R"({ @@ -147,6 +171,50 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") { } } +TEST_SUITE("PokemonCardPreviewSource::parseCardByIdResponse") { + TEST_CASE("returns images.large from data object") { + const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({ + "data": { + "id": "base1-4", + "images": { + "small": "https://images.pokemontcg.io/small.png", + "large": "https://images.pokemontcg.io/large.png" + } + } + })"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://images.pokemontcg.io/large.png"); + } + + TEST_CASE("falls back to images.small when large is absent") { + const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({ + "data": {"images":{"small":"https://small.only/img.png"}} + })"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://small.only/img.png"); + } + + TEST_CASE("missing images is NotFound") { + const auto out = PokemonCardPreviewSource::parseCardByIdResponse( + R"({"data":{"id":"base1-4","name":"Charizard"}})"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); + } + + TEST_CASE("data array shape is Transient") { + const auto out = + PokemonCardPreviewSource::parseCardByIdResponse(R"({"data":[]})"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } + + TEST_CASE("invalid JSON is Transient") { + const auto out = PokemonCardPreviewSource::parseCardByIdResponse("{not json"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } +} + TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") { TEST_CASE("network error is surfaced as Transient") { FixedHttpClient http; @@ -157,17 +225,54 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") { CHECK(out.error().kind == PreviewLookupError::Kind::Transient); } - TEST_CASE("network success is parsed end-to-end and uses the encoded URL") { + TEST_CASE("with setNo prefers card-by-id endpoint") { + FixedHttpClient http; + http.ok = true; + http.body = R"({"data":{"images":{"large":"https://l/by-id.png"}}})"; + PokemonCardPreviewSource src{http}; + const auto out = src.fetchImageUrl("Pikachu", "base1", "25"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://l/by-id.png"); + CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/cards/base1-25"); + } + + TEST_CASE("falls back to name-less search when card-by-id HTTP fails") { + class RoutingHttp final : public IHttpClient { + public: + int calls = 0; + std::string lastUrl; + Result get(std::string_view url) override { + lastUrl = std::string(url); + ++calls; + if (url.find("/v2/cards?") == std::string::npos) { + return Result::err("HTTP 404 from card id"); + } + return Result::ok( + R"({"data":[{"images":{"large":"https://l/search.png"}}]})"); + } + } http; + + PokemonCardPreviewSource src{http}; + const auto out = src.fetchImageUrl("Charizard", "base1", "4"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://l/search.png"); + CHECK(http.calls == 2); + CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos); + CHECK(http.lastUrl.find("number%3A4") != std::string::npos); + CHECK(http.lastUrl.find("name") == std::string::npos); + } + + TEST_CASE("empty setNo uses name search without card-by-id") { FixedHttpClient http; http.ok = true; http.body = R"({"data":[{"images":{"large":"https://l/x.png"}}]})"; PokemonCardPreviewSource src{http}; - const auto out = src.fetchImageUrl("Pikachu", "base1", "25"); + const auto out = src.fetchImageUrl("Pikachu", "base1", ""); REQUIRE(out.isOk()); CHECK(out.value() == "https://l/x.png"); CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos); CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos); - CHECK(http.lastUrl.find("number%3A25") != std::string::npos); + CHECK(http.lastUrl.find("/v2/cards/base1-") == std::string::npos); } } diff --git a/tests/pokemon_set_completion_tests.cpp b/tests/pokemon_set_completion_tests.cpp new file mode 100644 index 0000000..f2c6681 --- /dev/null +++ b/tests/pokemon_set_completion_tests.cpp @@ -0,0 +1,263 @@ +#include + +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/PokemonSetCatalog.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/services/PokemonSetCatalogService.hpp" +#include "ccm/services/PokemonSetCompletion.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; +} + +PokemonCard makeOwned(PokemonRegion region, std::string setId, std::string setNo) { + PokemonCard c; + c.id = 1; + c.name = "Owned"; + c.region = region; + c.set.id = std::move(setId); + c.set.name = "Set"; + c.setNo = std::move(setNo); + c.language = Language::English; + return c; +} + +PokemonSetCatalog westCatalog() { + PokemonSetCatalog catalog; + PokemonSetCatalogPack base; + base.setId = "base1"; + base.setName = "Base"; + base.cards = { + {"4", "Charizard"}, + {"58", "Growlithe"}, + {"59", "Arcanine"}, + }; + catalog.packs.push_back(std::move(base)); + return catalog; +} + +PokemonSetCatalog asiaCatalog() { + PokemonSetCatalog catalog; + PokemonSetCatalogPack pmcg1; + pmcg1.setId = "PMCG1"; + pmcg1.setName = "Expansion Pack"; + pmcg1.cards = { + {"001", "Charmander"}, + {"002", "Charmeleon"}, + {"006", "Charizard"}, + }; + catalog.packs.push_back(std::move(pmcg1)); + return catalog; +} + +} // namespace + +TEST_SUITE("computePokemonSetCompletion") { + TEST_CASE("west pack with owned card appears") { + const auto west = westCatalog(); + const auto asia = asiaCatalog(); + std::vector collection{ + makeOwned(PokemonRegion::West, "base1", "4"), + }; + const auto rows = computePokemonSetCompletion(collection, west, asia); + REQUIRE(rows.size() == 1); + CHECK(rows[0].region == PokemonRegion::West); + CHECK(rows[0].setId == "base1"); + CHECK(rows[0].ownedUnique == 1); + CHECK(rows[0].total == 3); + CHECK(rows[0].percent() == 33); + } + + TEST_CASE("asia and west do not cross-count") { + const auto west = westCatalog(); + const auto asia = asiaCatalog(); + // Same collector-looking number, different region/set. + PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4"); + PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "006"); + asiaCard.id = 2; + + const auto rows = computePokemonSetCompletion({westCard, asiaCard}, west, asia); + REQUIRE(rows.size() == 2); + CHECK(rows[0].setId == "base1"); + CHECK(rows[0].ownedUnique == 1); + CHECK(rows[1].setId == "PMCG1"); + CHECK(rows[1].ownedUnique == 1); + } + + TEST_CASE("region filter isolates catalogs") { + const auto west = westCatalog(); + const auto asia = asiaCatalog(); + PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4"); + PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "001"); + asiaCard.id = 2; + + const auto westOnly = computePokemonSetCompletion( + {westCard, asiaCard}, west, asia, PokemonRegion::West); + REQUIRE(westOnly.size() == 1); + CHECK(westOnly[0].setId == "base1"); + + const auto asiaOnly = computePokemonSetCompletion( + {westCard, asiaCard}, west, asia, PokemonRegion::Asia); + REQUIRE(asiaOnly.size() == 1); + CHECK(asiaOnly[0].setId == "PMCG1"); + } + + TEST_CASE("normalizes west 4/102 to 4") { + const auto west = westCatalog(); + PokemonSetCatalog emptyAsia; + std::vector collection{ + makeOwned(PokemonRegion::West, "base1", "4/102"), + }; + const auto rows = computePokemonSetCompletion(collection, west, emptyAsia); + REQUIRE(rows.size() == 1); + CHECK(rows[0].ownedUnique == 1); + } + + TEST_CASE("amount does not inflate unique ownership") { + const auto west = westCatalog(); + PokemonSetCatalog emptyAsia; + PokemonCard a = makeOwned(PokemonRegion::West, "base1", "4"); + a.amount = 5; + PokemonCard b = makeOwned(PokemonRegion::West, "base1", "4"); + b.id = 2; + PokemonCard c = makeOwned(PokemonRegion::West, "base1", "58"); + c.id = 3; + const auto rows = computePokemonSetCompletion({a, b, c}, west, emptyAsia); + REQUIRE(rows.size() == 1); + CHECK(rows[0].ownedUnique == 2); + } + + TEST_CASE("language filter hides packs with no matching language") { + const auto west = westCatalog(); + PokemonSetCatalog emptyAsia; + PokemonCard en = makeOwned(PokemonRegion::West, "base1", "4"); + en.language = Language::English; + + CHECK(computePokemonSetCompletion({en}, west, emptyAsia, std::nullopt, + Language::German) + .empty()); + REQUIRE(computePokemonSetCompletion({en}, west, emptyAsia, std::nullopt, + Language::English) + .size() == 1); + } + + TEST_CASE("empty catalog yields no rows") { + PokemonSetCatalog empty; + std::vector collection{ + makeOwned(PokemonRegion::West, "base1", "4"), + }; + CHECK(computePokemonSetCompletion(collection, empty, empty).empty()); + } +} + +TEST_SUITE("pokemonChecklistForSet") { + TEST_CASE("marks owned west cards") { + const auto west = westCatalog(); + PokemonSetCatalog emptyAsia; + std::vector collection{ + makeOwned(PokemonRegion::West, "base1", "58"), + }; + const auto list = pokemonChecklistForSet(collection, west, emptyAsia, + PokemonRegion::West, "base1"); + REQUIRE(list.size() == 3); + CHECK(list[0].setNo == "4"); + CHECK(list[0].owned == false); + CHECK(list[1].setNo == "58"); + CHECK(list[1].owned == true); + CHECK(list[2].setNo == "59"); + CHECK(list[2].owned == false); + } + + TEST_CASE("asia card does not mark west checklist") { + const auto west = westCatalog(); + const auto asia = asiaCatalog(); + std::vector collection{ + makeOwned(PokemonRegion::Asia, "PMCG1", "006"), + }; + const auto list = pokemonChecklistForSet(collection, west, asia, + PokemonRegion::West, "base1"); + REQUIRE(list.size() == 3); + CHECK(list[0].owned == false); + CHECK(list[1].owned == false); + CHECK(list[2].owned == false); + } +} + +TEST_SUITE("pokemonLanguagesInCollection") { + TEST_CASE("region filter scopes languages") { + PokemonCard westEn = makeOwned(PokemonRegion::West, "base1", "4"); + westEn.language = Language::English; + PokemonCard asiaJp = makeOwned(PokemonRegion::Asia, "PMCG1", "001"); + asiaJp.id = 2; + asiaJp.language = Language::Japanese; + + const auto all = pokemonLanguagesInCollection({westEn, asiaJp}); + REQUIRE(all.size() == 2); + CHECK(all[0] == Language::English); + CHECK(all[1] == Language::Japanese); + + const auto westOnly = + pokemonLanguagesInCollection({westEn, asiaJp}, PokemonRegion::West); + REQUIRE(westOnly.size() == 1); + CHECK(westOnly[0] == Language::English); + } +} + +TEST_SUITE("pokemonRegionsInCollection") { + TEST_CASE("reports regions with matching catalog packs") { + const auto west = westCatalog(); + const auto asia = asiaCatalog(); + PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4"); + PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "001"); + asiaCard.id = 2; + const auto regions = + pokemonRegionsInCollection({westCard, asiaCard}, west, asia); + REQUIRE(regions.size() == 2); + CHECK(regions[0] == PokemonRegion::West); + CHECK(regions[1] == PokemonRegion::Asia); + } +} + +TEST_SUITE("PokemonSetCatalogService") { + TEST_CASE("save then load round-trips for west and asia paths") { + InMemoryFileSystem fs; + auto config = makeConfig(fs, "/data"); + PokemonSetCatalogService store{fs, config, [](Game) { return "pokemon"; }}; + + CHECK_FALSE(store.exists(PokemonRegion::West)); + CHECK_FALSE(store.exists(PokemonRegion::Asia)); + CHECK(store.load(PokemonRegion::West).isErr()); + + const auto west = westCatalog(); + const auto asia = asiaCatalog(); + REQUIRE(store.save(PokemonRegion::West, west).isOk()); + REQUIRE(store.save(PokemonRegion::Asia, asia).isOk()); + CHECK(store.exists(PokemonRegion::West)); + CHECK(store.exists(PokemonRegion::Asia)); + + const auto loadedWest = store.load(PokemonRegion::West); + REQUIRE(loadedWest.isOk()); + CHECK(loadedWest.value() == west); + + const auto loadedAsia = store.load(PokemonRegion::Asia); + REQUIRE(loadedAsia.isOk()); + CHECK(loadedAsia.value() == asia); + + CHECK(fs.exists("/data/pokemon/set-catalog-west.json")); + CHECK(fs.exists("/data/pokemon/set-catalog-asia.json")); + } +} diff --git a/tests/pokemon_set_source_tests.cpp b/tests/pokemon_set_source_tests.cpp index 2af5d95..36b7855 100644 --- a/tests/pokemon_set_source_tests.cpp +++ b/tests/pokemon_set_source_tests.cpp @@ -92,3 +92,55 @@ TEST_SUITE("PokemonSetSource::fetchAll") { CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets"); } } + +TEST_SUITE("PokemonSetSource::parseCatalog") { + TEST_CASE("groups cards by set.id and dedupes collector numbers") { + const std::vector sets{ + Set{"base1", "Base", "1999/01/09"}, + Set{"jungle", "Jungle", "1999/06/16"}, + }; + const std::string json = R"({ + "data": [ + {"name":"Charizard","number":"4","set":{"id":"base1","name":"Base"}}, + {"name":"Charizard","number":"4/102","set":{"id":"base1","name":"Base"}}, + {"name":"Growlithe","number":"58","set":{"id":"base1","name":"Base"}}, + {"name":"Pikachu","number":"60","set":{"id":"jungle","name":"Jungle"}} + ], + "page":1,"pageSize":250,"count":4,"totalCount":4 + })"; + const auto catalog = PokemonSetSource::parseCatalog(json, sets); + REQUIRE(catalog.isOk()); + REQUIRE(catalog.value().packs.size() == 2); + const auto* base = catalog.value().findPack("base1"); + REQUIRE(base != nullptr); + REQUIRE(base->cards.size() == 2); + CHECK(base->cards[0].setNo == "4"); + CHECK(base->cards[1].setNo == "58"); + const auto* jungle = catalog.value().findPack("jungle"); + REQUIRE(jungle != nullptr); + REQUIRE(jungle->cards.size() == 1); + CHECK(jungle->cards[0].setNo == "60"); + } + + TEST_CASE("mergeCardsPage accumulates across pages") { + const std::vector sets{Set{"base1", "Base", "1999/01/09"}}; + PokemonSetCatalog catalog; + const std::string page1 = R"({ + "data":[{"name":"A","number":"1","set":{"id":"base1","name":"Base"}}], + "page":1,"pageSize":1,"count":1,"totalCount":2 + })"; + const std::string page2 = R"({ + "data":[{"name":"B","number":"2","set":{"id":"base1","name":"Base"}}], + "page":2,"pageSize":1,"count":1,"totalCount":2 + })"; + REQUIRE(PokemonSetSource::mergeCardsPage(page1, catalog, sets).isOk()); + REQUIRE(PokemonSetSource::mergeCardsPage(page2, catalog, sets).isOk()); + REQUIRE(catalog.packs.size() == 1); + REQUIRE(catalog.packs[0].cards.size() == 2); + } + + TEST_CASE("buildCardsPageUrl includes select and pagination") { + CHECK(PokemonSetSource::buildCardsPageUrl(2) == + "https://api.pokemontcg.io/v2/cards?select=name,number,set&pageSize=250&page=2"); + } +} diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index 81fb093..fac8b54 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -5,15 +5,15 @@ ## 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. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon and Yu-Gi-Oh! 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 via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic / Pokémon) or a game’s `IGameView::contentPanel` (Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. +- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magic’s splitter mounting. +- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. - `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` 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). - `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` — and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. - `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/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`, `PokemonSetCompletionPanel`. Same Add/Edit shape as Magic for the card form; the game view hosts **Single Cards | Set Completion** via `contentPanel` / `hostsOwnLayout` (like Digimon/Yu-Gi-Oh!). Catalog from `PokemonSetCatalogService` (`set-catalog-west.json` / `set-catalog-asia.json`), filled on Update Pokemon. The Add/Edit/Delete + filter toolbar lives inside the Single Cards tab; MainFrame hides its shared toolbar while Pokemon is active. - `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`). - `include/ccm/ui/YuGiOh*.hpp` + `src/YuGiOh*.cpp` — Yu-Gi-Oh!: list/selected/edit plus `YuGiOhGameView` notebook (**Single Cards** | **Set Completion**) via the same `hostsOwnLayout` / `contentPanel` pattern as Digimon, and `YuGiOhSetCompletionPanel`. Catalog from `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), filled on Update Sets from YGOPRODeck `cardinfo.php`. - `include/ccm/ui/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. @@ -29,7 +29,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 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 and Yu-Gi-Oh!’s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs. +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, Yu-Gi-Oh!’s, and Pokémon’s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), 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 937e9f8..f122ee6 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -15,6 +15,7 @@ add_library(ccm_ui_wx STATIC src/PokemonSelectedCardPanel.cpp src/PokemonCardEditDialog.cpp src/PokemonGameView.cpp + src/PokemonSetCompletionPanel.cpp src/YuGiOhCardListPanel.cpp src/YuGiOhSelectedCardPanel.cpp src/YuGiOhCardEditDialog.cpp diff --git a/ui_wx/include/ccm/ui/IGameView.hpp b/ui_wx/include/ccm/ui/IGameView.hpp index be53851..d7efad5 100644 --- a/ui_wx/include/ccm/ui/IGameView.hpp +++ b/ui_wx/include/ccm/ui/IGameView.hpp @@ -36,8 +36,9 @@ public: 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. + // toolbar instead of the shared selected|list splitter. Digimon, Yu-Gi-Oh!, + // and Pokemon use this for Single Cards / Set Completion notebooks. + // Default: no custom host. virtual wxPanel* contentPanel(wxWindow* parent) { (void)parent; return nullptr; diff --git a/ui_wx/include/ccm/ui/PokemonGameView.hpp b/ui_wx/include/ccm/ui/PokemonGameView.hpp index 236cf9c..069c3c7 100644 --- a/ui_wx/include/ccm/ui/PokemonGameView.hpp +++ b/ui_wx/include/ccm/ui/PokemonGameView.hpp @@ -1,7 +1,8 @@ #pragma once // PokemonGameView: unified West + Asia Pokemon UI. One collection file; -// separate West/Asia set caches; Sets > Update Pokemon refreshes both. +// separate West/Asia set caches and set-completion catalogs. Sets > Update +// Pokemon refreshes both regions. Hosts Single Cards | Set Completion tabs. #include "ccm/domain/PokemonCard.hpp" #include "ccm/games/IGameModule.hpp" @@ -9,17 +10,28 @@ #include "ccm/services/CollectionService.hpp" #include "ccm/services/ConfigService.hpp" #include "ccm/services/ImageService.hpp" +#include "ccm/services/PokemonSetCatalogService.hpp" #include "ccm/services/SetService.hpp" #include "ccm/ui/IGameView.hpp" +#include #include #include #include +class wxBitmapButton; +class wxBoxSizer; +class wxPanel; +class wxSimplebook; +class wxSplitterWindow; +class wxStaticText; +class wxTextCtrl; + namespace ccm::ui { class PokemonCardListPanel; class PokemonSelectedCardPanel; +class PokemonSetCompletionPanel; class PokemonGameView final : public IGameView { public: @@ -28,13 +40,20 @@ public: SetService& sets, ImageService& images, CardPreviewService& cardPreview, - IGameModule& module); + IGameModule& westModule, + IGameModule& asiaModule, + PokemonSetCatalogService& catalogStore); [[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; } [[nodiscard]] std::string displayName() const override { return "Pokemon"; } 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; @@ -48,19 +67,37 @@ public: private: void ensureSetsLoaded(); const std::vector& setsForDialog(PokemonRegion region); + void ensureSingleCardsMounted(wxWindow* splitterParent); + void buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer); + void buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer); + void selectTab(int index); + void refreshToolbarIcons(const ThemePalette& palette); + void refreshTabBarTheme(const ThemePalette& palette); ConfigService& config_; CollectionService& collection_; SetService& sets_; ImageService& images_; CardPreviewService& cardPreview_; - IGameModule& module_; + IGameModule& westModule_; + IGameModule& asiaModule_; + PokemonSetCatalogService& catalogStore_; - PokemonCardListPanel* listPanel_{nullptr}; - PokemonSelectedCardPanel* selectedPanel_{nullptr}; - std::vector setsCacheWest_; - std::vector setsCacheAsia_; - bool attemptedInitialSetLoad_{false}; + wxPanel* contentPanel_{nullptr}; + wxPanel* tabBar_{nullptr}; + wxSimplebook* book_{nullptr}; + wxSplitterWindow* singleSplitter_{nullptr}; + PokemonCardListPanel* listPanel_{nullptr}; + PokemonSelectedCardPanel* selectedPanel_{nullptr}; + PokemonSetCompletionPanel* setCompletionPanel_{nullptr}; + std::array tabPanels_{{nullptr, nullptr}}; + std::array tabLabels_{{nullptr, nullptr}}; + int activeTab_{0}; + std::array toolbarButtons_{{nullptr, nullptr, nullptr}}; + wxTextCtrl* filterInput_{nullptr}; + std::vector setsCacheWest_; + std::vector setsCacheAsia_; + bool attemptedInitialSetLoad_{false}; }; } // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/PokemonSetCompletionPanel.hpp b/ui_wx/include/ccm/ui/PokemonSetCompletionPanel.hpp new file mode 100644 index 0000000..03e026c --- /dev/null +++ b/ui_wx/include/ccm/ui/PokemonSetCompletionPanel.hpp @@ -0,0 +1,81 @@ +#pragma once + +// PokemonSetCompletionPanel: 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). Dual offline catalogs (West + Asia). Optional region +// and language filters restrict ownership; set titles may be annotated with +// region and/or language. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/PokemonSetCatalog.hpp" +#include "ccm/services/PokemonSetCatalogService.hpp" +#include "ccm/ui/Theme.hpp" + +#include + +#include +#include +#include + +class wxBoxSizer; +class wxChoice; +class wxListCtrl; +class wxScrolledWindow; +class wxSimplebook; +class wxStaticText; + +namespace ccm::ui { + +class PokemonSetCompletionPanel : public wxPanel { +public: + PokemonSetCompletionPanel(wxWindow* parent, PokemonSetCatalogService& catalogStore); + + void setCollection(std::vector cards); + void reloadFromStore(); + void applyTheme(const ThemePalette& palette); + +private: + void showGridPage(); + void showChecklistPage(PokemonRegion region, const std::string& setId, + const std::string& setName); + void rebuildGrid(); + void rebuildChecklist(PokemonRegion region, const std::string& setId); + void setEmptyMessage(const wxString& message); + void clearGridTiles(); + void refreshRegionChoice(); + void refreshLanguageChoice(); + void onRegionChoice(wxCommandEvent& event); + void onLanguageChoice(wxCommandEvent& event); + void rebuildCurrentView(); + [[nodiscard]] std::string displaySetName(const std::string& setName, + PokemonRegion region) const; + [[nodiscard]] bool catalogsReadyForFilter() const; + + PokemonSetCatalogService& catalogStore_; + PokemonSetCatalog westCatalog_; + PokemonSetCatalog asiaCatalog_; + bool westCatalogLoaded_{false}; + bool asiaCatalogLoaded_{false}; + std::vector collection_; + ThemePalette palette_{}; + std::optional regionFilter_; + std::optional languageFilter_; + + wxChoice* regionChoice_{nullptr}; + wxChoice* languageChoice_{nullptr}; + wxSimplebook* book_{nullptr}; + wxPanel* gridPage_{nullptr}; + wxScrolledWindow* scroll_{nullptr}; + wxBoxSizer* gridSizer_{nullptr}; + wxStaticText* emptyLabel_{nullptr}; + + wxPanel* detailPage_{nullptr}; + wxStaticText* detailTitle_{nullptr}; + wxListCtrl* checklist_{nullptr}; + PokemonRegion detailRegion_{PokemonRegion::West}; + std::string detailSetId_; + std::string detailSetName_; +}; + +} // namespace ccm::ui diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index 66d2fc2..05c814e 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -1,31 +1,66 @@ #include "ccm/ui/PokemonGameView.hpp" +#include "ccm/games/pokemon/PokemonSetSource.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" #include "ccm/ui/CardEditModalGuard.hpp" #include "ccm/ui/PokemonCardEditDialog.hpp" #include "ccm/ui/PokemonCardListPanel.hpp" #include "ccm/ui/PokemonSelectedCardPanel.hpp" +#include "ccm/ui/PokemonSetCompletionPanel.hpp" +#include "ccm/ui/SvgIcons.hpp" #include "ccm/ui/Theme.hpp" -#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include #include namespace ccm::ui { +namespace { +constexpr int kPokeToolbarIconPx = 18; +constexpr const char kPokeFilterHint[] = "Filter"; + +wxColour lighten(const wxColour& c, int amount) { + auto lift = [amount](unsigned char channel) -> unsigned char { + const int raised = static_cast(channel) + amount; + return static_cast(raised > 255 ? 255 : raised); + }; + return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue())); +} + +wxColour darken(const wxColour& c, int amount) { + auto drop = [amount](unsigned char channel) -> unsigned char { + const int lowered = static_cast(channel) - amount; + return static_cast(lowered < 0 ? 0 : lowered); + }; + return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue())); +} +} // namespace + PokemonGameView::PokemonGameView(ConfigService& config, CollectionService& collection, SetService& sets, ImageService& images, CardPreviewService& cardPreview, - IGameModule& module) + IGameModule& westModule, + IGameModule& asiaModule, + PokemonSetCatalogService& catalogStore) : config_(config), collection_(collection), sets_(sets), images_(images), cardPreview_(cardPreview), - module_(module) {} + westModule_(westModule), + asiaModule_(asiaModule), + catalogStore_(catalogStore) {} void PokemonGameView::ensureSetsLoaded() { if (attemptedInitialSetLoad_) return; @@ -49,6 +84,208 @@ void PokemonGameView::ensureSetsLoaded() { loadOrRefresh(Game::JapanesePokemon, setsCacheAsia_); } +void PokemonGameView::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 PokemonGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) { + auto* toolbar = new wxBoxSizer(wxHORIZONTAL); + auto makeToolBtn = [&](const char* svg, const wxString& tip) { + wxBitmap bmp = svgIconBitmap(svg, kPokeToolbarIconPx, "#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(kPokeFilterHint); + 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 PokemonGameView::refreshToolbarIcons(const ThemePalette& palette) { + const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); + if (toolbarButtons_[0]) { + toolbarButtons_[0]->SetBitmap( + svgIconBitmap(kSvgToolbarAdd, kPokeToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[1]) { + toolbarButtons_[1]->SetBitmap( + svgIconBitmap(kSvgToolbarEdit, kPokeToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[2]) { + toolbarButtons_[2]->SetBitmap( + svgIconBitmap(kSvgToolbarDelete, kPokeToolbarIconPx, tbHex.c_str())); + } +} + +void PokemonGameView::selectTab(int index) { + if (index < 0 || index > 1 || book_ == nullptr) return; + activeTab_ = index; + book_->SetSelection(index); + refreshTabBarTheme(paletteForTheme(config_.current().theme)); +} + +void PokemonGameView::refreshTabBarTheme(const ThemePalette& palette) { + if (tabBar_ == nullptr) return; + + const wxColour barBg = palette.panelBg; + const wxColour tabBg = palette.buttonBg; + + tabBar_->SetBackgroundColour(barBg); + tabBar_->SetOwnBackgroundColour(barBg); + + for (int i = 0; i < 2; ++i) { + auto* tab = tabPanels_[i]; + auto* label = tabLabels_[i]; + if (tab == nullptr || label == nullptr) continue; + const bool selected = (i == activeTab_); + tab->SetBackgroundColour(tabBg); + tab->SetOwnBackgroundColour(tabBg); + label->SetBackgroundColour(tabBg); + label->SetOwnBackgroundColour(tabBg); + label->SetForegroundColour(palette.text); + label->SetOwnForegroundColour(palette.text); + wxFont font = label->GetFont(); + font.SetWeight(selected ? wxFONTWEIGHT_BOLD : wxFONTWEIGHT_NORMAL); + label->SetFont(font); + tab->Refresh(); + label->Refresh(); + } + tabBar_->Layout(); + tabBar_->Refresh(); +} + +void PokemonGameView::buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer) { + tabBar_ = new wxPanel(parent, wxID_ANY); + tabBar_->SetBackgroundStyle(wxBG_STYLE_PAINT); + auto* tabSizer = new wxBoxSizer(wxHORIZONTAL); + tabSizer->AddSpacer(4); + + const char* labels[2] = {"Single Cards", "Set Completion"}; + for (int i = 0; i < 2; ++i) { + auto* tab = new wxPanel(tabBar_, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE); + tab->SetCursor(wxCursor(wxCURSOR_HAND)); + tab->SetBackgroundStyle(wxBG_STYLE_PAINT); + auto* label = new wxStaticText(tab, wxID_ANY, wxString::FromUTF8(labels[i])); + auto* inner = new wxBoxSizer(wxVERTICAL); + inner->Add(label, 0, wxALIGN_CENTER | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 5); + tab->SetSizer(inner); + + auto onClick = [this, i](wxMouseEvent&) { selectTab(i); }; + tab->Bind(wxEVT_LEFT_DOWN, onClick); + label->Bind(wxEVT_LEFT_DOWN, onClick); + tab->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {}); + tab->Bind(wxEVT_PAINT, [this, tab, i](wxPaintEvent&) { + wxPaintDC dc(tab); + const ThemePalette palette = paletteForTheme(config_.current().theme); + const bool dark = config_.current().theme == Theme::Dark; + const bool selected = (i == activeTab_); + const wxColour bg = palette.buttonBg; + const wxColour frame = + dark ? lighten(palette.panelBg, 55) : darken(palette.panelBg, 45); + const wxColour frameSel = dark ? lighten(palette.panelBg, 85) : darken(palette.panelBg, 70); + const wxRect r = tab->GetClientRect(); + dc.SetPen(wxPen(selected ? frameSel : frame, 1)); + dc.SetBrush(wxBrush(bg)); + dc.DrawRectangle(r.x, r.y, r.width, r.height); + if (selected) { + dc.SetPen(wxPen(palette.text, 2)); + dc.DrawLine(r.GetLeft() + 4, r.GetBottom() - 1, r.GetRight() - 4, + r.GetBottom() - 1); + } + }); + + tabPanels_[i] = tab; + tabLabels_[i] = label; + if (i > 0) tabSizer->AddSpacer(4); + tabSizer->Add(tab, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 3); + } + tabSizer->AddStretchSpacer(1); + + tabBar_->Bind(wxEVT_PAINT, [this](wxPaintEvent&) { + wxPaintDC dc(tabBar_); + const ThemePalette palette = paletteForTheme(config_.current().theme); + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(palette.panelBg)); + dc.DrawRectangle(tabBar_->GetClientRect()); + dc.SetPen(wxPen(darken(palette.text, 120), 1)); + const wxRect r = tabBar_->GetClientRect(); + dc.DrawLine(r.GetLeft(), r.GetBottom(), r.GetRight(), r.GetBottom()); + }); + tabBar_->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {}); + + tabBar_->SetSizer(tabSizer); + rootSizer->Add(tabBar_, 0, wxEXPAND); + refreshTabBarTheme(paletteForTheme(config_.current().theme)); +} + +wxPanel* PokemonGameView::contentPanel(wxWindow* parent) { + if (contentPanel_ == nullptr) { + contentPanel_ = new wxPanel(parent); + auto* root = new wxBoxSizer(wxVERTICAL); + + buildTabBar(contentPanel_, root); + + book_ = new wxSimplebook(contentPanel_, wxID_ANY); + auto* singlePage = new wxPanel(book_); + auto* singleSizer = new wxBoxSizer(wxVERTICAL); + buildSingleCardsToolbar(singlePage, singleSizer); + ensureSingleCardsMounted(singlePage); + singleSizer->Add(singleSplitter_, 1, wxEXPAND); + singlePage->SetSizer(singleSizer); + book_->AddPage(singlePage, "Single Cards"); + + setCompletionPanel_ = new PokemonSetCompletionPanel(book_, catalogStore_); + setCompletionPanel_->reloadFromStore(); + book_->AddPage(setCompletionPanel_, "Set Completion"); + + root->Add(book_, 1, wxEXPAND | wxTOP, 5); + contentPanel_->SetSizer(root); + + selectTab(0); + refreshToolbarIcons(paletteForTheme(config_.current().theme)); + contentPanel_->CallAfter([this]() { + refreshTabBarTheme(paletteForTheme(config_.current().theme)); + }); + } + return contentPanel_; +} + wxPanel* PokemonGameView::listPanel(wxWindow* parent) { if (listPanel_ == nullptr) { listPanel_ = new PokemonCardListPanel(parent); @@ -73,16 +310,23 @@ wxPanel* PokemonGameView::selectedPanel(wxWindow* parent) { } void PokemonGameView::refreshCollection() { - if (listPanel_ == nullptr) return; + if (contentPanel_ == nullptr && listPanel_ == nullptr) return; + auto loaded = collection_.list(Game::Pokemon); if (!loaded) { showThemedMessageDialog(nullptr, "Failed to load Pokemon collection: " + loaded.error(), "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& PokemonGameView::setsForDialog(PokemonRegion region) { @@ -195,56 +439,127 @@ void PokemonGameView::onDeleteCard(wxWindow* parentWindow) { } std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) { - auto westOut = sets_.updateSets(Game::Pokemon); - auto asiaOut = sets_.updateSets(Game::JapanesePokemon); - - if (westOut) { - setsCacheWest_ = westOut.value(); - } - if (asiaOut) { - setsCacheAsia_ = asiaOut.value(); + auto* westSrc = dynamic_cast(&westModule_.setSource()); + auto* asiaSrc = dynamic_cast(&asiaModule_.setSource()); + if (westSrc == nullptr || asiaSrc == nullptr) { + showThemedMessageDialog(parentWindow, "Pokemon set source unavailable.", + "Error", wxOK | wxICON_ERROR); + return "Update failed"; } - if (!westOut && !asiaOut) { + auto westBoth = westSrc->fetchAllWithCatalog(); + auto asiaBoth = asiaSrc->fetchAllWithCatalog(); + + std::string westErr; + std::string asiaErr; + std::size_t westSets = 0; + std::size_t asiaSets = 0; + std::size_t westPacks = 0; + std::size_t asiaPacks = 0; + + if (westBoth) { + auto savedSets = sets_.saveSets(Game::Pokemon, westBoth.value().sets); + if (!savedSets) { + westErr = savedSets.error(); + } else { + auto savedCatalog = + catalogStore_.save(PokemonRegion::West, westBoth.value().catalog); + if (!savedCatalog) { + westErr = "sets saved, but catalog failed: " + savedCatalog.error(); + } + setsCacheWest_ = westBoth.value().sets; + westSets = westBoth.value().sets.size(); + westPacks = westBoth.value().catalog.packs.size(); + } + } else { + westErr = westBoth.error(); + } + + if (asiaBoth) { + auto savedSets = sets_.saveSets(Game::JapanesePokemon, asiaBoth.value().sets); + if (!savedSets) { + asiaErr = savedSets.error(); + } else { + auto savedCatalog = + catalogStore_.save(PokemonRegion::Asia, asiaBoth.value().catalog); + if (!savedCatalog) { + asiaErr = "sets saved, but catalog failed: " + savedCatalog.error(); + } + setsCacheAsia_ = asiaBoth.value().sets; + asiaSets = asiaBoth.value().sets.size(); + asiaPacks = asiaBoth.value().catalog.packs.size(); + } + } else { + asiaErr = asiaBoth.error(); + } + + if (setCompletionPanel_ != nullptr) { + setCompletionPanel_->reloadFromStore(); + if (auto loaded = collection_.list(Game::Pokemon)) { + setCompletionPanel_->setCollection(std::move(loaded).value()); + } + } + + if (!westErr.empty() && !asiaErr.empty()) { showThemedMessageDialog( parentWindow, - "Failed to update West sets: " + westOut.error() + - "\nFailed to update Asia sets: " + asiaOut.error(), + "Failed to update West: " + westErr + "\nFailed to update Asia: " + asiaErr, "Error", wxOK | wxICON_ERROR); return "Update failed"; } - if (!westOut) { + if (!westErr.empty()) { showThemedMessageDialog( parentWindow, - "Updated " + std::to_string(asiaOut.value().size()) + - " Asia Pokemon sets, but West failed: " + westOut.error(), + "Updated " + std::to_string(asiaSets) + " Asia sets / " + + std::to_string(asiaPacks) + " checklists, but West failed: " + westErr, "Sets partially updated", wxOK | wxICON_WARNING); return "Pokemon sets partially updated."; } - if (!asiaOut) { + if (!asiaErr.empty()) { showThemedMessageDialog( parentWindow, - "Updated " + std::to_string(westOut.value().size()) + - " West Pokemon sets, but Asia failed: " + asiaOut.error(), + "Updated " + std::to_string(westSets) + " West sets / " + + std::to_string(westPacks) + " checklists, but Asia failed: " + asiaErr, "Sets partially updated", wxOK | wxICON_WARNING); return "Pokemon sets partially updated."; } showThemedMessageDialog( parentWindow, - "Updated " + std::to_string(westOut.value().size()) + " West and " + - std::to_string(asiaOut.value().size()) + " Asia Pokemon sets.", + "Updated " + std::to_string(westSets) + " West sets (" + + std::to_string(westPacks) + " checklists) and " + std::to_string(asiaSets) + + " Asia sets (" + std::to_string(asiaPacks) + " checklists).", "Sets updated", wxOK | wxICON_INFORMATION); return "Pokemon sets updated."; } void PokemonGameView::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(kPokeFilterHint); + filterInput_->Refresh(); + } + } + } if (listPanel_) listPanel_->setFilter(filter); } void PokemonGameView::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); + refreshTabBarTheme(palette); + if (filterInput_ != nullptr) { + filterInput_->SetBackgroundColour(palette.inputBg); + filterInput_->SetForegroundColour(palette.inputText); + filterInput_->SetOwnBackgroundColour(palette.inputBg); + filterInput_->SetOwnForegroundColour(palette.inputText); + } } } // namespace ccm::ui diff --git a/ui_wx/src/PokemonSetCompletionPanel.cpp b/ui_wx/src/PokemonSetCompletionPanel.cpp new file mode 100644 index 0000000..3debb8a --- /dev/null +++ b/ui_wx/src/PokemonSetCompletionPanel.cpp @@ -0,0 +1,395 @@ +#include "ccm/ui/PokemonSetCompletionPanel.hpp" + +#include "ccm/services/PokemonSetCompletion.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ccm::ui { + +namespace { + +wxColour mutedTextColour(const ThemePalette& palette) { + 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 + +PokemonSetCompletionPanel::PokemonSetCompletionPanel(wxWindow* parent, + PokemonSetCatalogService& catalogStore) + : wxPanel(parent), catalogStore_(catalogStore) { + palette_ = paletteForTheme(inferThemeFromWindow(this)); + + auto* filterRow = new wxBoxSizer(wxHORIZONTAL); + + auto* regionLabel = new wxStaticText(this, wxID_ANY, "Region"); + regionChoice_ = new wxChoice(this, wxID_ANY); + regionChoice_->Append("All regions"); + regionChoice_->SetSelection(0); + regionChoice_->Bind(wxEVT_CHOICE, &PokemonSetCompletionPanel::onRegionChoice, this); + filterRow->Add(regionLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + filterRow->Add(regionChoice_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 16); + + auto* langLabel = new wxStaticText(this, wxID_ANY, "Language"); + languageChoice_ = new wxChoice(this, wxID_ANY); + languageChoice_->Append("All languages"); + languageChoice_->SetSelection(0); + languageChoice_->Bind(wxEVT_CHOICE, &PokemonSetCompletionPanel::onLanguageChoice, this); + filterRow->Add(langLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8); + filterRow->Add(languageChoice_, 0, wxALIGN_CENTER_VERTICAL); + + 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(filterRow, 0, wxEXPAND | wxALL, 8); + root->Add(book_, 1, wxEXPAND); + SetSizer(root); + + showGridPage(); +} + +void PokemonSetCompletionPanel::setCollection(std::vector cards) { + collection_ = std::move(cards); + refreshRegionChoice(); + refreshLanguageChoice(); + rebuildCurrentView(); +} + +void PokemonSetCompletionPanel::reloadFromStore() { + westCatalogLoaded_ = false; + asiaCatalogLoaded_ = false; + westCatalog_ = {}; + asiaCatalog_ = {}; + if (catalogStore_.exists(PokemonRegion::West)) { + if (auto loaded = catalogStore_.load(PokemonRegion::West)) { + westCatalog_ = std::move(loaded).value(); + westCatalogLoaded_ = true; + } + } + if (catalogStore_.exists(PokemonRegion::Asia)) { + if (auto loaded = catalogStore_.load(PokemonRegion::Asia)) { + asiaCatalog_ = std::move(loaded).value(); + asiaCatalogLoaded_ = true; + } + } + showGridPage(); + rebuildGrid(); +} + +void PokemonSetCompletionPanel::applyTheme(const ThemePalette& palette) { + palette_ = palette; + applyThemeToWindowTree(this, palette, inferThemeFromWindow(this)); + rebuildCurrentView(); +} + +void PokemonSetCompletionPanel::showGridPage() { + detailSetId_.clear(); + detailSetName_.clear(); + book_->SetSelection(0); +} + +void PokemonSetCompletionPanel::showChecklistPage(PokemonRegion region, + const std::string& setId, + const std::string& setName) { + detailRegion_ = region; + detailSetId_ = setId; + detailSetName_ = setName; + detailTitle_->SetLabelText( + wxString::FromUTF8(displaySetName(setName, region).c_str())); + rebuildChecklist(region, setId); + book_->SetSelection(1); +} + +std::string PokemonSetCompletionPanel::displaySetName(const std::string& setName, + PokemonRegion region) const { + std::string out = setName; + if (!regionFilter_.has_value()) { + out += " ("; + out += std::string(to_string(region)); + out += ")"; + } + if (languageFilter_.has_value()) { + out += " ("; + out += std::string(to_string(*languageFilter_)); + out += ")"; + } + return out; +} + +bool PokemonSetCompletionPanel::catalogsReadyForFilter() const { + if (!regionFilter_.has_value()) { + return westCatalogLoaded_ || asiaCatalogLoaded_; + } + if (*regionFilter_ == PokemonRegion::West) return westCatalogLoaded_; + return asiaCatalogLoaded_; +} + +void PokemonSetCompletionPanel::refreshRegionChoice() { + const auto previous = regionFilter_; + const auto present = + pokemonRegionsInCollection(collection_, westCatalog_, asiaCatalog_); + + regionChoice_->Clear(); + regionChoice_->Append("All regions"); + for (const PokemonRegion region : present) { + regionChoice_->Append(wxString::FromUTF8(std::string(to_string(region)).c_str())); + } + + int selection = 0; + regionFilter_ = std::nullopt; + if (previous.has_value()) { + for (std::size_t i = 0; i < present.size(); ++i) { + if (present[i] == *previous) { + selection = static_cast(i + 1); + regionFilter_ = previous; + break; + } + } + } + regionChoice_->SetSelection(selection); +} + +void PokemonSetCompletionPanel::refreshLanguageChoice() { + const auto previous = languageFilter_; + const auto present = pokemonLanguagesInCollection(collection_, regionFilter_); + + languageChoice_->Clear(); + languageChoice_->Append("All languages"); + for (const Language lang : present) { + languageChoice_->Append(wxString::FromUTF8(std::string(to_string(lang)).c_str())); + } + + int selection = 0; + languageFilter_ = std::nullopt; + if (previous.has_value()) { + for (std::size_t i = 0; i < present.size(); ++i) { + if (present[i] == *previous) { + selection = static_cast(i + 1); + languageFilter_ = previous; + break; + } + } + } + languageChoice_->SetSelection(selection); +} + +void PokemonSetCompletionPanel::onRegionChoice(wxCommandEvent& /*event*/) { + const int sel = regionChoice_->GetSelection(); + if (sel <= 0) { + regionFilter_ = std::nullopt; + } else { + const auto present = + pokemonRegionsInCollection(collection_, westCatalog_, asiaCatalog_); + const auto idx = static_cast(sel - 1); + if (idx < present.size()) { + regionFilter_ = present[idx]; + } else { + regionFilter_ = std::nullopt; + regionChoice_->SetSelection(0); + } + } + refreshLanguageChoice(); + rebuildCurrentView(); +} + +void PokemonSetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) { + const int sel = languageChoice_->GetSelection(); + if (sel <= 0) { + languageFilter_ = std::nullopt; + } else { + const auto present = pokemonLanguagesInCollection(collection_, regionFilter_); + const auto idx = static_cast(sel - 1); + if (idx < present.size()) { + languageFilter_ = present[idx]; + } else { + languageFilter_ = std::nullopt; + languageChoice_->SetSelection(0); + } + } + rebuildCurrentView(); +} + +void PokemonSetCompletionPanel::rebuildCurrentView() { + if (book_->GetSelection() == 1 && !detailSetId_.empty()) { + const auto rows = computePokemonSetCompletion( + collection_, westCatalog_, asiaCatalog_, regionFilter_, languageFilter_); + bool stillVisible = false; + for (const auto& row : rows) { + if (row.setId == detailSetId_ && row.region == detailRegion_) { + stillVisible = true; + break; + } + } + if (!stillVisible) { + showGridPage(); + rebuildGrid(); + return; + } + detailTitle_->SetLabelText( + wxString::FromUTF8(displaySetName(detailSetName_, detailRegion_).c_str())); + rebuildChecklist(detailRegion_, detailSetId_); + } else { + rebuildGrid(); + } +} + +void PokemonSetCompletionPanel::setEmptyMessage(const wxString& message) { + clearGridTiles(); + emptyLabel_->SetLabelText(message); + emptyLabel_->Wrap(480); + emptyLabel_->Show(); + scroll_->Hide(); + gridPage_->Layout(); +} + +void PokemonSetCompletionPanel::clearGridTiles() { + if (gridSizer_ == nullptr) return; + gridSizer_->Clear(true); +} + +void PokemonSetCompletionPanel::rebuildGrid() { + if (!catalogsReadyForFilter()) { + setEmptyMessage(wxString::FromUTF8( + "Set checklists are not downloaded yet.\n" + "Run Sets → Update Pokemon to enable Set Completion.")); + return; + } + + const auto rows = computePokemonSetCompletion( + collection_, westCatalog_, asiaCatalog_, regionFilter_, languageFilter_); + if (rows.empty()) { + setEmptyMessage(wxString::FromUTF8( + "No Pokemon 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); + + const std::string title = displaySetName(row.setName, row.region); + auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(title.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 PokemonRegion region = row.region; + const std::string setId = row.setId; + const std::string setName = row.setName; + auto openDetail = [this, region, setId, setName](wxMouseEvent&) { + showChecklistPage(region, 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 PokemonSetCompletionPanel::rebuildChecklist(PokemonRegion region, + const std::string& setId) { + checklist_->DeleteAllItems(); + const auto entries = pokemonChecklistForSet( + collection_, westCatalog_, asiaCatalog_, region, setId, languageFilter_); + const wxColour muted = mutedTextColour(palette_); + const wxColour ownedGreen(46, 160, 67); + + long idx = 0; + for (const auto& entry : entries) { + const std::string line = + (entry.owned ? "✓ " : " ") + 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, ownedGreen); + } else { + checklist_->SetItemTextColour(row, muted); + } + } + checklist_->SetColumnWidth(0, wxLIST_AUTOSIZE); + detailPage_->Layout(); +} + +} // namespace ccm::ui