set completion pokemon

This commit is contained in:
sdine
2026-07-23 10:29:01 +02:00
parent ceb8605b5f
commit 16a5efb6b9
34 changed files with 2364 additions and 116 deletions
+1 -1
View File
@@ -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<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `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<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). 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`).
+6 -1
View File
@@ -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<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoCatalogStore_ =
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
pokeCatalogStore_ =
std::make_unique<ccm::PokemonSetCatalogService>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
@@ -164,7 +167,8 @@ public:
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_, *jpPokeMod_,
*pokeCatalogStore_);
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
*ygoCatalogStore_);
@@ -212,6 +216,7 @@ private:
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
+3 -3
View File
@@ -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<T>`, `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<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` (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<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `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.
+3
View File
@@ -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
@@ -0,0 +1,52 @@
#pragma once
// PokemonSetCatalog: offline pack → card checklist for Pokemon set
// completion. West and Asia each persist their own file under
// `<dataStorage>/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`).
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const PokemonCatalogCard&,
const PokemonCatalogCard&) = default;
};
struct PokemonSetCatalogPack {
std::string setId;
std::string setName;
std::vector<PokemonCatalogCard> cards;
friend bool operator==(const PokemonSetCatalogPack&,
const PokemonSetCatalogPack&) = default;
};
struct PokemonSetCatalog {
std::vector<PokemonSetCatalogPack> packs;
[[nodiscard]] const PokemonSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const PokemonSetCatalog&,
const PokemonSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const PokemonCatalogCard& c);
void from_json(const nlohmann::json& j, PokemonCatalogCard& c);
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p);
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p);
void to_json(nlohmann::json& j, const PokemonSetCatalog& c);
void from_json(const nlohmann::json& j, PokemonSetCatalog& c);
} // namespace ccm
@@ -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:"<name>" set.id:<setId> number:<setNo>
// and returns `data[0].images.large` (with `images.small` as a graceful
// fallback). Mirrors the established `getImage` flow in
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
// 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<std::string, PreviewLookupError>
parseResponse(const std::string& body);
// Parse a Pokemon TCG /v2/cards/{id} response (`data` object).
static Result<std::string, PreviewLookupError>
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<std::vector<AutoDetectedPrint>>
@@ -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 <string>
#include <vector>
namespace ccm {
class PokemonSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
static constexpr const char* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards";
static constexpr int kCardsPageSize = 250;
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
explicit PokemonSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Sets endpoint + paginated cards dump for the offline checklist.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> 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<CardsPageMeta> mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets);
static Result<PokemonSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize);
private:
IHttpClient& http_;
};
@@ -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<JapanesePokemonPrintEnInfo>
printsForSet(std::string_view setId) const;
// TCGPlayer product-image CDN URL for classic JA gap-fill.
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
@@ -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 <string>
#include <string_view>
#include <vector>
namespace ccm {
class JapanesePokemonSetSource final : public ISetSource {
public:
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
Result<std::vector<Set>> fetchAll() override;
// List + per-set detail (cards + release date) + EN catalog gap-fill.
Result<FetchWithCatalog> fetchAllWithCatalog();
void augmentCachedSets(std::vector<Set>& sets) const override;
// Pure parsers for hermetic tests.
@@ -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<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog);
// Catalog-only pack (classic products with no TCGdex detail).
static PokemonSetCatalogPack catalogPackFromEnCatalog(
const Set& set, const JapanesePokemonEnCatalog& enCatalog);
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
static void appendMissingClassicProducts(std::vector<Set>& sets);
@@ -0,0 +1,36 @@
#pragma once
// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and
// pokemon/set-catalog-asia.json under the configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class PokemonSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<PokemonSetCatalog> load(PokemonRegion region) const;
Result<void> save(PokemonRegion region, const PokemonSetCatalog& catalog);
[[nodiscard]] bool exists(PokemonRegion region) const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const;
};
} // namespace ccm
@@ -0,0 +1,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 <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonSetCompletionProgress {
PokemonRegion region{PokemonRegion::West};
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct PokemonChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection (optionally region-scoped),
// in allLanguages() order.
[[nodiscard]] std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter = std::nullopt);
// Distinct regions that have ≥1 owned card matching a catalog pack.
[[nodiscard]] std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog);
// Packs where the collection owns ≥1 matching card, ordered by setName then
// region. When regionFilter is set, only that region's catalog/cards count.
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter = std::nullopt,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
[[nodiscard]] std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
+39
View File
@@ -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
@@ -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<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
@@ -38,31 +26,74 @@ std::string toLower(std::string s) {
return s;
}
Result<std::string, PreviewLookupError> imageUrlFromCardObject(const nlohmann::json& card) {
using R = Result<std::string, PreviewLookupError>;
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<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
}
} // 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:"<name>" set.id:<setId> number:<num>.
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<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_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<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
return 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<std::string, PreviewLookupError>;
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()});
+150
View File
@@ -1,12 +1,35 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
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<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
@@ -36,10 +59,137 @@ Result<std::vector<Set>> 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::CardsPageMeta>
PokemonSetSource::mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<CardsPageMeta>::err(
"Pokemon TCG cards response missing 'data' array.");
}
std::unordered_map<std::string, std::string> 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<std::string, std::size_t> packIndex;
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
packIndex.emplace(catalog.packs[i].setId, i);
}
std::vector<std::unordered_set<std::string>> 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<std::string>{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<int>(j.at("data").size()));
meta.totalCount = j.value("totalCount", meta.count);
return Result<CardsPageMeta>::ok(meta);
} catch (const std::exception& e) {
return Result<CardsPageMeta>::err(
std::string("Pokemon TCG cards JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalog> PokemonSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
PokemonSetCatalog catalog;
auto meta = mergeCardsPage(body, catalog, sets);
if (!meta) return Result<PokemonSetCatalog>::err(meta.error());
finalizeCatalog(catalog);
return Result<PokemonSetCatalog>::ok(std::move(catalog));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto setsResp = http_.get(kEndpoint);
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
auto sets = parseResponse(setsResp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
PokemonSetCatalog catalog;
int page = 1;
int totalCount = 0;
int fetched = 0;
for (;;) {
auto cardsResp = http_.get(buildCardsPageUrl(page));
if (!cardsResp) return Result<FetchWithCatalog>::err(cardsResp.error());
auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value());
if (!meta) return Result<FetchWithCatalog>::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<FetchWithCatalog>::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<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -144,6 +144,20 @@ bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noe
return it != printKeysBySet_.end() && !it->second.empty();
}
std::vector<JapanesePokemonPrintEnInfo>
JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const {
std::vector<JapanesePokemonPrintEnInfo> out;
const auto keysIt = printKeysBySet_.find(std::string(setId));
if (keysIt == printKeysBySet_.end()) return out;
out.reserve(keysIt->second.size());
for (const auto& key : keysIt->second) {
const auto pit = printsByKey_.find(key);
if (pit == printsByKey_.end()) continue;
out.push_back(pit->second);
}
return out;
}
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
if (productId.empty()) return {};
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
@@ -1,5 +1,6 @@
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
@@ -8,6 +9,8 @@
#include <array>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace ccm {
@@ -55,6 +58,41 @@ const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
return false;
}
void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
const JapanesePokemonEnCatalog& enCatalog) {
std::unordered_set<std::string> seen;
for (const auto& card : pack.cards) {
seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo));
}
for (const auto& print : enCatalog.printsForSet(pack.setId)) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name = print.nameEn;
if (name.empty()) name = print.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
}
void sortPackCards(PokemonSetCatalogPack& pack) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
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<PokemonSetCatalogPack> JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog) {
auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
// Transient/NotFound from parse — treat empty cards as catalog-only.
if (rows.error().kind == PreviewLookupError::Kind::NotFound) {
auto pack = catalogPackFromEnCatalog(set, enCatalog);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err(
"No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name;
if (auto print = enCatalog.findPrint(set.id, localId)) {
name = print->nameEn;
if (name.empty()) name = print->nameJa;
}
if (name.empty()) name = row.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
gapFillFromEnCatalog(pack, enCatalog);
sortPackCards(pack);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
@@ -163,13 +265,7 @@ Result<std::vector<Set>> 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<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<JapanesePokemonSetSource::FetchWithCatalog>
JapanesePokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> sets = std::move(parsed).value();
PokemonSetCatalog catalog;
catalog.packs.reserve(sets.size());
for (auto& s : sets) {
applyEnglishSetName(s, catalog_);
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) {
// Classic / catalog-only products often have no TCGdex detail.
auto pack = catalogPackFromEnCatalog(s, catalog_);
if (!pack.cards.empty()) {
catalog.packs.push_back(std::move(pack));
}
continue;
}
if (s.releaseDate.empty()) {
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_);
if (pack) {
catalog.packs.push_back(std::move(pack).value());
} else {
auto fallback = catalogPackFromEnCatalog(s, catalog_);
if (!fallback.cards.empty()) {
catalog.packs.push_back(std::move(fallback));
}
}
}
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
FetchWithCatalog out;
out.sets = std::move(sets);
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
for (auto& s : sets) {
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(),
@@ -0,0 +1,54 @@
#include "ccm/services/PokemonSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
PokemonSetCatalogService::PokemonSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path PokemonSetCatalogService::catalogPath(PokemonRegion region) const {
const char* file = region == PokemonRegion::Asia ? "set-catalog-asia.json"
: "set-catalog-west.json";
return fs::path(config_.current().dataStorage) / dirName_(Game::Pokemon) / file;
}
bool PokemonSetCatalogService::exists(PokemonRegion region) const {
return fs_.exists(catalogPath(region));
}
Result<PokemonSetCatalog> PokemonSetCatalogService::load(PokemonRegion region) const {
const auto p = catalogPath(region);
if (!fs_.exists(p)) {
return Result<PokemonSetCatalog>::err(
region == PokemonRegion::Asia
? "Asia Pokemon set catalog not yet downloaded."
: "West Pokemon set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<PokemonSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<PokemonSetCatalog>::ok(j.get<PokemonSetCatalog>());
} catch (const std::exception& e) {
return Result<PokemonSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> PokemonSetCatalogService::save(PokemonRegion region,
const PokemonSetCatalog& catalog) {
const auto p = catalogPath(region);
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+190
View File
@@ -0,0 +1,190 @@
#include "ccm/services/PokemonSetCompletion.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
bool passesLanguageFilter(const PokemonCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
bool passesRegionFilter(const PokemonCard& card, std::optional<PokemonRegion> regionFilter) {
return !regionFilter.has_value() || card.region == *regionFilter;
}
std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
if (region == PokemonRegion::Asia) {
return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo);
}
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
}
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
PokemonRegion region,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
std::vector<PokemonSetCompletionProgress>
computeForCatalog(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& catalog,
PokemonRegion region,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, region, languageFilter);
std::vector<PokemonSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, 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<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
if (!passesRegionFilter(card, regionFilter)) continue;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog) {
std::vector<PokemonRegion> out;
const auto westRows =
computeForCatalog(collection, westCatalog, PokemonRegion::West, std::nullopt);
if (!westRows.empty()) out.push_back(PokemonRegion::West);
const auto asiaRows =
computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, std::nullopt);
if (!asiaRows.empty()) out.push_back(PokemonRegion::Asia);
return out;
}
std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter,
std::optional<Language> languageFilter) {
std::vector<PokemonSetCompletionProgress> out;
const bool includeWest =
!regionFilter.has_value() || *regionFilter == PokemonRegion::West;
const bool includeAsia =
!regionFilter.has_value() || *regionFilter == PokemonRegion::Asia;
if (includeWest) {
auto west = computeForCatalog(collection, westCatalog, PokemonRegion::West,
languageFilter);
out.insert(out.end(), std::make_move_iterator(west.begin()),
std::make_move_iterator(west.end()));
}
if (includeAsia) {
auto asia = computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia,
languageFilter);
out.insert(out.end(), std::make_move_iterator(asia.begin()),
std::make_move_iterator(asia.end()));
}
std::sort(out.begin(), out.end(),
[](const PokemonSetCompletionProgress& a,
const PokemonSetCompletionProgress& b) {
if (a.setName != b.setName) return a.setName < b.setName;
return static_cast<int>(a.region) < static_cast<int>(b.region);
});
return out;
}
std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter) {
const PokemonSetCatalog& catalog =
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<PokemonChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
PokemonChecklistEntry entry;
entry.setNo = normalizeForRegion(region, card.setNo);
entry.name = card.name;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+29 -5
View File
@@ -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 Asias 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:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`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 cards `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:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`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 cards `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 `<dataStorage>/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 `<dataStorage>/pokemon/set-catalog-asia.json`
For each set, the source `GET`s `/v2/ja/sets/{id}` and builds checklist rows from `cards[]` (`localId``setNo`, display name prefers EN catalog `nameEn`, else TCGdex Japanese `name`). Prints present in the bundled EN catalog but missing from TCGdex `cards[]` are **gap-filled** into the pack (covers UnnumberedPromo / City Gym / Expansion Sheets / Southern Islands and sparse classic sets). Catalog-only products with no TCGdex detail become packs entirely from `JapanesePokemonEnCatalog::printsForSet`.
The Pokemon **Set Completion** tab also loads this file offline; ownership for an Asia pack requires `PokemonRegion::Asia`, matching `card.set.id`, and `normalizeLocalId` on `setNo`. Region and language filters on the tab restrict which packs/cards count. West and Asia never cross-count.
If `set-catalog-asia.json` is missing (and the active region filter needs it), the Set Completion tab prompts the user to run Update Pokemon.
### Sets without printed collector numbers (`UnnumberedPromo`)
Physically unnumbered Japanese promos (and the other classic catalog-only products above) have **no printed set number**. The app still stores a synthetic `setNo` / catalog `local_id` (`001`, `002`, …) so preview and collection JSON stay keyed by `(setId, localId)` — but that value must not be treated as something the user can read off the card.
@@ -297,6 +321,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), 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.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
+3 -2
View File
@@ -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).
+1
View File
@@ -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
+23
View File
@@ -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<PokemonSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("base1") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c;
@@ -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());
@@ -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");
}
}
+110 -5
View File
@@ -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<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
++calls;
if (url.find("/v2/cards?") == std::string::npos) {
return Result<std::string>::err("HTTP 404 from card id");
}
return Result<std::string>::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);
}
}
+263
View File
@@ -0,0 +1,263 @@
#include <doctest/doctest.h>
#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 <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
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<PokemonCard> 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<PokemonCard> 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<PokemonCard> 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<PokemonCard> 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<PokemonCard> 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"));
}
}
+52
View File
@@ -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<Set> 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<Set> 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");
}
}
+4 -4
View File
@@ -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<IGameView*>` 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 games `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 Magics 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 games `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<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon 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 `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` 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 ~50100 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 games `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimons 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 games `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimons, Yu-Gi-Oh!s, and Pokémons 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<State>` holding `std::atomic<bool> alive`, `std::atomic<unsigned> 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.
+1
View File
@@ -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
+3 -2
View File
@@ -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;
+45 -8
View File
@@ -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 <array>
#include <string>
#include <string_view>
#include <vector>
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<Set>& 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<PokemonCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
IGameModule& westModule_;
IGameModule& asiaModule_;
PokemonSetCatalogService& catalogStore_;
PokemonCardListPanel* listPanel_{nullptr};
PokemonSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCacheWest_;
std::vector<Set> 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<wxPanel*, 2> tabPanels_{{nullptr, nullptr}};
std::array<wxStaticText*, 2> tabLabels_{{nullptr, nullptr}};
int activeTab_{0};
std::array<wxBitmapButton*, 3> toolbarButtons_{{nullptr, nullptr, nullptr}};
wxTextCtrl* filterInput_{nullptr};
std::vector<Set> setsCacheWest_;
std::vector<Set> setsCacheAsia_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -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 <wx/panel.h>
#include <optional>
#include <string>
#include <vector>
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<PokemonCard> 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<PokemonCard> collection_;
ThemePalette palette_{};
std::optional<PokemonRegion> regionFilter_;
std::optional<Language> 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
+342 -27
View File
@@ -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 <wx/msgdlg.h>
#include <wx/bmpbuttn.h>
#include <wx/dcclient.h>
#include <wx/panel.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/splitter.h>
#include <wx/stattext.h>
#include <wx/textctrl.h>
#include <wx/window.h>
#include <optional>
#include <string>
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<int>(channel) + amount;
return static_cast<unsigned char>(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<int>(channel) - amount;
return static_cast<unsigned char>(lowered < 0 ? 0 : lowered);
};
return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue()));
}
} // namespace
PokemonGameView::PokemonGameView(ConfigService& config,
CollectionService<PokemonCard>& 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<Set>& 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<PokemonSetSource*>(&westModule_.setSource());
auto* asiaSrc = dynamic_cast<JapanesePokemonSetSource*>(&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
+395
View File
@@ -0,0 +1,395 @@
#include "ccm/ui/PokemonSetCompletionPanel.hpp"
#include "ccm/services/PokemonSetCompletion.hpp"
#include <wx/button.h>
#include <wx/choice.h>
#include <wx/cursor.h>
#include <wx/gauge.h>
#include <wx/listctrl.h>
#include <wx/scrolwin.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <string>
#include <utility>
namespace ccm::ui {
namespace {
wxColour mutedTextColour(const ThemePalette& palette) {
const auto blend = [](unsigned char a, unsigned char b) -> unsigned char {
return static_cast<unsigned char>((static_cast<int>(a) * 2 + static_cast<int>(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<PokemonCard> 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<int>(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<int>(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<std::size_t>(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<std::size_t>(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