mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
set completion pokemon
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()});
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -25,14 +25,25 @@ Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects
|
||||
**Info API:** `https://api.pokemontcg.io/v2/sets`
|
||||
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
|
||||
|
||||
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
|
||||
**Asset API:** `https://api.pokemontcg.io/v2/cards` (by id) and `https://api.pokemontcg.io/v2/cards?q=...` (search)
|
||||
Used by `PokemonCardPreviewSource` in two ways:
|
||||
|
||||
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
|
||||
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/cards/{setId}-{number}` (single-card `data` object) — same idea as Asia’s direct localId fetch — so Lucene `name:` ∩ `number:` misses cannot blank the preview after Auto-detect fills Set #. On HTTP failure or missing images, falls back to a name-less search `set.id:… number:…` (collector numbers are unique within a set). When Set # or set id is missing, keeps the older `name:"…"` search with optional `set.id` / `number`. The parser takes `images.large` first and falls back to `images.small`.
|
||||
|
||||
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<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 picker’s **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the card’s `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
|
||||
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the search endpoint with `name:"<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 picker’s **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the card’s `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
|
||||
|
||||
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
|
||||
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query and card-id path expect only the printed-number component (unquoted `number:4` / `number:TG14`; do not wrap alphanumeric numbers in Lucene quotes when combining with other clauses — that has been observed to 500 on the live API).
|
||||
|
||||
### Set-completion catalog (West)
|
||||
|
||||
**Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes:
|
||||
|
||||
1. The set list (`pokemon/sets-west.json`) from `/v2/sets` (same as before)
|
||||
2. A pack checklist at `<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 Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
|
||||
|
||||
+3
-2
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 game’s `IGameView::contentPanel` (Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magic’s splitter mounting.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a game’s `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<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 ~50–100 lines of hook overrides on top of the matching base template.
|
||||
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
|
||||
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`, `PokemonSetCompletionPanel`. Same Add/Edit shape as Magic for the card form; the game view hosts **Single Cards | Set Completion** via `contentPanel` / `hostsOwnLayout` (like Digimon/Yu-Gi-Oh!). Catalog from `PokemonSetCatalogService` (`set-catalog-west.json` / `set-catalog-asia.json`), filled on Update Pokemon. The Add/Edit/Delete + filter toolbar lives inside the Single Cards tab; MainFrame hides its shared toolbar while Pokemon is active.
|
||||
- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`).
|
||||
- `include/ccm/ui/YuGiOh*.hpp` + `src/YuGiOh*.cpp` — Yu-Gi-Oh!: list/selected/edit plus `YuGiOhGameView` notebook (**Single Cards** | **Set Completion**) via the same `hostsOwnLayout` / `contentPanel` pattern as Digimon, and `YuGiOhSetCompletionPanel`. Catalog from `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), filled on Update Sets from YGOPRODeck `cardinfo.php`.
|
||||
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
|
||||
@@ -29,7 +29,7 @@
|
||||
3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount.
|
||||
4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`.
|
||||
5. **wxFont modifications** mutate in place: `font.MakeBold().MakeLarger()` — do not call `Scale` (it does not exist on wxFont 3.2; use `MakeLarger` / `SetPointSize`).
|
||||
6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s and Yu-Gi-Oh!’s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs.
|
||||
6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s, Yu-Gi-Oh!’s, and Pokémon’s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs.
|
||||
7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link.
|
||||
8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user