Minor: Set completion tracking (#19)

This commit is contained in:
Sebastian Dine
2026-07-23 10:29:56 +02:00
committed by GitHub
parent c9e6bc2b6b
commit 9917e364c1
66 changed files with 5786 additions and 224 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ The `ccm` executable — composition root only. The single place where concrete
## Conventions
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `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`).
+17 -3
View File
@@ -21,6 +21,9 @@
#include "ccm/services/CardPreviewService.hpp"
#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"
#include "ccm/ui/AppContext.hpp"
@@ -110,6 +113,12 @@ public:
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
*fs_, *config_, &dirNameForGame);
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
digiBattle99CatalogStore_ =
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_);
@@ -158,12 +167,14 @@ 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_);
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
*ygoCatalogStore_);
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*digiBattle99Mod_);
*digiBattle99Mod_, *digiBattle99CatalogStore_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
@@ -203,6 +214,9 @@ private:
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
+3 -3
View File
@@ -4,11 +4,11 @@
## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `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/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect.
- `include/ccm/services/`high-level operations: `ConfigService`, `CollectionService<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.
+9
View File
@@ -8,6 +8,9 @@ add_library(ccm_core STATIC
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/DigiBattle99Card.cpp
src/domain/DigiBattle99SetCatalog.cpp
src/domain/YuGiOhSetCatalog.cpp
src/domain/PokemonSetCatalog.cpp
src/domain/JapanesePokemonCard.cpp
src/domain/Configuration.cpp
@@ -17,6 +20,12 @@ add_library(ccm_core STATIC
src/services/CardPreviewService.cpp
src/services/CardSorter.cpp
src/services/CardFilter.cpp
src/services/DigiBattle99SetCompletion.cpp
src/services/DigiBattle99SetCatalogService.cpp
src/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
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
// completion. Filled from digimoncard.io bulk search.php (same payload as the
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99CatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const DigiBattle99CatalogCard&,
const DigiBattle99CatalogCard&) = default;
};
struct DigiBattle99SetCatalogPack {
std::string setId;
std::string setName;
std::vector<DigiBattle99CatalogCard> cards;
friend bool operator==(const DigiBattle99SetCatalogPack&,
const DigiBattle99SetCatalogPack&) = default;
};
struct DigiBattle99SetCatalog {
std::vector<DigiBattle99SetCatalogPack> packs;
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const DigiBattle99SetCatalog&,
const DigiBattle99SetCatalog&) = default;
};
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
} // namespace ccm
@@ -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
@@ -0,0 +1,52 @@
#pragma once
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const YuGiOhCatalogCard&,
const YuGiOhCatalogCard&) = default;
};
struct YuGiOhSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhCatalogCard> cards;
friend bool operator==(const YuGiOhSetCatalogPack&,
const YuGiOhSetCatalogPack&) = default;
};
struct YuGiOhSetCatalog {
std::vector<YuGiOhSetCatalogPack> packs;
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhSetCatalog&,
const YuGiOhSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
} // namespace ccm
@@ -3,12 +3,15 @@
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
// The same payload also builds the set-completion catalog (parseCatalog).
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -20,12 +23,21 @@ public:
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
struct FetchWithCatalog {
std::vector<Set> sets;
DigiBattle99SetCatalog catalog;
};
explicit DigiBattle99SetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
// One HTTP round-trip producing both the set list and the pack catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
static std::string slugifyPackName(std::string_view packName);
@@ -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);
@@ -1,21 +1,45 @@
#pragma once
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
// Sets come from cardsets.php; the set-completion catalog is built from the
// unfiltered cardinfo.php dump (card_sets[] per card).
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <vector>
namespace ccm {
class YuGiOhSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
static constexpr const char* kCardInfoEndpoint =
"https://db.ygoprodeck.com/api/v7/cardinfo.php";
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhSetCatalog catalog;
};
explicit YuGiOhSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for
// the pack checklist catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Build the offline checklist from a cardinfo.php body, resolving pack
// ids against the already-parsed sets list (by set_name → Set.id).
static Result<YuGiOhSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
private:
IHttpClient& http_;
};
@@ -0,0 +1,36 @@
#pragma once
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
// the configured dataStorage path.
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class DigiBattle99SetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<DigiBattle99SetCatalog> load() const;
Result<void> save(const DigiBattle99SetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,59 @@
#pragma once
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
// inflate the numerator. An optional languageFilter restricts ownership to
// cards of that language (packs with zero matches are omitted).
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99SetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct DigiBattle99ChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -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
+4
View File
@@ -27,6 +27,10 @@ public:
// repository, and return the new list.
Result<std::vector<Set>> updateSets(Game game);
// Persist an already-fetched set list (no HTTP). Used when a game-specific
// Update Sets path fetches sets + side payloads in one round-trip.
Result<void> saveSets(Game game, const std::vector<Set>& sets);
// Cached read; returns an error if no local data exists yet.
Result<std::vector<Set>> getSets(Game game);
@@ -0,0 +1,36 @@
#pragma once
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
// configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhSetCatalog> load() const;
Result<void> save(const YuGiOhSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,60 @@
#pragma once
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
// amount / rarity / firstEdition do not inflate the numerator. An optional
// languageFilter restricts ownership to cards of that language (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhSetCompletionProgress {
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 YuGiOhChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,40 @@
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
namespace ccm {
const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack(
std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/PokemonSetCatalog.hpp"
namespace ccm {
const PokemonSetCatalogPack* PokemonSetCatalog::findPack(std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const PokemonCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, PokemonCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const PokemonSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, PokemonSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/YuGiOhSetCatalog.hpp"
namespace ccm {
const YuGiOhSetCatalogPack* YuGiOhSetCatalog::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 YuGiOhCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& 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 YuGiOhSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
@@ -1,5 +1,7 @@
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
@@ -40,6 +42,24 @@ std::string releaseDateForPack(const std::string& packName) {
return {};
}
Result<nlohmann::json> parseSearchArray(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return Result<nlohmann::json>::err(
j.value("error", std::string{"digimoncard.io set search error"}));
}
if (!j.is_array()) {
return Result<nlohmann::json>::err(
"digimoncard.io Digi-Battle response is not a JSON array.");
}
return Result<nlohmann::json>::ok(j);
} catch (const std::exception& e) {
return Result<nlohmann::json>::err(
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
}
} // namespace
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
@@ -61,59 +81,126 @@ std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
}
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return Result<std::vector<Set>>::err(
j.value("error", std::string{"digimoncard.io set search error"}));
}
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"digimoncard.io Digi-Battle response is not a JSON array.");
}
auto arr = parseSearchArray(body);
if (!arr) return Result<std::vector<Set>>::err(arr.error());
// Preserve first-seen order of pack names, then sort by release date.
std::unordered_set<std::string> seen;
std::vector<std::string> packNames;
packNames.reserve(16);
for (const auto& entry : j) {
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string name = pack.get<std::string>();
if (name.empty()) continue;
if (seen.insert(name).second) packNames.push_back(name);
}
// Preserve first-seen order of pack names, then sort by release date.
std::unordered_set<std::string> seen;
std::vector<std::string> packNames;
packNames.reserve(16);
for (const auto& entry : arr.value()) {
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string name = pack.get<std::string>();
if (name.empty()) continue;
if (seen.insert(name).second) packNames.push_back(name);
}
std::vector<Set> out;
out.reserve(packNames.size());
for (const auto& name : packNames) {
Set s;
s.id = slugifyPackName(name);
s.name = name;
s.releaseDate = releaseDateForPack(name);
if (s.id.empty()) continue;
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
return a.name < b.name;
});
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
std::vector<Set> out;
out.reserve(packNames.size());
for (const auto& name : packNames) {
Set s;
s.id = slugifyPackName(name);
s.name = name;
s.releaseDate = releaseDateForPack(name);
if (s.id.empty()) continue;
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
return a.name < b.name;
});
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<DigiBattle99SetCatalog> DigiBattle99SetSource::parseCatalog(const std::string& body) {
auto arr = parseSearchArray(body);
if (!arr) return Result<DigiBattle99SetCatalog>::err(arr.error());
// pack display name -> (setId, ordered unique cards by first-seen setNo)
struct PackBuild {
std::string setId;
std::string setName;
std::unordered_set<std::string> seenNos;
std::vector<DigiBattle99CatalogCard> cards;
};
std::unordered_map<std::string, PackBuild> byName;
for (const auto& entry : arr.value()) {
if (!entry.contains("name") || !entry.at("name").is_string()) continue;
if (!entry.contains("id") || !entry.at("id").is_string()) continue;
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
DigiBattle99CatalogCard card;
card.name = entry.at("name").get<std::string>();
card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(
entry.at("id").get<std::string>());
if (card.setNo.empty()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string packName = pack.get<std::string>();
if (packName.empty()) continue;
auto& build = byName[packName];
if (build.setName.empty()) {
build.setName = packName;
build.setId = slugifyPackName(packName);
}
if (build.setId.empty()) continue;
if (!build.seenNos.insert(card.setNo).second) continue;
build.cards.push_back(card);
}
}
DigiBattle99SetCatalog catalog;
catalog.packs.reserve(byName.size());
for (auto& [_, build] : byName) {
if (build.setId.empty()) continue;
std::sort(build.cards.begin(), build.cards.end(),
[](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
DigiBattle99SetCatalogPack pack;
pack.setId = std::move(build.setId);
pack.setName = std::move(build.setName);
pack.cards = std::move(build.cards);
catalog.packs.push_back(std::move(pack));
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) {
return a.setName < b.setName;
});
return Result<DigiBattle99SetCatalog>::ok(std::move(catalog));
}
Result<DigiBattle99SetSource::FetchWithCatalog>
DigiBattle99SetSource::fetchAllWithCatalog() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<FetchWithCatalog>::err(resp.error());
auto sets = parseResponse(resp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
auto catalog = parseCatalog(resp.value());
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog).value();
return Result<FetchWithCatalog>::ok(std::move(out));
}
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
auto both = fetchAllWithCatalog();
if (!both) return Result<std::vector<Set>>::err(both.error());
return Result<std::vector<Set>>::ok(std::move(both).value().sets);
}
} // namespace ccm
@@ -13,18 +13,6 @@ namespace ccm {
namespace {
// Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found.
std::string normalizeNumber(std::string_view setNo) {
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
}
return s;
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
@@ -38,31 +26,74 @@ std::string toLower(std::string s) {
return s;
}
Result<std::string, PreviewLookupError> imageUrlFromCardObject(const nlohmann::json& card) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
if (!card.contains("images") || !card.at("images").is_object()) {
return R::err({K::NotFound, "Card has no 'images' object."});
}
const auto& images = card.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return R::ok(images.at("large").get<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
}
} // namespace
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
// Pokemon TCG search uses an unquoted `number:` clause (e.g. number:4 or
// number:TG14). Cards are commonly stored as `4/102`; strip the suffix.
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
}
return s;
}
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
// Build the unencoded query first so the output matches what the Pokemon
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
std::string query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
// When both set id and collector number are known, omit name: — Lucene
// name∩number intersections can miss even when the print is real, and
// collector numbers are unique within a set.
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
std::string query;
if (!setId.empty() && !num.empty()) {
query = "set.id:";
query += std::string(setId);
}
const std::string num = normalizeNumber(setNo);
if (!num.empty()) {
query += " number:";
query += num;
} else {
query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
query += std::string(setId);
}
if (!num.empty()) {
query += " number:";
query += num;
}
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") +
rfc3986PercentEncode(query);
}
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
std::string_view setNo) {
const std::string num = PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
std::string id = std::string(setId) + "-" + num;
return std::string("https://api.pokemontcg.io/v2/cards/") + rfc3986PercentEncode(id);
}
std::string PokemonCardPreviewSource::buildDetectSearchUrl(std::string_view name,
std::string_view setId) {
std::string url = buildSearchUrl(name, setId, "");
@@ -84,18 +115,23 @@ PokemonCardPreviewSource::parseResponse(const std::string& body) {
if (data.empty()) {
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
}
const auto& first = data.at(0);
if (!first.contains("images") || !first.at("images").is_object()) {
return R::err({K::NotFound, "Card has no 'images' object."});
return imageUrlFromCardObject(data.at(0));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_object()) {
return R::err({K::Transient, "Pokemon TCG card response missing 'data' object."});
}
const auto& images = first.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return R::ok(images.at("large").get<std::string>());
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
return imageUrlFromCardObject(j.at("data"));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
@@ -108,6 +144,18 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string num = normalizeCollectorNumber(setNo);
if (!setId.empty() && !num.empty()) {
auto byId = http_.get(buildCardByIdUrl(setId, num));
if (byId) {
auto img = parseCardByIdResponse(byId.value());
if (img) return img;
// NotFound (no images) or Transient (schema): fall through to search.
}
// HTTP failure (404/5xx/offline): fall through to search.
}
const std::string url = buildSearchUrl(name, setId, setNo);
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
+150
View File
@@ -1,12 +1,35 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace ccm {
namespace {
void finalizeCatalog(PokemonSetCatalog& catalog) {
for (auto& pack : catalog.packs) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
}
} // namespace
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
@@ -36,10 +59,137 @@ Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body
}
}
std::string PokemonSetSource::buildCardsPageUrl(int page, int pageSize) {
return std::string(kCardsEndpoint) + "?select=name,number,set&pageSize=" +
std::to_string(pageSize) + "&page=" + std::to_string(page);
}
Result<PokemonSetSource::CardsPageMeta>
PokemonSetSource::mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return Result<CardsPageMeta>::err(
"Pokemon TCG cards response missing 'data' array.");
}
std::unordered_map<std::string, std::string> idToName;
idToName.reserve(sets.size());
for (const auto& set : sets) {
if (!set.id.empty()) idToName.emplace(set.id, set.name);
}
// Index existing packs for multi-page merges.
std::unordered_map<std::string, std::size_t> packIndex;
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
packIndex.emplace(catalog.packs[i].setId, i);
}
std::vector<std::unordered_set<std::string>> seenByPack(catalog.packs.size());
for (std::size_t i = 0; i < catalog.packs.size(); ++i) {
for (const auto& card : catalog.packs[i].cards) {
seenByPack[i].insert(card.setNo);
}
}
for (const auto& entry : j.at("data")) {
const std::string name = entry.value("name", "");
const std::string number =
PokemonCardPreviewSource::normalizeCollectorNumber(entry.value("number", ""));
if (name.empty() || number.empty()) continue;
std::string setId;
std::string setName;
if (entry.contains("set") && entry.at("set").is_object()) {
setId = entry.at("set").value("id", "");
setName = entry.at("set").value("name", "");
}
if (setId.empty()) continue;
if (const auto it = idToName.find(setId); it != idToName.end() && !it->second.empty()) {
setName = it->second;
}
if (setName.empty()) setName = setId;
auto pit = packIndex.find(setId);
if (pit == packIndex.end()) {
PokemonSetCatalogPack pack;
pack.setId = setId;
pack.setName = setName;
pack.cards.push_back(PokemonCatalogCard{number, name});
packIndex.emplace(setId, catalog.packs.size());
seenByPack.emplace_back(std::unordered_set<std::string>{number});
catalog.packs.push_back(std::move(pack));
continue;
}
const std::size_t idx = pit->second;
if (!seenByPack[idx].insert(number).second) continue;
if (catalog.packs[idx].setName.empty() && !setName.empty()) {
catalog.packs[idx].setName = setName;
}
catalog.packs[idx].cards.push_back(PokemonCatalogCard{number, name});
}
CardsPageMeta meta;
meta.page = j.value("page", 1);
meta.pageSize = j.value("pageSize", kCardsPageSize);
meta.count = j.value("count", static_cast<int>(j.at("data").size()));
meta.totalCount = j.value("totalCount", meta.count);
return Result<CardsPageMeta>::ok(meta);
} catch (const std::exception& e) {
return Result<CardsPageMeta>::err(
std::string("Pokemon TCG cards JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalog> PokemonSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
PokemonSetCatalog catalog;
auto meta = mergeCardsPage(body, catalog, sets);
if (!meta) return Result<PokemonSetCatalog>::err(meta.error());
finalizeCatalog(catalog);
return Result<PokemonSetCatalog>::ok(std::move(catalog));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto setsResp = http_.get(kEndpoint);
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
auto sets = parseResponse(setsResp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
PokemonSetCatalog catalog;
int page = 1;
int totalCount = 0;
int fetched = 0;
for (;;) {
auto cardsResp = http_.get(buildCardsPageUrl(page));
if (!cardsResp) return Result<FetchWithCatalog>::err(cardsResp.error());
auto meta = mergeCardsPage(cardsResp.value(), catalog, sets.value());
if (!meta) return Result<FetchWithCatalog>::err(meta.error());
fetched += meta.value().count;
totalCount = meta.value().totalCount;
if (meta.value().count <= 0 || fetched >= totalCount) break;
++page;
// Safety: avoid unbounded loops if the API lies about totals.
if (page > 10000) {
return Result<FetchWithCatalog>::err(
"Pokemon TCG cards pagination exceeded safety limit.");
}
}
finalizeCatalog(catalog);
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -144,6 +144,20 @@ bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noe
return it != printKeysBySet_.end() && !it->second.empty();
}
std::vector<JapanesePokemonPrintEnInfo>
JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const {
std::vector<JapanesePokemonPrintEnInfo> out;
const auto keysIt = printKeysBySet_.find(std::string(setId));
if (keysIt == printKeysBySet_.end()) return out;
out.reserve(keysIt->second.size());
for (const auto& key : keysIt->second) {
const auto pit = printsByKey_.find(key);
if (pit == printsByKey_.end()) continue;
out.push_back(pit->second);
}
return out;
}
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
if (productId.empty()) return {};
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
@@ -1,5 +1,6 @@
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
@@ -8,6 +9,8 @@
#include <array>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace ccm {
@@ -55,6 +58,41 @@ const std::unordered_map<std::string, std::string>& setNameJaOverrides() {
return false;
}
void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
const JapanesePokemonEnCatalog& enCatalog) {
std::unordered_set<std::string> seen;
for (const auto& card : pack.cards) {
seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo));
}
for (const auto& print : enCatalog.printsForSet(pack.setId)) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name = print.nameEn;
if (name.empty()) name = print.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
}
void sortPackCards(PokemonSetCatalogPack& pack) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
}
void applyEnglishSetName(Set& s, const JapanesePokemonEnCatalog& catalog) {
if (auto en = catalog.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
}
} // namespace
JapanesePokemonSetSource::JapanesePokemonSetSource(
@@ -152,6 +190,70 @@ JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) {
}
}
PokemonSetCatalogPack JapanesePokemonSetSource::catalogPackFromEnCatalog(
const Set& set, const JapanesePokemonEnCatalog& enCatalog) {
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
for (const auto& print : enCatalog.printsForSet(set.id)) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
if (localId.empty()) continue;
std::string name = print.nameEn;
if (name.empty()) name = print.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
sortPackCards(pack);
return pack;
}
Result<PokemonSetCatalogPack> JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog) {
auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
// Transient/NotFound from parse — treat empty cards as catalog-only.
if (rows.error().kind == PreviewLookupError::Kind::NotFound) {
auto pack = catalogPackFromEnCatalog(set, enCatalog);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err(
"No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name;
if (auto print = enCatalog.findPrint(set.id, localId)) {
name = print->nameEn;
if (name.empty()) name = print->nameJa;
}
if (name.empty()) name = row.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
gapFillFromEnCatalog(pack, enCatalog);
sortPackCards(pack);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
@@ -163,13 +265,7 @@ Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
for (auto& s : out) {
// Prefer catalog English; never leave Japanese TCGdex names in Set.name
// (the set picker must stay English-only).
if (auto en = catalog_.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
applyEnglishSetName(s, catalog_);
if (!s.releaseDate.empty()) continue;
auto detail = http_.get(buildSetDetailUrl(s.id));
@@ -185,19 +281,67 @@ Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<JapanesePokemonSetSource::FetchWithCatalog>
JapanesePokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> sets = std::move(parsed).value();
PokemonSetCatalog catalog;
catalog.packs.reserve(sets.size());
for (auto& s : sets) {
applyEnglishSetName(s, catalog_);
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) {
// Classic / catalog-only products often have no TCGdex detail.
auto pack = catalogPackFromEnCatalog(s, catalog_);
if (!pack.cards.empty()) {
catalog.packs.push_back(std::move(pack));
}
continue;
}
if (s.releaseDate.empty()) {
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_);
if (pack) {
catalog.packs.push_back(std::move(pack).value());
} else {
auto fallback = catalogPackFromEnCatalog(s, catalog_);
if (!fallback.cards.empty()) {
catalog.packs.push_back(std::move(fallback));
}
}
}
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
FetchWithCatalog out;
out.sets = std::move(sets);
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
for (auto& s : sets) {
if (auto en = catalog_.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty() && s.releaseDate.empty()) {
s.releaseDate = en->releaseDate;
}
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
applyEnglishSetName(s, catalog_);
}
appendMissingClassicProducts(sets);
std::sort(sets.begin(), sets.end(),
+152
View File
@@ -1,10 +1,15 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cctype>
#include <string>
#include <unordered_map>
#include <utility>
namespace ccm {
namespace {
@@ -39,6 +44,47 @@ void appendMissingSetAliases(std::vector<Set>& sets) {
}
}
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
const std::string digits = ygoCollectorDigitsOnly(setNo);
if (abbrev.empty() || digits.empty()) return {};
return abbrev + "|" + digits;
}
[[nodiscard]] bool ygoHasEnRegionInfix(std::string_view setCode) {
const std::string_view s = trimAsciiSpaces(setCode);
const auto dash = s.find('-');
if (dash == std::string_view::npos || dash + 3 > s.size()) return false;
const std::string_view tail = s.substr(dash + 1);
if (tail.size() < 3) return false;
return (tail[0] == 'E' || tail[0] == 'e') && (tail[1] == 'N' || tail[1] == 'n')
&& std::isdigit(static_cast<unsigned char>(tail[2])) != 0;
}
[[nodiscard]] std::string uppercaseAscii(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
return s;
}
[[nodiscard]] std::string resolvePackId(const std::unordered_map<std::string, std::string>& nameToId,
const std::string& setName,
const std::string& setCode) {
const auto it = nameToId.find(setName);
if (it != nameToId.end() && !it->second.empty()) return it->second;
const std::string abbrev = uppercaseAscii(ygoAbbrevBeforeDash(setCode));
return abbrev;
}
struct PackBuild {
std::string setId;
std::string setName;
// slotKey → index into cards (for EN preference upgrades).
std::unordered_map<std::string, std::size_t> slotIndex;
std::vector<YuGiOhCatalogCard> cards;
};
} // namespace
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
@@ -73,10 +119,116 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
}
}
Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object() || !j.contains("data") || !j.at("data").is_array()) {
return Result<YuGiOhSetCatalog>::err(
"YGOPRODeck cardinfo response missing data array.");
}
std::unordered_map<std::string, std::string> nameToId;
nameToId.reserve(sets.size());
for (const auto& set : sets) {
if (set.name.empty() || set.id.empty()) continue;
// First wins — aliases and upstream rows rarely collide by name.
nameToId.emplace(set.name, set.id);
}
// Keyed by pack setId.
std::unordered_map<std::string, PackBuild> byId;
for (const auto& cardJson : j.at("data")) {
const std::string cardName = cardJson.value("name", "");
if (cardName.empty()) continue;
if (!cardJson.contains("card_sets") || !cardJson.at("card_sets").is_array()) {
continue;
}
for (const auto& printing : cardJson.at("card_sets")) {
const std::string setName = printing.value("set_name", "");
const std::string setCode = printing.value("set_code", "");
if (setName.empty() || setCode.empty()) continue;
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
const std::string slot = ygoSlotKey(setCode);
if (slot.empty()) continue;
const std::string packId = resolvePackId(nameToId, setName, setCode);
if (packId.empty()) continue;
auto& build = byId[packId];
if (build.setId.empty()) {
build.setId = packId;
build.setName = setName;
}
const auto existing = build.slotIndex.find(slot);
if (existing == build.slotIndex.end()) {
build.slotIndex.emplace(slot, build.cards.size());
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
continue;
}
// Prefer an EN-embedded code over a bare / other-region equivalent.
auto& prev = build.cards[existing->second];
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
prev.setNo = setCode;
if (!cardName.empty()) prev.name = cardName;
}
}
}
YuGiOhSetCatalog catalog;
catalog.packs.reserve(byId.size());
for (auto& [_, build] : byId) {
if (build.setId.empty() || build.cards.empty()) continue;
std::sort(build.cards.begin(), build.cards.end(),
[](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
YuGiOhSetCatalogPack pack;
pack.setId = std::move(build.setId);
pack.setName = std::move(build.setName);
pack.cards = std::move(build.cards);
catalog.packs.push_back(std::move(pack));
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) {
return a.setName < b.setName;
});
return Result<YuGiOhSetCatalog>::ok(std::move(catalog));
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("YGOPRODeck catalog parse error: ") + e.what());
}
}
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
Result<YuGiOhSetSource::FetchWithCatalog> YuGiOhSetSource::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());
auto infoResp = http_.get(kCardInfoEndpoint);
if (!infoResp) return Result<FetchWithCatalog>::err(infoResp.error());
auto catalog = parseCatalog(infoResp.value(), sets.value());
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog).value();
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -0,0 +1,50 @@
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
DigiBattle99SetCatalogService::DigiBattle99SetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path DigiBattle99SetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::DigiBattle99) /
"set-catalog.json";
}
bool DigiBattle99SetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<DigiBattle99SetCatalog> DigiBattle99SetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<DigiBattle99SetCatalog>::err(
"Digimon Digi-Battle set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<DigiBattle99SetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<DigiBattle99SetCatalog>::ok(j.get<DigiBattle99SetCatalog>());
} catch (const std::exception& e) {
return Result<DigiBattle99SetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> DigiBattle99SetCatalogService::save(const DigiBattle99SetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
@@ -0,0 +1,129 @@
#include "ccm/services/DigiBattle99SetCompletion.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.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 DigiBattle99Card& card,
std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSetNosBySetId(const std::vector<DigiBattle99Card>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
} // namespace
std::vector<Language>
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
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<DigiBattle99SetCompletionProgress>
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
std::vector<DigiBattle99SetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedNos] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
DigiBattle99SetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const DigiBattle99SetCompletionProgress& a,
const DigiBattle99SetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<DigiBattle99ChecklistEntry>
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<DigiBattle99ChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
DigiBattle99ChecklistEntry entry;
entry.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
entry.name = card.name;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const DigiBattle99ChecklistEntry& a,
const DigiBattle99ChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
@@ -0,0 +1,54 @@
#include "ccm/services/PokemonSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
PokemonSetCatalogService::PokemonSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path PokemonSetCatalogService::catalogPath(PokemonRegion region) const {
const char* file = region == PokemonRegion::Asia ? "set-catalog-asia.json"
: "set-catalog-west.json";
return fs::path(config_.current().dataStorage) / dirName_(Game::Pokemon) / file;
}
bool PokemonSetCatalogService::exists(PokemonRegion region) const {
return fs_.exists(catalogPath(region));
}
Result<PokemonSetCatalog> PokemonSetCatalogService::load(PokemonRegion region) const {
const auto p = catalogPath(region);
if (!fs_.exists(p)) {
return Result<PokemonSetCatalog>::err(
region == PokemonRegion::Asia
? "Asia Pokemon set catalog not yet downloaded."
: "West Pokemon set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<PokemonSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<PokemonSetCatalog>::ok(j.get<PokemonSetCatalog>());
} catch (const std::exception& e) {
return Result<PokemonSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> PokemonSetCatalogService::save(PokemonRegion region,
const PokemonSetCatalog& catalog) {
const auto p = catalogPath(region);
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+190
View File
@@ -0,0 +1,190 @@
#include "ccm/services/PokemonSetCompletion.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
bool passesLanguageFilter(const PokemonCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
bool passesRegionFilter(const PokemonCard& card, std::optional<PokemonRegion> regionFilter) {
return !regionFilter.has_value() || card.region == *regionFilter;
}
std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
if (region == PokemonRegion::Asia) {
return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo);
}
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
}
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
PokemonRegion region,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
std::vector<PokemonSetCompletionProgress>
computeForCatalog(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& catalog,
PokemonRegion region,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, region, languageFilter);
std::vector<PokemonSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedNos] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo = normalizeForRegion(region, card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
PokemonSetCompletionProgress row;
row.region = region;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
return out;
}
} // namespace
std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
if (!passesRegionFilter(card, regionFilter)) continue;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog) {
std::vector<PokemonRegion> out;
const auto westRows =
computeForCatalog(collection, westCatalog, PokemonRegion::West, std::nullopt);
if (!westRows.empty()) out.push_back(PokemonRegion::West);
const auto asiaRows =
computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, std::nullopt);
if (!asiaRows.empty()) out.push_back(PokemonRegion::Asia);
return out;
}
std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter,
std::optional<Language> languageFilter) {
std::vector<PokemonSetCompletionProgress> out;
const bool includeWest =
!regionFilter.has_value() || *regionFilter == PokemonRegion::West;
const bool includeAsia =
!regionFilter.has_value() || *regionFilter == PokemonRegion::Asia;
if (includeWest) {
auto west = computeForCatalog(collection, westCatalog, PokemonRegion::West,
languageFilter);
out.insert(out.end(), std::make_move_iterator(west.begin()),
std::make_move_iterator(west.end()));
}
if (includeAsia) {
auto asia = computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia,
languageFilter);
out.insert(out.end(), std::make_move_iterator(asia.begin()),
std::make_move_iterator(asia.end()));
}
std::sort(out.begin(), out.end(),
[](const PokemonSetCompletionProgress& a,
const PokemonSetCompletionProgress& b) {
if (a.setName != b.setName) return a.setName < b.setName;
return static_cast<int>(a.region) < static_cast<int>(b.region);
});
return out;
}
std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter) {
const PokemonSetCatalog& catalog =
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<PokemonChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
PokemonChecklistEntry entry;
entry.setNo = normalizeForRegion(region, card.setNo);
entry.name = card.name;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+4
View File
@@ -20,6 +20,10 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
return fetched;
}
Result<void> SetService::saveSets(Game game, const std::vector<Set>& sets) {
return repo_.save(game, sets);
}
Result<std::vector<Set>> SetService::getSets(Game game) {
auto loaded = repo_.load(game);
if (!loaded) return loaded;
@@ -0,0 +1,49 @@
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
YuGiOhSetCatalogService::YuGiOhSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path YuGiOhSetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOh) /
"set-catalog.json";
}
bool YuGiOhSetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<YuGiOhSetCatalog> YuGiOhSetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<YuGiOhSetCatalog>::err("Yu-Gi-Oh! set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<YuGiOhSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<YuGiOhSetCatalog>::ok(j.get<YuGiOhSetCatalog>());
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> YuGiOhSetCatalogService::save(const YuGiOhSetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+132
View File
@@ -0,0 +1,132 @@
#include "ccm/services/YuGiOhSetCompletion.hpp"
#include "ccm/util/YuGiOhPrintingSlot.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>>;
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
const std::string digits = ygoCollectorDigitsOnly(setNo);
if (abbrev.empty() || digits.empty()) return {};
return abbrev + "|" + digits;
}
bool passesLanguageFilter(const YuGiOhCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSlotsBySetId(const std::vector<YuGiOhCard>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string key = ygoSlotKey(card.setNo);
if (key.empty()) continue;
out[card.set.id].insert(key);
}
return out;
}
} // namespace
std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
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<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSlotsBySetId(collection, languageFilter);
std::vector<YuGiOhSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedSlots] : 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 key = ygoSlotKey(card.setNo);
if (!key.empty() && ownedSlots.count(key) != 0) ++matched;
}
YuGiOhSetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhSetCompletionProgress& a,
const YuGiOhSetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedSlots;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string key = ygoSlotKey(card.setNo);
if (!key.empty()) ownedSlots.insert(key);
}
std::vector<YuGiOhChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
YuGiOhChecklistEntry entry;
entry.setNo = card.setNo;
entry.name = card.name;
const std::string key = ygoSlotKey(card.setNo);
entry.owned = !key.empty() && ownedSlots.count(key) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhChecklistEntry& a, const YuGiOhChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+53 -5
View File
@@ -25,14 +25,25 @@ Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects
**Info API:** `https://api.pokemontcg.io/v2/sets`
Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date.
**Asset API:** `https://api.pokemontcg.io/v2/cards?q=...`
**Asset API:** `https://api.pokemontcg.io/v2/cards` (by id) and `https://api.pokemontcg.io/v2/cards?q=...` (search)
Used by `PokemonCardPreviewSource` in two ways:
1. **Preview lookup (`fetchImageUrl`).** Search by `name` plus optional `set.id` and collector number. The parser takes `data[0].images.large` first and falls back to `images.small` if needed.
1. **Preview lookup (`fetchImageUrl`).** When both set id and collector number are present, prefers `GET /v2/cards/{setId}-{number}` (single-card `data` object) — same idea as Asias direct localId fetch — so Lucene `name:` `number:` misses cannot blank the preview after Auto-detect fills Set #. On HTTP failure or missing images, falls back to a name-less search `set.id:… number:…` (collector numbers are unique within a set). When Set # or set id is missing, keeps the older `name:"…"` search with optional `set.id` / `number`. The parser takes `images.large` first and falls back to `images.small`.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the same endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
2. **Auto-detect print (`detectFirstPrint` / `detectPrintVariants`, Pokémon edit dialog).** Uses the search endpoint with `name:"<name>"` and `set.id:<setId>` only — **no** `number:` clause — plus `select=name,number,rarity,set` and `pageSize=50` so the response stays small. If the set-scoped HTTP request fails, it retries with **`name:` only** and still filters rows in `PokemonCardPreviewSource::parsePrintVariants(...)` by the pickers **`set.id`** (not the display set name). The dialog passes `card.set.id` into `CardPreviewService::detectPrintVariants(...)` on a worker thread so the modal stays responsive. Each matching `data[]` row whose **card name matches exactly** (case-insensitive) and whose embedded `set.id` equals the chosen set maps to `AutoDetectedPrint::setNo` as the API `number` field only (for example `25`, not `25/185`). `AutoDetectedPrint::rarity` is filled from the cards `rarity` field but the Pokémon edit dialog does not auto-sync holo or other flags from it. Distinct `(setNo, rarity)` pairs are deduped. When both an exact card name and `set.id` are supplied, an upstream miss returns an error instead of blending unrelated sets from a broader payload. The edit dialog offers **Auto detect** (fills Set # from the first variant), **Next** (cycles distinct `setNo` values when multiple exist), silent prefetch on **Edit** open, and clears cached variants when **Name** or **Set** changes. The Set # field and persisted `PokemonCard::setNo` keep only the printed-number portion; values such as `4/104` are trimmed to `4` on load and save.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query expects only the printed-number component.
The preview path normalizes collector numbers before request build. For example, `4/102` is reduced to `4` because the remote `number:` query and card-id path expect only the printed-number component (unquoted `number:4` / `number:TG14`; do not wrap alphanumeric numbers in Lucene quotes when combining with other clauses — that has been observed to 500 on the live API).
### Set-completion catalog (West)
**Sets → Update Pokemon** uses `PokemonSetSource::fetchAllWithCatalog()` so the West path writes:
1. The set list (`pokemon/sets-west.json`) from `/v2/sets` (same as before)
2. A pack checklist at `<dataStorage>/pokemon/set-catalog-west.json` from a paginated `/v2/cards?select=name,number,set&pageSize=250` dump
Each catalog pack stores `id` (pokemontcg.io set id), `name` (display), and `cards[]` of `{ setNo, name }` keyed by the API `number` field (normalized by stripping anything after `/`). Duplicate collector numbers within a pack collapse to one checklist row. The Pokemon **Set Completion** tab reads this file offline; ownership for a West pack requires `PokemonRegion::West`, matching `card.set.id`, and a normalized collector number match. Amount / holo / 1st Edition are ignored for completion counts.
If `set-catalog-west.json` is missing (and the active region filter is West or All with no Asia catalog either), the Set Completion tab prompts the user to run Update Pokemon.
## Yu-Gi-Oh! APIs (Yugipedia + YGOPRODeck)
@@ -82,6 +93,17 @@ Used in two situations:
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
### Set-completion catalog (`cardinfo.php` all-cards dump)
**Sets → Update Yu-Gi-Oh!** uses `YuGiOhSetSource::fetchAllWithCatalog()` so two HTTP responses write:
1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases)
2. A pack checklist at `<dataStorage>/yugioh/set-catalog.json` from the unfiltered `cardinfo.php` dump
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name }` drawn from each cards `card_sets[]`. European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
## Digimon Digi-Battle (1999) APIs (digimoncard.io)
English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse).
@@ -100,6 +122,19 @@ and collects unique `set_name[]` pack strings. Each pack becomes a `Set` with:
Unknown future packs get an empty release date and sort last.
Cached on disk as `<dataStorage>/digibattle99/sets.json` via `SetService` / `JsonSetRepository`.
### Set-completion catalog (same `search.php` payload)
**Sets → Update Digimon (Digi-Battle)** uses `DigiBattle99SetSource::fetchAllWithCatalog()` so one HTTP response writes both:
1. The set list (`sets.json`) as above
2. A pack checklist at `<dataStorage>/digibattle99/set-catalog.json`
Each catalog pack stores `id` (slug), `name` (display), and `cards[]` of `{ setNo, name }` (API `id` normalized like preview — alphabetic prefix uppercased). A card listed in multiple `set_name[]` packs appears under **each** pack. The Digimon **Set Completion** tab reads this file offline (no live HTTP while browsing); ownership for a pack requires matching `card.set.id` plus normalized `setNo`.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Digimon (Digi-Battle).
### Asset API: CDN images + `search.php` lookup
Card scans live at:
@@ -146,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.
@@ -273,6 +321,6 @@ All source types return `Result<T, std::string>` errors so failures cross bounda
- info API failures (bad set payload, schema mismatch, endpoint/network failure), and
- asset API failures (query mismatch, no matching card, missing image fields, image download failure).
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon West (`GET /v2/cards/{setId}-{number}``data` object, or search `data[]`; `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages.<id>.imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text.
For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedias gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedias `Card_Gallery:<Card>` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first.
+7 -4
View File
@@ -19,11 +19,14 @@
- `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`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. Drives `fetchAll` via `FixedHttpClient`.
- `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.
- `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).
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh / DigiBattle99) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
- `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005``LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
+3
View File
@@ -23,6 +23,9 @@ add_executable(ccm_core_tests
pokemon_card_preview_source_tests.cpp
digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp
digibattle99_set_completion_tests.cpp
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
+263
View File
@@ -0,0 +1,263 @@
#include <doctest/doctest.h>
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/DigiBattle99SetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <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;
}
DigiBattle99Card makeOwned(std::string setId, std::string setName, std::string setNo) {
DigiBattle99Card c;
c.id = 1;
c.name = "Owned";
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
return c;
}
DigiBattle99SetCatalog sampleCatalog() {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack starter;
starter.setId = "series-1-starter-set";
starter.setName = "Series 1 Starter Set";
starter.cards = {
{"ST-01", "Agumon"},
{"ST-02", "Greymon"},
{"ST-03", "Gabumon"},
};
DigiBattle99SetCatalogPack booster;
booster.setId = "series-1-booster-pack";
booster.setName = "Series 1 Booster Pack";
booster.cards = {
{"ST-01", "Agumon"},
{"BO-01", "MetalGreymon"},
};
catalog.packs.push_back(std::move(booster));
catalog.packs.push_back(std::move(starter));
return catalog;
}
} // namespace
TEST_SUITE("computeDigiBattle99SetCompletion") {
TEST_CASE("only packs with owned cards appear") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("unique setNo within a pack; amount does not inflate") {
const auto catalog = sampleCatalog();
DigiBattle99Card a = makeOwned("series-1-starter-set", "Series 1 Starter Set", "st-01");
a.amount = 4;
DigiBattle99Card b = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
b.id = 2;
DigiBattle99Card c = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
c.id = 3;
const auto rows =
computeDigiBattle99SetCompletion({a, b, c}, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 66);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "series-1-starter-set");
}
TEST_CASE("empty catalog yields no rows") {
DigiBattle99SetCatalog empty;
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, empty).empty());
}
TEST_CASE("owned set missing from catalog is skipped") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack onlyBooster;
onlyBooster.setId = "series-1-booster-pack";
onlyBooster.setName = "Series 1 Booster Pack";
onlyBooster.cards = {{"BO-01", "MetalGreymon"}};
catalog.packs.push_back(std::move(onlyBooster));
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
};
CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty());
}
TEST_CASE("language filter hides packs with no cards in that language") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
en.language = Language::English;
const auto allRows = computeDigiBattle99SetCompletion({en}, catalog);
REQUIRE(allRows.size() == 1);
const auto deRows =
computeDigiBattle99SetCompletion({en}, catalog, Language::German);
CHECK(deRows.empty());
const auto enRows =
computeDigiBattle99SetCompletion({en}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
}
TEST_CASE("same setNo in two languages counts once aggregated; filter is exclusive") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
en.language = Language::English;
DigiBattle99Card de =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
de.id = 2;
de.language = Language::German;
const auto allRows = computeDigiBattle99SetCompletion({en, de}, catalog);
REQUIRE(allRows.size() == 1);
CHECK(allRows[0].ownedUnique == 1);
const auto enRows =
computeDigiBattle99SetCompletion({en, de}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
DigiBattle99Card deOnly =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
deOnly.id = 3;
deOnly.language = Language::German;
const auto deRows = computeDigiBattle99SetCompletion({en, de, deOnly}, catalog,
Language::German);
REQUIRE(deRows.size() == 1);
CHECK(deRows[0].ownedUnique == 2);
}
}
TEST_SUITE("digiBattle99ChecklistForSet") {
TEST_CASE("greys missing cards and marks owned ones") {
const auto catalog = sampleCatalog();
std::vector<DigiBattle99Card> collection{
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"),
};
const auto list =
digiBattle99ChecklistForSet(collection, catalog, "series-1-starter-set");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "ST-01");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "ST-02");
CHECK(list[1].owned == true);
CHECK(list[2].setNo == "ST-03");
CHECK(list[2].owned == false);
}
TEST_CASE("unknown set returns empty") {
const auto catalog = sampleCatalog();
CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty());
}
TEST_CASE("owned flags respect language filter") {
const auto catalog = sampleCatalog();
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
en.language = Language::English;
const auto filtered =
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
Language::German);
REQUIRE(filtered.size() == 3);
CHECK(filtered[0].owned == false);
CHECK(filtered[1].owned == false);
CHECK(filtered[2].owned == false);
const auto english =
digiBattle99ChecklistForSet({en}, catalog, "series-1-starter-set",
Language::English);
REQUIRE(english.size() == 3);
CHECK(english[1].owned == true);
}
}
TEST_SUITE("digiBattle99LanguagesInCollection") {
TEST_CASE("empty collection yields empty") {
CHECK(digiBattle99LanguagesInCollection({}).empty());
}
TEST_CASE("returns distinct languages in allLanguages order") {
DigiBattle99Card jp =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
jp.language = Language::Japanese;
DigiBattle99Card en =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
en.id = 2;
en.language = Language::English;
DigiBattle99Card enDup =
makeOwned("series-1-booster-pack", "Series 1 Booster Pack", "BO-01");
enDup.id = 3;
enDup.language = Language::English;
DigiBattle99Card de =
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-03");
de.id = 4;
de.language = Language::German;
const auto langs = digiBattle99LanguagesInCollection({jp, en, enDup, de});
REQUIRE(langs.size() == 3);
CHECK(langs[0] == Language::English);
CHECK(langs[1] == Language::German);
CHECK(langs[2] == Language::Japanese);
}
}
TEST_SUITE("DigiBattle99SetCatalogService") {
TEST_CASE("save then load round-trips") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
DigiBattle99SetCatalogService store{fs, config, [](Game) { return "digibattle99"; }};
CHECK_FALSE(store.exists());
CHECK(store.load().isErr());
const auto catalog = sampleCatalog();
REQUIRE(store.save(catalog).isOk());
CHECK(store.exists());
const auto loaded = store.load();
REQUIRE(loaded.isOk());
CHECK(loaded.value() == catalog);
}
}
+71
View File
@@ -109,4 +109,75 @@ TEST_SUITE("DigiBattle99SetSource::fetchAll") {
CHECK(out.value().front().id == "series-1-starter-set");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
TEST_CASE("fetchAllWithCatalog returns sets and pack cards in one GET") {
FixedHttpClient http;
http.ok = true;
http.body = R"([
{"name":"Agumon","id":"st-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
])";
DigiBattle99SetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
CHECK(out.value().sets.size() == 2);
const auto* starter = out.value().catalog.findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 2);
CHECK(starter->cards[0].setNo == "ST-01");
CHECK(starter->cards[0].name == "Agumon");
const auto* booster = out.value().catalog.findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 1);
CHECK(booster->cards[0].setNo == "ST-01");
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
}
}
TEST_SUITE("DigiBattle99SetSource::parseCatalog") {
TEST_CASE("lists a card under every pack in set_name") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
REQUIRE(out.value().packs.size() == 2);
const auto* booster = out.value().findPack("series-1-booster-pack");
REQUIRE(booster != nullptr);
REQUIRE(booster->cards.size() == 2);
CHECK(booster->cards[0].setNo == "BO-01");
CHECK(booster->cards[1].setNo == "ST-01");
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].setNo == "ST-01");
}
TEST_CASE("dedupes the same setNo within one pack") {
const std::string json = R"([
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
{"name":"Agumon Alt","id":"ST-01","set_name":["Series 1 Starter Set"]}
])";
const auto out = DigiBattle99SetSource::parseCatalog(json);
REQUIRE(out.isOk());
const auto* starter = out.value().findPack("series-1-starter-set");
REQUIRE(starter != nullptr);
REQUIRE(starter->cards.size() == 1);
CHECK(starter->cards[0].name == "Agumon");
}
TEST_CASE("empty array returns an empty catalog") {
const auto out = DigiBattle99SetSource::parseCatalog("[]");
REQUIRE(out.isOk());
CHECK(out.value().empty());
}
TEST_CASE("error object is an error") {
const auto out = DigiBattle99SetSource::parseCatalog(
R"({"error":"No cards found for this search."})");
CHECK(out.isErr());
}
}
+69
View File
@@ -2,6 +2,9 @@
#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"
#include "ccm/domain/MagicCard.hpp"
@@ -245,6 +248,72 @@ TEST_SUITE("DigiBattle99Card JSON") {
}
}
TEST_SUITE("DigiBattle99SetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
DigiBattle99SetCatalog catalog;
DigiBattle99SetCatalogPack pack;
pack.setId = "series-1-starter-set";
pack.setName = "Series 1 Starter Set";
pack.cards.push_back(DigiBattle99CatalogCard{"ST-01", "Agumon"});
pack.cards.push_back(DigiBattle99CatalogCard{"ST-126", "Agumon"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "series-1-starter-set");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "ST-01");
const DigiBattle99SetCatalog back = j.get<DigiBattle99SetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("series-1-starter-set") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("YuGiOhSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.setName = "Legend of Blue Eyes White Dragon";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician"});
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") == "LOB");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "LOB-001");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("LOB") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("PokemonSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
PokemonSetCatalog catalog;
PokemonSetCatalogPack pack;
pack.setId = "base1";
pack.setName = "Base";
pack.cards.push_back(PokemonCatalogCard{"4", "Charizard"});
pack.cards.push_back(PokemonCatalogCard{"58", "Growlithe"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "base1");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "4");
const PokemonSetCatalog back = j.get<PokemonSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("base1") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c;
@@ -139,6 +139,21 @@ TEST_SUITE("JapanesePokemonEnCatalog") {
CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1"));
}
TEST_CASE("printsForSet returns all prints for a set id") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"A","local_id":"1","name_en":"One"},
{"set_id":"A","local_id":"2","name_en":"Two"},
{"set_id":"B","local_id":"1","name_en":"Other"}
]
})");
REQUIRE(catalog.isOk());
const auto prints = catalog.value().printsForSet("A");
REQUIRE(prints.size() == 2);
CHECK(catalog.value().printsForSet("missing").empty());
}
TEST_CASE("missing set/print returns nullopt") {
JapanesePokemonEnCatalog empty;
CHECK_FALSE(empty.findSet("X").has_value());
@@ -315,3 +315,56 @@ TEST_SUITE("JapanesePokemonSetSource::fetchAll") {
CHECK(jungle->name == "Pokémon Jungle");
}
}
TEST_SUITE("JapanesePokemonSetSource::parseCatalogPackFromSetDetail") {
TEST_CASE("builds checklist from cards[] and prefers EN catalog names") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {"PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック"}},
"prints": [
{"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"},
{"set_id":"PMCG1","local_id":"099","name_en":"Catalog Only","name_ja":""}
]
})");
REQUIRE(catalog.isOk());
Set set;
set.id = "PMCG1";
set.name = "Expansion Pack";
const std::string detail = R"({
"id":"PMCG1",
"name":"拡張パック",
"cards":[
{"localId":"001","name":"ヒトカゲ"},
{"localId":"002","name":"リザード"}
]
})";
const auto pack = JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
detail, set, catalog.value());
REQUIRE(pack.isOk());
REQUIRE(pack.value().cards.size() == 3);
CHECK(pack.value().cards[0].setNo == "001");
CHECK(pack.value().cards[0].name == "Charmander");
CHECK(pack.value().cards[1].setNo == "002");
CHECK(pack.value().cards[1].name == "リザード");
CHECK(pack.value().cards[2].setNo == "099");
CHECK(pack.value().cards[2].name == "Catalog Only");
}
TEST_CASE("catalogPackFromEnCatalog covers classic-only products") {
const auto catalog = JapanesePokemonEnCatalog::parse(R"({
"sets": {},
"prints": [
{"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu"},
{"set_id":"UnnumberedPromo","local_id":"002","name_en":"Mewtwo"}
]
})");
REQUIRE(catalog.isOk());
Set set;
set.id = "UnnumberedPromo";
set.name = "Unnumbered Promotional cards";
const auto pack =
JapanesePokemonSetSource::catalogPackFromEnCatalog(set, catalog.value());
REQUIRE(pack.cards.size() == 2);
CHECK(pack.cards[0].setNo == "001");
CHECK(pack.cards[1].setNo == "002");
}
}
+110 -5
View File
@@ -34,10 +34,13 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
CHECK(url.find("number") == std::string::npos);
}
TEST_CASE("setNo is appended as a number: clause") {
TEST_CASE("setId plus setNo omits name to avoid Lucene name-number misses") {
const auto url = PokemonCardPreviewSource::buildSearchUrl(
"Charizard", "base1", "4");
CHECK(url.find("number%3A4") != std::string::npos);
CHECK(url.find("set.id%3Abase1") != std::string::npos);
CHECK(url.find("name") == std::string::npos);
CHECK(url.find("Charizard") == std::string::npos);
}
TEST_CASE("setNo with a slash is normalized to the printed number") {
@@ -47,6 +50,7 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
"Charizard", "base1", "4/102");
CHECK(url.find("number%3A4") != std::string::npos);
CHECK(url.find("102") == std::string::npos);
CHECK(url.find("name") == std::string::npos);
}
TEST_CASE("name with spaces is percent-encoded") {
@@ -55,14 +59,34 @@ TEST_SUITE("PokemonCardPreviewSource::buildSearchUrl") {
CHECK(url.find("%22Mr.%20Mime%22") != std::string::npos);
}
TEST_CASE("empty setId omits the set.id clause") {
TEST_CASE("empty setId keeps name and appends number") {
const auto url =
PokemonCardPreviewSource::buildSearchUrl("Pikachu", "", "25");
CHECK(url.find("set.id") == std::string::npos);
CHECK(url.find("%22Pikachu%22") != std::string::npos);
CHECK(url.find("number%3A25") != std::string::npos);
}
}
TEST_SUITE("PokemonCardPreviewSource::buildCardByIdUrl") {
TEST_CASE("joins setId and normalized number with a hyphen") {
const auto url = PokemonCardPreviewSource::buildCardByIdUrl("base1", "4");
CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4");
}
TEST_CASE("percent-encodes alphanumeric collector numbers") {
const auto url =
PokemonCardPreviewSource::buildCardByIdUrl("swsh12tg", "TG14");
CHECK(url == "https://api.pokemontcg.io/v2/cards/swsh12tg-TG14");
}
TEST_CASE("strips slash form before building the id") {
const auto url =
PokemonCardPreviewSource::buildCardByIdUrl("base1", "4/102");
CHECK(url == "https://api.pokemontcg.io/v2/cards/base1-4");
}
}
TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
TEST_CASE("returns images.large when present") {
const std::string json = R"({
@@ -147,6 +171,50 @@ TEST_SUITE("PokemonCardPreviewSource::parseResponse") {
}
}
TEST_SUITE("PokemonCardPreviewSource::parseCardByIdResponse") {
TEST_CASE("returns images.large from data object") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({
"data": {
"id": "base1-4",
"images": {
"small": "https://images.pokemontcg.io/small.png",
"large": "https://images.pokemontcg.io/large.png"
}
}
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://images.pokemontcg.io/large.png");
}
TEST_CASE("falls back to images.small when large is absent") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(R"({
"data": {"images":{"small":"https://small.only/img.png"}}
})");
REQUIRE(out.isOk());
CHECK(out.value() == "https://small.only/img.png");
}
TEST_CASE("missing images is NotFound") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse(
R"({"data":{"id":"base1-4","name":"Charizard"}})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::NotFound);
}
TEST_CASE("data array shape is Transient") {
const auto out =
PokemonCardPreviewSource::parseCardByIdResponse(R"({"data":[]})");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("invalid JSON is Transient") {
const auto out = PokemonCardPreviewSource::parseCardByIdResponse("{not json");
REQUIRE(out.isErr());
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
}
TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
TEST_CASE("network error is surfaced as Transient") {
FixedHttpClient http;
@@ -157,17 +225,54 @@ TEST_SUITE("PokemonCardPreviewSource::fetchImageUrl") {
CHECK(out.error().kind == PreviewLookupError::Kind::Transient);
}
TEST_CASE("network success is parsed end-to-end and uses the encoded URL") {
TEST_CASE("with setNo prefers card-by-id endpoint") {
FixedHttpClient http;
http.ok = true;
http.body = R"({"data":{"images":{"large":"https://l/by-id.png"}}})";
PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Pikachu", "base1", "25");
REQUIRE(out.isOk());
CHECK(out.value() == "https://l/by-id.png");
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/cards/base1-25");
}
TEST_CASE("falls back to name-less search when card-by-id HTTP fails") {
class RoutingHttp final : public IHttpClient {
public:
int calls = 0;
std::string lastUrl;
Result<std::string> get(std::string_view url) override {
lastUrl = std::string(url);
++calls;
if (url.find("/v2/cards?") == std::string::npos) {
return Result<std::string>::err("HTTP 404 from card id");
}
return Result<std::string>::ok(
R"({"data":[{"images":{"large":"https://l/search.png"}}]})");
}
} http;
PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Charizard", "base1", "4");
REQUIRE(out.isOk());
CHECK(out.value() == "https://l/search.png");
CHECK(http.calls == 2);
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
CHECK(http.lastUrl.find("number%3A4") != std::string::npos);
CHECK(http.lastUrl.find("name") == std::string::npos);
}
TEST_CASE("empty setNo uses name search without card-by-id") {
FixedHttpClient http;
http.ok = true;
http.body = R"({"data":[{"images":{"large":"https://l/x.png"}}]})";
PokemonCardPreviewSource src{http};
const auto out = src.fetchImageUrl("Pikachu", "base1", "25");
const auto out = src.fetchImageUrl("Pikachu", "base1", "");
REQUIRE(out.isOk());
CHECK(out.value() == "https://l/x.png");
CHECK(http.lastUrl.find("%22Pikachu%22") != std::string::npos);
CHECK(http.lastUrl.find("set.id%3Abase1") != std::string::npos);
CHECK(http.lastUrl.find("number%3A25") != std::string::npos);
CHECK(http.lastUrl.find("/v2/cards/base1-") == std::string::npos);
}
}
+263
View File
@@ -0,0 +1,263 @@
#include <doctest/doctest.h>
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/PokemonSetCatalogService.hpp"
#include "ccm/services/PokemonSetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
PokemonCard makeOwned(PokemonRegion region, std::string setId, std::string setNo) {
PokemonCard c;
c.id = 1;
c.name = "Owned";
c.region = region;
c.set.id = std::move(setId);
c.set.name = "Set";
c.setNo = std::move(setNo);
c.language = Language::English;
return c;
}
PokemonSetCatalog westCatalog() {
PokemonSetCatalog catalog;
PokemonSetCatalogPack base;
base.setId = "base1";
base.setName = "Base";
base.cards = {
{"4", "Charizard"},
{"58", "Growlithe"},
{"59", "Arcanine"},
};
catalog.packs.push_back(std::move(base));
return catalog;
}
PokemonSetCatalog asiaCatalog() {
PokemonSetCatalog catalog;
PokemonSetCatalogPack pmcg1;
pmcg1.setId = "PMCG1";
pmcg1.setName = "Expansion Pack";
pmcg1.cards = {
{"001", "Charmander"},
{"002", "Charmeleon"},
{"006", "Charizard"},
};
catalog.packs.push_back(std::move(pmcg1));
return catalog;
}
} // namespace
TEST_SUITE("computePokemonSetCompletion") {
TEST_CASE("west pack with owned card appears") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "4"),
};
const auto rows = computePokemonSetCompletion(collection, west, asia);
REQUIRE(rows.size() == 1);
CHECK(rows[0].region == PokemonRegion::West);
CHECK(rows[0].setId == "base1");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("asia and west do not cross-count") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
// Same collector-looking number, different region/set.
PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4");
PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "006");
asiaCard.id = 2;
const auto rows = computePokemonSetCompletion({westCard, asiaCard}, west, asia);
REQUIRE(rows.size() == 2);
CHECK(rows[0].setId == "base1");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[1].setId == "PMCG1");
CHECK(rows[1].ownedUnique == 1);
}
TEST_CASE("region filter isolates catalogs") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4");
PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "001");
asiaCard.id = 2;
const auto westOnly = computePokemonSetCompletion(
{westCard, asiaCard}, west, asia, PokemonRegion::West);
REQUIRE(westOnly.size() == 1);
CHECK(westOnly[0].setId == "base1");
const auto asiaOnly = computePokemonSetCompletion(
{westCard, asiaCard}, west, asia, PokemonRegion::Asia);
REQUIRE(asiaOnly.size() == 1);
CHECK(asiaOnly[0].setId == "PMCG1");
}
TEST_CASE("normalizes west 4/102 to 4") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "4/102"),
};
const auto rows = computePokemonSetCompletion(collection, west, emptyAsia);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 1);
}
TEST_CASE("amount does not inflate unique ownership") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
PokemonCard a = makeOwned(PokemonRegion::West, "base1", "4");
a.amount = 5;
PokemonCard b = makeOwned(PokemonRegion::West, "base1", "4");
b.id = 2;
PokemonCard c = makeOwned(PokemonRegion::West, "base1", "58");
c.id = 3;
const auto rows = computePokemonSetCompletion({a, b, c}, west, emptyAsia);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
}
TEST_CASE("language filter hides packs with no matching language") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
PokemonCard en = makeOwned(PokemonRegion::West, "base1", "4");
en.language = Language::English;
CHECK(computePokemonSetCompletion({en}, west, emptyAsia, std::nullopt,
Language::German)
.empty());
REQUIRE(computePokemonSetCompletion({en}, west, emptyAsia, std::nullopt,
Language::English)
.size() == 1);
}
TEST_CASE("empty catalog yields no rows") {
PokemonSetCatalog empty;
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "4"),
};
CHECK(computePokemonSetCompletion(collection, empty, empty).empty());
}
}
TEST_SUITE("pokemonChecklistForSet") {
TEST_CASE("marks owned west cards") {
const auto west = westCatalog();
PokemonSetCatalog emptyAsia;
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::West, "base1", "58"),
};
const auto list = pokemonChecklistForSet(collection, west, emptyAsia,
PokemonRegion::West, "base1");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "4");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "58");
CHECK(list[1].owned == true);
CHECK(list[2].setNo == "59");
CHECK(list[2].owned == false);
}
TEST_CASE("asia card does not mark west checklist") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
std::vector<PokemonCard> collection{
makeOwned(PokemonRegion::Asia, "PMCG1", "006"),
};
const auto list = pokemonChecklistForSet(collection, west, asia,
PokemonRegion::West, "base1");
REQUIRE(list.size() == 3);
CHECK(list[0].owned == false);
CHECK(list[1].owned == false);
CHECK(list[2].owned == false);
}
}
TEST_SUITE("pokemonLanguagesInCollection") {
TEST_CASE("region filter scopes languages") {
PokemonCard westEn = makeOwned(PokemonRegion::West, "base1", "4");
westEn.language = Language::English;
PokemonCard asiaJp = makeOwned(PokemonRegion::Asia, "PMCG1", "001");
asiaJp.id = 2;
asiaJp.language = Language::Japanese;
const auto all = pokemonLanguagesInCollection({westEn, asiaJp});
REQUIRE(all.size() == 2);
CHECK(all[0] == Language::English);
CHECK(all[1] == Language::Japanese);
const auto westOnly =
pokemonLanguagesInCollection({westEn, asiaJp}, PokemonRegion::West);
REQUIRE(westOnly.size() == 1);
CHECK(westOnly[0] == Language::English);
}
}
TEST_SUITE("pokemonRegionsInCollection") {
TEST_CASE("reports regions with matching catalog packs") {
const auto west = westCatalog();
const auto asia = asiaCatalog();
PokemonCard westCard = makeOwned(PokemonRegion::West, "base1", "4");
PokemonCard asiaCard = makeOwned(PokemonRegion::Asia, "PMCG1", "001");
asiaCard.id = 2;
const auto regions =
pokemonRegionsInCollection({westCard, asiaCard}, west, asia);
REQUIRE(regions.size() == 2);
CHECK(regions[0] == PokemonRegion::West);
CHECK(regions[1] == PokemonRegion::Asia);
}
}
TEST_SUITE("PokemonSetCatalogService") {
TEST_CASE("save then load round-trips for west and asia paths") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
PokemonSetCatalogService store{fs, config, [](Game) { return "pokemon"; }};
CHECK_FALSE(store.exists(PokemonRegion::West));
CHECK_FALSE(store.exists(PokemonRegion::Asia));
CHECK(store.load(PokemonRegion::West).isErr());
const auto west = westCatalog();
const auto asia = asiaCatalog();
REQUIRE(store.save(PokemonRegion::West, west).isOk());
REQUIRE(store.save(PokemonRegion::Asia, asia).isOk());
CHECK(store.exists(PokemonRegion::West));
CHECK(store.exists(PokemonRegion::Asia));
const auto loadedWest = store.load(PokemonRegion::West);
REQUIRE(loadedWest.isOk());
CHECK(loadedWest.value() == west);
const auto loadedAsia = store.load(PokemonRegion::Asia);
REQUIRE(loadedAsia.isOk());
CHECK(loadedAsia.value() == asia);
CHECK(fs.exists("/data/pokemon/set-catalog-west.json"));
CHECK(fs.exists("/data/pokemon/set-catalog-asia.json"));
}
}
+52
View File
@@ -92,3 +92,55 @@ TEST_SUITE("PokemonSetSource::fetchAll") {
CHECK(http.lastUrl == "https://api.pokemontcg.io/v2/sets");
}
}
TEST_SUITE("PokemonSetSource::parseCatalog") {
TEST_CASE("groups cards by set.id and dedupes collector numbers") {
const std::vector<Set> sets{
Set{"base1", "Base", "1999/01/09"},
Set{"jungle", "Jungle", "1999/06/16"},
};
const std::string json = R"({
"data": [
{"name":"Charizard","number":"4","set":{"id":"base1","name":"Base"}},
{"name":"Charizard","number":"4/102","set":{"id":"base1","name":"Base"}},
{"name":"Growlithe","number":"58","set":{"id":"base1","name":"Base"}},
{"name":"Pikachu","number":"60","set":{"id":"jungle","name":"Jungle"}}
],
"page":1,"pageSize":250,"count":4,"totalCount":4
})";
const auto catalog = PokemonSetSource::parseCatalog(json, sets);
REQUIRE(catalog.isOk());
REQUIRE(catalog.value().packs.size() == 2);
const auto* base = catalog.value().findPack("base1");
REQUIRE(base != nullptr);
REQUIRE(base->cards.size() == 2);
CHECK(base->cards[0].setNo == "4");
CHECK(base->cards[1].setNo == "58");
const auto* jungle = catalog.value().findPack("jungle");
REQUIRE(jungle != nullptr);
REQUIRE(jungle->cards.size() == 1);
CHECK(jungle->cards[0].setNo == "60");
}
TEST_CASE("mergeCardsPage accumulates across pages") {
const std::vector<Set> sets{Set{"base1", "Base", "1999/01/09"}};
PokemonSetCatalog catalog;
const std::string page1 = R"({
"data":[{"name":"A","number":"1","set":{"id":"base1","name":"Base"}}],
"page":1,"pageSize":1,"count":1,"totalCount":2
})";
const std::string page2 = R"({
"data":[{"name":"B","number":"2","set":{"id":"base1","name":"Base"}}],
"page":2,"pageSize":1,"count":1,"totalCount":2
})";
REQUIRE(PokemonSetSource::mergeCardsPage(page1, catalog, sets).isOk());
REQUIRE(PokemonSetSource::mergeCardsPage(page2, catalog, sets).isOk());
REQUIRE(catalog.packs.size() == 1);
REQUIRE(catalog.packs[0].cards.size() == 2);
}
TEST_CASE("buildCardsPageUrl includes select and pagination") {
CHECK(PokemonSetSource::buildCardsPageUrl(2) ==
"https://api.pokemontcg.io/v2/cards?select=name,number,set&pageSize=250&page=2");
}
}
+250
View File
@@ -0,0 +1,250 @@
#include <doctest/doctest.h>
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/services/YuGiOhSetCompletion.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;
}
YuGiOhCard makeOwned(std::string setId, std::string setName, std::string setNo) {
YuGiOhCard c;
c.id = 1;
c.name = "Owned";
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
return c;
}
YuGiOhSetCatalog sampleCatalog() {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack lob;
lob.setId = "LOB";
lob.setName = "Legend of Blue Eyes White Dragon";
lob.cards = {
{"LOB-001", "Blue-Eyes White Dragon"},
{"LOB-EN005", "Dark Magician"},
{"LOB-007", "Gaia The Fierce Knight"},
};
YuGiOhSetCatalogPack mrd;
mrd.setId = "MRD";
mrd.setName = "Metal Raiders";
mrd.cards = {
{"MRD-001", "Summoned Skull"},
{"LOB-001", "Blue-Eyes White Dragon"},
};
catalog.packs.push_back(std::move(mrd));
catalog.packs.push_back(std::move(lob));
return catalog;
}
} // namespace
TEST_SUITE("computeYuGiOhSetCompletion") {
TEST_CASE("only packs with owned cards appear") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
const auto rows = computeYuGiOhSetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "LOB");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("printing slot match treats LOB-005 and LOB-EN005 as one slot") {
const auto catalog = sampleCatalog();
YuGiOhCard a = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
a.amount = 4;
YuGiOhCard b = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-EN005");
b.id = 2;
YuGiOhCard c = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
c.id = 3;
const auto rows = computeYuGiOhSetCompletion({a, b, c}, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 66);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
const auto rows = computeYuGiOhSetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "LOB");
}
TEST_CASE("empty catalog yields no rows") {
YuGiOhSetCatalog empty;
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
CHECK(computeYuGiOhSetCompletion(collection, empty).empty());
}
TEST_CASE("owned set missing from catalog is skipped") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack onlyMrd;
onlyMrd.setId = "MRD";
onlyMrd.setName = "Metal Raiders";
onlyMrd.cards = {{"MRD-001", "Summoned Skull"}};
catalog.packs.push_back(std::move(onlyMrd));
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
CHECK(computeYuGiOhSetCompletion(collection, catalog).empty());
}
TEST_CASE("language filter hides packs with no cards in that language") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
en.language = Language::English;
const auto allRows = computeYuGiOhSetCompletion({en}, catalog);
REQUIRE(allRows.size() == 1);
const auto deRows =
computeYuGiOhSetCompletion({en}, catalog, Language::German);
CHECK(deRows.empty());
const auto enRows =
computeYuGiOhSetCompletion({en}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
}
TEST_CASE("same slot in two languages counts once aggregated; filter is exclusive") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
en.language = Language::English;
YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
de.id = 2;
de.language = Language::German;
const auto allRows = computeYuGiOhSetCompletion({en, de}, catalog);
REQUIRE(allRows.size() == 1);
CHECK(allRows[0].ownedUnique == 1);
const auto enRows =
computeYuGiOhSetCompletion({en, de}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
YuGiOhCard deOnly = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
deOnly.id = 3;
deOnly.language = Language::German;
const auto deRows =
computeYuGiOhSetCompletion({en, de, deOnly}, catalog, Language::German);
REQUIRE(deRows.size() == 1);
CHECK(deRows[0].ownedUnique == 2);
}
}
TEST_SUITE("yuGiOhChecklistForSet") {
TEST_CASE("greys missing cards and marks owned ones") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"),
};
const auto list = yuGiOhChecklistForSet(collection, catalog, "LOB");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "LOB-001");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "LOB-007");
CHECK(list[1].owned == false);
CHECK(list[2].setNo == "LOB-EN005");
CHECK(list[2].owned == true);
}
TEST_CASE("unknown set returns empty") {
const auto catalog = sampleCatalog();
CHECK(yuGiOhChecklistForSet({}, catalog, "missing").empty());
}
TEST_CASE("owned flags respect language filter") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
en.language = Language::English;
const auto filtered =
yuGiOhChecklistForSet({en}, catalog, "LOB", Language::German);
REQUIRE(filtered.size() == 3);
CHECK(filtered[0].owned == false);
CHECK(filtered[1].owned == false);
CHECK(filtered[2].owned == false);
const auto english =
yuGiOhChecklistForSet({en}, catalog, "LOB", Language::English);
REQUIRE(english.size() == 3);
CHECK(english[2].owned == true);
}
}
TEST_SUITE("yuGiOhLanguagesInCollection") {
TEST_CASE("empty collection yields empty") {
CHECK(yuGiOhLanguagesInCollection({}).empty());
}
TEST_CASE("returns distinct languages in allLanguages order") {
YuGiOhCard jp = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
jp.language = Language::Japanese;
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
en.id = 2;
en.language = Language::English;
YuGiOhCard enDup = makeOwned("MRD", "Metal Raiders", "MRD-001");
enDup.id = 3;
enDup.language = Language::English;
YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-007");
de.id = 4;
de.language = Language::German;
const auto langs = yuGiOhLanguagesInCollection({jp, en, enDup, de});
REQUIRE(langs.size() == 3);
CHECK(langs[0] == Language::English);
CHECK(langs[1] == Language::German);
CHECK(langs[2] == Language::Japanese);
}
}
TEST_SUITE("YuGiOhSetCatalogService") {
TEST_CASE("save then load round-trips") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
YuGiOhSetCatalogService store{fs, config, [](Game) { return "yugioh"; }};
CHECK_FALSE(store.exists());
CHECK(store.load().isErr());
const auto catalog = sampleCatalog();
REQUIRE(store.save(catalog).isOk());
CHECK(store.exists());
const auto loaded = store.load();
REQUIRE(loaded.isOk());
CHECK(loaded.value() == catalog);
}
}
+113
View File
@@ -3,6 +3,8 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <vector>
using namespace ccm;
namespace {
@@ -172,3 +174,114 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") {
CHECK(out.error() == "offline");
}
}
TEST_SUITE("YuGiOhSetSource::parseCatalog") {
TEST_CASE("groups by set_name resolved to Set.id and dedupes printing slots") {
const std::vector<Set> sets{
Set{"LOB", "Legend of Blue Eyes White Dragon", "2002/03/08"},
Set{"MRD", "Metal Raiders", "2002/06/26"},
};
const std::string json = R"({
"data": [
{
"name": "Blue-Eyes White Dragon",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"},
{"set_name":"Metal Raiders","set_code":"MRD-010","set_rarity":"Ultra Rare"}
]
},
{
"name": "Dark Magician",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-E003","set_rarity":"Ultra Rare"}
]
}
]
})";
const auto out = YuGiOhSetSource::parseCatalog(json, sets);
REQUIRE(out.isOk());
const auto* lob = out.value().findPack("LOB");
REQUIRE(lob != nullptr);
REQUIRE(lob->cards.size() == 2);
bool sawBe = false;
bool sawDm = false;
for (const auto& c : lob->cards) {
if (c.name == "Blue-Eyes White Dragon" && c.setNo == "LOB-001") sawBe = true;
if (c.name == "Dark Magician" && c.setNo == "LOB-EN005") sawDm = true;
}
CHECK(sawBe);
CHECK(sawDm);
const auto* mrd = out.value().findPack("MRD");
REQUIRE(mrd != nullptr);
REQUIRE(mrd->cards.size() == 1);
CHECK(mrd->cards[0].setNo == "MRD-010");
}
TEST_CASE("missing data array returns error") {
CHECK(YuGiOhSetSource::parseCatalog(R"([])", {}).isErr());
CHECK(YuGiOhSetSource::parseCatalog(R"({"data":{}})", {}).isErr());
}
}
namespace {
class RoutingHttpClient final : public IHttpClient {
public:
std::string setsBody;
std::string infoBody;
bool setsOk = true;
bool infoOk = true;
std::vector<std::string> urls;
Result<std::string> get(std::string_view url) override {
urls.emplace_back(url);
if (url == YuGiOhSetSource::kEndpoint) {
return setsOk ? Result<std::string>::ok(setsBody)
: Result<std::string>::err("sets offline");
}
if (url == YuGiOhSetSource::kCardInfoEndpoint) {
return infoOk ? Result<std::string>::ok(infoBody)
: Result<std::string>::err("info offline");
}
return Result<std::string>::err("unexpected url");
}
};
} // namespace
TEST_SUITE("YuGiOhSetSource::fetchAllWithCatalog") {
TEST_CASE("fetches sets then cardinfo and returns both") {
RoutingHttpClient http;
http.setsBody = R"([{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}])";
http.infoBody = R"({
"data": [{
"name": "Blue-Eyes White Dragon",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"}
]
}]
})";
YuGiOhSetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
REQUIRE(http.urls.size() == 2);
CHECK(http.urls[0] == YuGiOhSetSource::kEndpoint);
CHECK(http.urls[1] == YuGiOhSetSource::kCardInfoEndpoint);
CHECK(out.value().sets.front().id == "LOB");
REQUIRE(out.value().catalog.findPack("LOB") != nullptr);
CHECK(out.value().catalog.findPack("LOB")->cards.size() == 1);
}
TEST_CASE("cardinfo failure propagates after sets succeed") {
RoutingHttpClient http;
http.setsBody = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])";
http.infoOk = false;
YuGiOhSetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isErr());
CHECK(out.error() == "info offline");
}
}
+6 -4
View File
@@ -5,15 +5,17 @@
## 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.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon, Yu-Gi-Oh!, and Pokemon own a tabbed layout without changing Magics splitter mounting.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic) or a games `IGameView::contentPanel` (Pokémon / Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)``Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` — and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI.
- `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`.
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50100 lines of hook overrides on top of the matching base template.
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`, `PokemonSetCompletionPanel`. Same Add/Edit shape as Magic for the card form; the game view hosts **Single Cards | Set Completion** via `contentPanel` / `hostsOwnLayout` (like Digimon/Yu-Gi-Oh!). Catalog from `PokemonSetCatalogService` (`set-catalog-west.json` / `set-catalog-asia.json`), filled on Update Pokemon. The Add/Edit/Delete + filter toolbar lives inside the Single Cards tab; MainFrame hides its shared toolbar while Pokemon is active.
- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`).
- `include/ccm/ui/YuGiOh*.hpp` + `src/YuGiOh*.cpp` — Yu-Gi-Oh!: list/selected/edit plus `YuGiOhGameView` notebook (**Single Cards** | **Set Completion**) via the same `hostsOwnLayout` / `contentPanel` pattern as Digimon, and `YuGiOhSetCompletionPanel`. Catalog from `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), filled on Update Sets from YGOPRODeck `cardinfo.php`.
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
@@ -27,7 +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 splitter swaps `listPanel()` / `selectedPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games.
6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a games `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimons, Yu-Gi-Oh!s, and Pokémons Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs.
7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link.
8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr<State>` holding `std::atomic<bool> alive`, `std::atomic<unsigned> currentGen`, and a back-pointer to the panel; spawn a detached `std::thread`, then deliver the result with `wxTheApp->CallAfter([state, gen, ...]() { if (!state->alive) return; if (state->currentGen != gen) return; ... })`. Flip `alive=false` in the panel destructor so late callbacks become no-ops.
9. **Icons come from `SvgIcons.hpp`.** Don't inline new SVG strings in panel sources; add them to `SvgIcons.{hpp,cpp}` so all panels stay in sync. Always pass a runtime fill color (`wxSystemSettings::GetColour(...).GetAsString(wxC2S_HTML_SYNTAX)`); never bake one into the SVG.
+3
View File
@@ -15,14 +15,17 @@ 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
src/YuGiOhGameView.cpp
src/YuGiOhSetCompletionPanel.cpp
src/DigiBattle99CardListPanel.cpp
src/DigiBattle99SelectedCardPanel.cpp
src/DigiBattle99CardEditDialog.cpp
src/DigiBattle99GameView.cpp
src/DigiBattle99SetCompletionPanel.cpp
src/SettingsDialog.cpp
src/SwitchCtrl.cpp
+35 -1
View File
@@ -5,18 +5,29 @@
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/IGameView.hpp"
#include <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 DigiBattle99CardListPanel;
class DigiBattle99SelectedCardPanel;
class DigiBattle99SetCompletionPanel;
class DigiBattle99GameView final : public IGameView {
public:
@@ -25,13 +36,19 @@ public:
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
IGameModule& module,
DigiBattle99SetCatalogService& catalogStore);
[[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; }
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
wxPanel* contentPanel(wxWindow* parent) override;
[[nodiscard]] wxPanel* contentPanelIfCreated() const noexcept override {
return contentPanel_;
}
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
void refreshCollection() override;
void onAddCard(wxWindow* parentWindow) override;
@@ -47,6 +64,12 @@ public:
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
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<DigiBattle99Card>& collection_;
@@ -54,9 +77,20 @@ private:
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
DigiBattle99SetCatalogService& catalogStore_;
wxPanel* contentPanel_{nullptr};
wxPanel* tabBar_{nullptr};
wxSimplebook* book_{nullptr};
wxSplitterWindow* singleSplitter_{nullptr};
DigiBattle99CardListPanel* listPanel_{nullptr};
DigiBattle99SelectedCardPanel* selectedPanel_{nullptr};
DigiBattle99SetCompletionPanel* 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> setsCache_;
bool attemptedInitialSetLoad_{false};
};
@@ -0,0 +1,71 @@
#pragma once
// DigiBattle99SetCompletionPanel: Set Completion tab — pack tiles with
// progress bars for sets the user owns ≥1 card of, plus an in-tab checklist
// drill-down (unowned rows greyed). Catalog is offline (set-catalog.json).
// Optional language filter restricts ownership to one language and labels
// set titles as "{setName} ({language})".
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.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 DigiBattle99SetCompletionPanel : public wxPanel {
public:
DigiBattle99SetCompletionPanel(wxWindow* parent, DigiBattle99SetCatalogService& catalogStore);
void setCollection(std::vector<DigiBattle99Card> cards);
void reloadFromStore();
void applyTheme(const ThemePalette& palette);
private:
void showGridPage();
void showChecklistPage(const std::string& setId, const std::string& setName);
void rebuildGrid();
void rebuildChecklist(const std::string& setId);
void setEmptyMessage(const wxString& message);
void clearGridTiles();
void refreshLanguageChoice();
void onLanguageChoice(wxCommandEvent& event);
void rebuildCurrentView();
[[nodiscard]] std::string displaySetName(const std::string& setName) const;
DigiBattle99SetCatalogService& catalogStore_;
DigiBattle99SetCatalog catalog_;
bool catalogLoaded_{false};
std::vector<DigiBattle99Card> collection_;
ThemePalette palette_{};
std::optional<Language> languageFilter_;
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};
std::string detailSetId_;
std::string detailSetName_;
};
} // namespace ccm::ui
+19
View File
@@ -35,6 +35,25 @@ public:
virtual wxPanel* listPanel(wxWindow* parent) = 0;
virtual wxPanel* selectedPanel(wxWindow* parent) = 0;
// When non-null, MainFrame mounts this as the sole content under the
// toolbar instead of the shared selected|list splitter. Digimon, 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;
}
// Non-constructing accessor so MainFrame can hide a previously mounted
// content panel without forcing lazy creation for inactive games.
[[nodiscard]] virtual wxPanel* contentPanelIfCreated() const noexcept {
return nullptr;
}
// Games that own their layout via contentPanel must not have their
// list/selected panels parented onto MainFrame's shared splitter.
[[nodiscard]] virtual bool hostsOwnLayout() const noexcept { return false; }
// Reload the active collection from disk and refresh the panels. The
// selected card is preserved when possible.
virtual void refreshCollection() = 0;
+2
View File
@@ -59,6 +59,8 @@ private:
Game activeGame_{Game::Magic};
wxSplitterWindow* splitter_{nullptr};
wxPanel* contentHost_{nullptr};
wxPanel* toolbarPanel_{nullptr};
wxTextCtrl* filterInput_{nullptr};
wxPanel* menuStrip_{nullptr};
wxStaticText* statusText_{nullptr};
+45 -8
View File
@@ -1,7 +1,8 @@
#pragma once
// PokemonGameView: unified West + Asia Pokemon UI. One collection file;
// separate West/Asia set caches; Sets > Update Pokemon refreshes both.
// separate West/Asia set caches and set-completion catalogs. Sets > Update
// Pokemon refreshes both regions. Hosts Single Cards | Set Completion tabs.
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/IGameModule.hpp"
@@ -9,17 +10,28 @@
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/PokemonSetCatalogService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/IGameView.hpp"
#include <array>
#include <string>
#include <string_view>
#include <vector>
class wxBitmapButton;
class wxBoxSizer;
class wxPanel;
class wxSimplebook;
class wxSplitterWindow;
class wxStaticText;
class wxTextCtrl;
namespace ccm::ui {
class PokemonCardListPanel;
class PokemonSelectedCardPanel;
class PokemonSetCompletionPanel;
class PokemonGameView final : public IGameView {
public:
@@ -28,13 +40,20 @@ public:
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
IGameModule& westModule,
IGameModule& asiaModule,
PokemonSetCatalogService& catalogStore);
[[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; }
[[nodiscard]] std::string displayName() const override { return "Pokemon"; }
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
wxPanel* contentPanel(wxWindow* parent) override;
[[nodiscard]] wxPanel* contentPanelIfCreated() const noexcept override {
return contentPanel_;
}
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
void refreshCollection() override;
void onAddCard(wxWindow* parentWindow) override;
@@ -48,19 +67,37 @@ public:
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog(PokemonRegion region);
void ensureSingleCardsMounted(wxWindow* splitterParent);
void buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer);
void buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer);
void selectTab(int index);
void refreshToolbarIcons(const ThemePalette& palette);
void refreshTabBarTheme(const ThemePalette& palette);
ConfigService& config_;
CollectionService<PokemonCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
IGameModule& westModule_;
IGameModule& asiaModule_;
PokemonSetCatalogService& catalogStore_;
PokemonCardListPanel* listPanel_{nullptr};
PokemonSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCacheWest_;
std::vector<Set> setsCacheAsia_;
bool attemptedInitialSetLoad_{false};
wxPanel* contentPanel_{nullptr};
wxPanel* tabBar_{nullptr};
wxSimplebook* book_{nullptr};
wxSplitterWindow* singleSplitter_{nullptr};
PokemonCardListPanel* listPanel_{nullptr};
PokemonSelectedCardPanel* selectedPanel_{nullptr};
PokemonSetCompletionPanel* setCompletionPanel_{nullptr};
std::array<wxPanel*, 2> tabPanels_{{nullptr, nullptr}};
std::array<wxStaticText*, 2> tabLabels_{{nullptr, nullptr}};
int activeTab_{0};
std::array<wxBitmapButton*, 3> toolbarButtons_{{nullptr, nullptr, nullptr}};
wxTextCtrl* filterInput_{nullptr};
std::vector<Set> setsCacheWest_;
std::vector<Set> setsCacheAsia_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,81 @@
#pragma once
// PokemonSetCompletionPanel: Set Completion tab — pack tiles with progress
// bars for sets the user owns ≥1 card of, plus an in-tab checklist drill-down
// (unowned rows greyed). Dual offline catalogs (West + Asia). Optional region
// and language filters restrict ownership; set titles may be annotated with
// region and/or language.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/services/PokemonSetCatalogService.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/panel.h>
#include <optional>
#include <string>
#include <vector>
class wxBoxSizer;
class wxChoice;
class wxListCtrl;
class wxScrolledWindow;
class wxSimplebook;
class wxStaticText;
namespace ccm::ui {
class PokemonSetCompletionPanel : public wxPanel {
public:
PokemonSetCompletionPanel(wxWindow* parent, PokemonSetCatalogService& catalogStore);
void setCollection(std::vector<PokemonCard> cards);
void reloadFromStore();
void applyTheme(const ThemePalette& palette);
private:
void showGridPage();
void showChecklistPage(PokemonRegion region, const std::string& setId,
const std::string& setName);
void rebuildGrid();
void rebuildChecklist(PokemonRegion region, const std::string& setId);
void setEmptyMessage(const wxString& message);
void clearGridTiles();
void refreshRegionChoice();
void refreshLanguageChoice();
void onRegionChoice(wxCommandEvent& event);
void onLanguageChoice(wxCommandEvent& event);
void rebuildCurrentView();
[[nodiscard]] std::string displaySetName(const std::string& setName,
PokemonRegion region) const;
[[nodiscard]] bool catalogsReadyForFilter() const;
PokemonSetCatalogService& catalogStore_;
PokemonSetCatalog westCatalog_;
PokemonSetCatalog asiaCatalog_;
bool westCatalogLoaded_{false};
bool asiaCatalogLoaded_{false};
std::vector<PokemonCard> collection_;
ThemePalette palette_{};
std::optional<PokemonRegion> regionFilter_;
std::optional<Language> languageFilter_;
wxChoice* regionChoice_{nullptr};
wxChoice* languageChoice_{nullptr};
wxSimplebook* book_{nullptr};
wxPanel* gridPage_{nullptr};
wxScrolledWindow* scroll_{nullptr};
wxBoxSizer* gridSizer_{nullptr};
wxStaticText* emptyLabel_{nullptr};
wxPanel* detailPage_{nullptr};
wxStaticText* detailTitle_{nullptr};
wxListCtrl* checklist_{nullptr};
PokemonRegion detailRegion_{PokemonRegion::West};
std::string detailSetId_;
std::string detailSetName_;
};
} // namespace ccm::ui
+44 -10
View File
@@ -7,31 +7,48 @@
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.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 YuGiOhCardListPanel;
class YuGiOhSelectedCardPanel;
class YuGiOhSetCompletionPanel;
class YuGiOhGameView final : public IGameView {
public:
YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module,
YuGiOhSetCatalogService& catalogStore);
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; }
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;
@@ -45,6 +62,12 @@ public:
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
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<YuGiOhCard>& collection_;
@@ -52,11 +75,22 @@ private:
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
YuGiOhSetCatalogService& catalogStore_;
YuGiOhCardListPanel* listPanel_{nullptr};
YuGiOhSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
wxPanel* contentPanel_{nullptr};
wxPanel* tabBar_{nullptr};
wxSimplebook* book_{nullptr};
wxSplitterWindow* singleSplitter_{nullptr};
YuGiOhCardListPanel* listPanel_{nullptr};
YuGiOhSelectedCardPanel* selectedPanel_{nullptr};
YuGiOhSetCompletionPanel* 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> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,71 @@
#pragma once
// YuGiOhSetCompletionPanel: Set Completion tab — pack tiles with progress
// bars for sets the user owns ≥1 card of, plus an in-tab checklist drill-down
// (unowned rows greyed). Catalog is offline (set-catalog.json). Optional
// language filter restricts ownership to one language and labels set titles
// as "{setName} ({language})".
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/services/YuGiOhSetCatalogService.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 YuGiOhSetCompletionPanel : public wxPanel {
public:
YuGiOhSetCompletionPanel(wxWindow* parent, YuGiOhSetCatalogService& catalogStore);
void setCollection(std::vector<YuGiOhCard> cards);
void reloadFromStore();
void applyTheme(const ThemePalette& palette);
private:
void showGridPage();
void showChecklistPage(const std::string& setId, const std::string& setName);
void rebuildGrid();
void rebuildChecklist(const std::string& setId);
void setEmptyMessage(const wxString& message);
void clearGridTiles();
void refreshLanguageChoice();
void onLanguageChoice(wxCommandEvent& event);
void rebuildCurrentView();
[[nodiscard]] std::string displaySetName(const std::string& setName) const;
YuGiOhSetCatalogService& catalogStore_;
YuGiOhSetCatalog catalog_;
bool catalogLoaded_{false};
std::vector<YuGiOhCard> collection_;
ThemePalette palette_{};
std::optional<Language> languageFilter_;
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};
std::string detailSetId_;
std::string detailSetName_;
};
} // namespace ccm::ui
+315 -13
View File
@@ -1,31 +1,63 @@
#include "ccm/ui/DigiBattle99GameView.hpp"
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/DigiBattle99CardEditDialog.hpp"
#include "ccm/ui/DigiBattle99CardListPanel.hpp"
#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp"
#include "ccm/ui/DigiBattle99SetCompletionPanel.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include <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 kDigiToolbarIconPx = 18;
constexpr const char kDigiFilterHint[] = "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
DigiBattle99GameView::DigiBattle99GameView(ConfigService& config,
CollectionService<DigiBattle99Card>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module)
IGameModule& module,
DigiBattle99SetCatalogService& catalogStore)
: config_(config),
collection_(collection),
sets_(sets),
images_(images),
cardPreview_(cardPreview),
module_(module) {}
module_(module),
catalogStore_(catalogStore) {}
void DigiBattle99GameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
@@ -45,6 +77,214 @@ void DigiBattle99GameView::ensureSetsLoaded() {
}
}
void DigiBattle99GameView::ensureSingleCardsMounted(wxWindow* splitterParent) {
if (singleSplitter_ == nullptr) {
singleSplitter_ = new wxSplitterWindow(splitterParent, wxID_ANY, wxDefaultPosition,
wxDefaultSize, wxSP_LIVE_UPDATE);
singleSplitter_->SetMinimumPaneSize(280);
}
auto* list = listPanel(singleSplitter_);
auto* selected = selectedPanel(singleSplitter_);
if (!singleSplitter_->IsSplit()) {
singleSplitter_->SplitVertically(selected, list, 360);
}
}
void DigiBattle99GameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) {
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
auto makeToolBtn = [&](const char* svg, const wxString& tip) {
wxBitmap bmp = svgIconBitmap(svg, kDigiToolbarIconPx, "#000000");
auto* b = new wxBitmapButton(parent, wxID_ANY, bmp, wxDefaultPosition, wxDefaultSize,
wxBU_EXACTFIT);
b->SetToolTip(tip);
return b;
};
toolbarButtons_[0] = makeToolBtn(kSvgToolbarAdd, "Add Card");
toolbarButtons_[1] = makeToolBtn(kSvgToolbarEdit, "Edit");
toolbarButtons_[2] = makeToolBtn(kSvgToolbarDelete, "Delete");
toolbar->AddSpacer(4);
toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->AddStretchSpacer(1);
filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1));
filterInput_->SetHint(kDigiFilterHint);
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
pageSizer->Add(toolbar, 0, wxEXPAND);
toolbarButtons_[0]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onAddCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[1]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onEditCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[2]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onDeleteCard(owner != nullptr ? owner : contentPanel_);
});
filterInput_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) {
if (filterInput_ == nullptr) return;
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
});
}
void DigiBattle99GameView::refreshToolbarIcons(const ThemePalette& palette) {
const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
if (toolbarButtons_[0]) {
toolbarButtons_[0]->SetBitmap(
svgIconBitmap(kSvgToolbarAdd, kDigiToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[1]) {
toolbarButtons_[1]->SetBitmap(
svgIconBitmap(kSvgToolbarEdit, kDigiToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[2]) {
toolbarButtons_[2]->SetBitmap(
svgIconBitmap(kSvgToolbarDelete, kDigiToolbarIconPx, tbHex.c_str()));
}
}
void DigiBattle99GameView::selectTab(int index) {
if (index < 0 || index > 1 || book_ == nullptr) return;
activeTab_ = index;
book_->SetSelection(index);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
}
void DigiBattle99GameView::refreshTabBarTheme(const ThemePalette& palette) {
if (tabBar_ == nullptr) return;
const wxColour barBg = palette.panelBg;
// Match toolbar button plate (Add/Edit/Delete), not a darker inset fill.
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);
// Keep the label plate identical to the tab fill so a late theme pass
// cannot leave a darker box around the caption.
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 DigiBattle99GameView::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);
// Compact padding so the strip stays short; frame is drawn in paint.
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_);
// Same plate as toolbar bitmap buttons.
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* DigiBattle99GameView::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 DigiBattle99SetCompletionPanel(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));
// First mount: re-assert tab plate colors after the initial layout paint.
contentPanel_->CallAfter([this]() {
refreshTabBarTheme(paletteForTheme(config_.current().theme));
});
}
return contentPanel_;
}
wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) {
listPanel_ = new DigiBattle99CardListPanel(parent);
@@ -69,7 +309,10 @@ wxPanel* DigiBattle99GameView::selectedPanel(wxWindow* parent) {
}
void DigiBattle99GameView::refreshCollection() {
if (listPanel_ == nullptr) return;
// Ensure the Digimon host (and list panel) exist even when MainFrame mounts
// via contentPanel before an explicit listPanel call.
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::DigiBattle99);
if (!loaded) {
showThemedMessageDialog(
@@ -78,9 +321,15 @@ void DigiBattle99GameView::refreshCollection() {
"Error", wxOK | wxICON_ERROR);
return;
}
listPanel_->setCards(std::move(loaded).value());
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
auto cards = std::move(loaded).value();
if (listPanel_ != nullptr) {
listPanel_->setCards(cards);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->setCollection(std::move(cards));
}
}
const std::vector<Set>& DigiBattle99GameView::setsForDialog() {
@@ -190,27 +439,80 @@ void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) {
}
std::string DigiBattle99GameView::onUpdateSets(wxWindow* parentWindow) {
auto out = sets_.updateSets(Game::DigiBattle99);
if (!out) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
auto* digiSrc = dynamic_cast<DigiBattle99SetSource*>(&module_.setSource());
if (digiSrc == nullptr) {
showThemedMessageDialog(parentWindow, "Digimon Digi-Battle set source unavailable.",
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
setsCache_ = out.value();
auto both = digiSrc->fetchAllWithCatalog();
if (!both) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
auto savedSets = sets_.saveSets(Game::DigiBattle99, both.value().sets);
if (!savedSets) {
showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
auto savedCatalog = catalogStore_.save(both.value().catalog);
if (!savedCatalog) {
showThemedMessageDialog(parentWindow,
"Sets saved, but set catalog failed: " + savedCatalog.error(),
"Warning", wxOK | wxICON_WARNING);
}
setsCache_ = both.value().sets;
if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->reloadFromStore();
if (auto loaded = collection_.list(Game::DigiBattle99)) {
setCompletionPanel_->setCollection(std::move(loaded).value());
}
}
const std::size_t setCount = both.value().sets.size();
const std::size_t packCount = both.value().catalog.packs.size();
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(out.value().size()) + " Digimon (Digi-Battle) sets.",
"Updated " + std::to_string(setCount) + " Digimon (Digi-Battle) sets and " +
std::to_string(packCount) + " set checklists.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Digimon (Digi-Battle) sets updated.";
}
void DigiBattle99GameView::setFilter(std::string_view filter) {
if (filterInput_ != nullptr) {
const wxString wanted = wxString::FromUTF8(std::string(filter).c_str());
if (filterInput_->GetValue() != wanted) {
filterInput_->ChangeValue(wanted);
if (filter.empty()) {
filterInput_->SetHint(kDigiFilterHint);
filterInput_->Refresh();
}
}
}
if (listPanel_) listPanel_->setFilter(filter);
}
void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette);
refreshToolbarIcons(palette);
refreshTabBarTheme(palette);
if (filterInput_ != nullptr) {
filterInput_->SetBackgroundColour(palette.inputBg);
filterInput_->SetForegroundColour(palette.inputText);
filterInput_->SetOwnBackgroundColour(palette.inputBg);
filterInput_->SetOwnForegroundColour(palette.inputText);
filterInput_->Refresh();
}
}
} // namespace ccm::ui
@@ -0,0 +1,313 @@
#include "ccm/ui/DigiBattle99SetCompletionPanel.hpp"
#include "ccm/services/DigiBattle99SetCompletion.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) {
// Blend text toward panel background so missing checklist rows read as greyed.
const auto blend = [](unsigned char a, unsigned char b) -> unsigned char {
return static_cast<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
DigiBattle99SetCompletionPanel::DigiBattle99SetCompletionPanel(
wxWindow* parent, DigiBattle99SetCatalogService& catalogStore)
: wxPanel(parent), catalogStore_(catalogStore) {
palette_ = paletteForTheme(inferThemeFromWindow(this));
auto* langRow = new wxBoxSizer(wxHORIZONTAL);
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, &DigiBattle99SetCompletionPanel::onLanguageChoice,
this);
langRow->Add(langLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
langRow->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(langRow, 0, wxEXPAND | wxALL, 8);
root->Add(book_, 1, wxEXPAND);
SetSizer(root);
showGridPage();
}
void DigiBattle99SetCompletionPanel::setCollection(std::vector<DigiBattle99Card> cards) {
collection_ = std::move(cards);
refreshLanguageChoice();
rebuildCurrentView();
}
void DigiBattle99SetCompletionPanel::reloadFromStore() {
catalogLoaded_ = false;
catalog_ = {};
if (catalogStore_.exists()) {
if (auto loaded = catalogStore_.load()) {
catalog_ = std::move(loaded).value();
catalogLoaded_ = true;
}
}
showGridPage();
rebuildGrid();
}
void DigiBattle99SetCompletionPanel::applyTheme(const ThemePalette& palette) {
palette_ = palette;
applyThemeToWindowTree(this, palette, inferThemeFromWindow(this));
rebuildCurrentView();
}
void DigiBattle99SetCompletionPanel::showGridPage() {
detailSetId_.clear();
detailSetName_.clear();
book_->SetSelection(0);
}
void DigiBattle99SetCompletionPanel::showChecklistPage(const std::string& setId,
const std::string& setName) {
detailSetId_ = setId;
detailSetName_ = setName;
detailTitle_->SetLabelText(wxString::FromUTF8(displaySetName(setName).c_str()));
rebuildChecklist(setId);
book_->SetSelection(1);
}
std::string DigiBattle99SetCompletionPanel::displaySetName(const std::string& setName) const {
if (!languageFilter_.has_value()) return setName;
return setName + " (" + std::string(to_string(*languageFilter_)) + ")";
}
void DigiBattle99SetCompletionPanel::refreshLanguageChoice() {
const auto previous = languageFilter_;
const auto present = digiBattle99LanguagesInCollection(collection_);
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 DigiBattle99SetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) {
const int sel = languageChoice_->GetSelection();
if (sel <= 0) {
languageFilter_ = std::nullopt;
} else {
const auto present = digiBattle99LanguagesInCollection(collection_);
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 DigiBattle99SetCompletionPanel::rebuildCurrentView() {
if (book_->GetSelection() == 1 && !detailSetId_.empty()) {
const auto rows =
computeDigiBattle99SetCompletion(collection_, catalog_, languageFilter_);
bool stillVisible = false;
for (const auto& row : rows) {
if (row.setId == detailSetId_) {
stillVisible = true;
break;
}
}
if (!stillVisible) {
showGridPage();
rebuildGrid();
return;
}
detailTitle_->SetLabelText(
wxString::FromUTF8(displaySetName(detailSetName_).c_str()));
rebuildChecklist(detailSetId_);
} else {
rebuildGrid();
}
}
void DigiBattle99SetCompletionPanel::setEmptyMessage(const wxString& message) {
clearGridTiles();
emptyLabel_->SetLabelText(message);
emptyLabel_->Wrap(480);
emptyLabel_->Show();
scroll_->Hide();
gridPage_->Layout();
}
void DigiBattle99SetCompletionPanel::clearGridTiles() {
if (gridSizer_ == nullptr) return;
gridSizer_->Clear(true);
}
void DigiBattle99SetCompletionPanel::rebuildGrid() {
if (!catalogLoaded_) {
setEmptyMessage(wxString::FromUTF8(
"Set checklists are not downloaded yet.\n"
"Run Sets → Update Digimon (Digi-Battle) to enable Set Completion."));
return;
}
const auto rows =
computeDigiBattle99SetCompletion(collection_, catalog_, languageFilter_);
if (rows.empty()) {
setEmptyMessage(wxString::FromUTF8(
"No Digimon (Digi-Battle) sets in progress yet.\n"
"Add cards on the Single Cards tab to track set completion here."));
return;
}
emptyLabel_->Hide();
scroll_->Show();
clearGridTiles();
for (const auto& row : rows) {
auto* tile = new wxPanel(scroll_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxBORDER_SIMPLE);
tile->SetBackgroundColour(palette_.panelBg);
auto* tileSizer = new wxBoxSizer(wxVERTICAL);
const std::string title = displaySetName(row.setName);
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 std::string setId = row.setId;
const std::string setName = row.setName;
auto openDetail = [this, setId, setName](wxMouseEvent&) {
showChecklistPage(setId, setName);
};
tile->Bind(wxEVT_LEFT_UP, openDetail);
nameLbl->Bind(wxEVT_LEFT_UP, openDetail);
countLbl->Bind(wxEVT_LEFT_UP, openDetail);
gauge->Bind(wxEVT_LEFT_UP, openDetail);
tile->SetCursor(wxCursor(wxCURSOR_HAND));
nameLbl->SetCursor(wxCursor(wxCURSOR_HAND));
countLbl->SetCursor(wxCursor(wxCURSOR_HAND));
gridSizer_->Add(tile, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
}
gridSizer_->AddStretchSpacer(1);
scroll_->FitInside();
gridPage_->Layout();
Layout();
}
void DigiBattle99SetCompletionPanel::rebuildChecklist(const std::string& setId) {
checklist_->DeleteAllItems();
const auto entries =
digiBattle99ChecklistForSet(collection_, catalog_, setId, languageFilter_);
const wxColour muted = mutedTextColour(palette_);
// Fixed green so owned checkmarks stay readable in both light and dark themes.
const wxColour ownedGreen(46, 160, 67);
long idx = 0;
for (const auto& entry : entries) {
// Align names: checkmark + two spaces vs four spaces for missing cards.
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
+53 -7
View File
@@ -133,10 +133,11 @@ void MainFrame::buildLayout() {
menuStrip_->SetSizer(menuSizer);
root->Add(menuStrip_, 0, wxEXPAND);
toolbarPanel_ = new wxPanel(this, wxID_ANY);
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
auto makeToolBtn = [&](int id, const char* svg, const wxString& tip) {
wxBitmap bmp = svgIconBitmap(svg, kToolbarIconPx, "#000000");
auto* b = new wxBitmapButton(this, id, bmp, wxDefaultPosition,
auto* b = new wxBitmapButton(toolbarPanel_, id, bmp, wxDefaultPosition,
wxDefaultSize,
wxBU_EXACTFIT);
b->SetToolTip(tip);
@@ -150,16 +151,21 @@ void MainFrame::buildLayout() {
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->AddStretchSpacer(1);
filterInput_ = new wxTextCtrl(this, wxID_ANY, "", wxDefaultPosition,
filterInput_ = new wxTextCtrl(toolbarPanel_, wxID_ANY, "", wxDefaultPosition,
wxSize(260, -1));
filterInput_->SetHint(kFilterInputHint);
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
root->Add(toolbar, 0, wxEXPAND);
toolbarPanel_->SetSizer(toolbar);
root->Add(toolbarPanel_, 0, wxEXPAND);
splitter_ = new wxSplitterWindow(this, wxID_ANY, wxDefaultPosition,
contentHost_ = new wxPanel(this, wxID_ANY);
auto* hostSizer = new wxBoxSizer(wxVERTICAL);
splitter_ = new wxSplitterWindow(contentHost_, wxID_ANY, wxDefaultPosition,
wxDefaultSize, wxSP_LIVE_UPDATE);
splitter_->SetMinimumPaneSize(280);
root->Add(splitter_, 1, wxEXPAND);
hostSizer->Add(splitter_, 1, wxEXPAND);
contentHost_->SetSizer(hostSizer);
root->Add(contentHost_, 1, wxEXPAND);
auto* statusPanel = new wxPanel(this, wxID_ANY);
auto* statusSizer = new wxBoxSizer(wxHORIZONTAL);
@@ -199,15 +205,55 @@ IGameView* MainFrame::activeView() {
void MainFrame::mountActiveView() {
auto* view = activeView();
if (view == nullptr || splitter_ == nullptr) return;
if (view == nullptr || splitter_ == nullptr || contentHost_ == nullptr) return;
auto* hostSizer = contentHost_->GetSizer();
if (hostSizer == nullptr) return;
// Hide every other view's panels so wx doesn't double-paint them.
for (auto* other : ctx_.gameViews) {
if (other == nullptr || other == view) continue;
if (other->hostsOwnLayout()) {
if (auto* cp = other->contentPanelIfCreated()) cp->Hide();
continue;
}
if (auto* lp = other->listPanel(splitter_)) lp->Hide();
if (auto* sp = other->selectedPanel(splitter_)) sp->Hide();
}
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
if (toolbarPanel_ != nullptr) {
if (view->hostsOwnLayout()) toolbarPanel_->Hide();
else toolbarPanel_->Show();
Layout();
}
if (view->hostsOwnLayout()) {
auto* custom = view->contentPanel(contentHost_);
if (custom == nullptr) return;
splitter_->Hide();
hostSizer->Clear(false);
custom->Show();
hostSizer->Add(custom, 1, wxEXPAND);
contentHost_->Layout();
// Digimon (and other hostsOwnLayout views) apply their own tree theme
// and then restore tab-strip colors; a follow-up applyThemeToWindowTree
// here would reset tab labels to panelBg and leave a dark box around text.
view->applyTheme(palette);
return;
}
if (auto* previousCustom = view->contentPanelIfCreated()) {
previousCustom->Hide();
}
// Re-seat the shared splitter if a contentPanel game was showing.
hostSizer->Clear(false);
splitter_->Show();
hostSizer->Add(splitter_, 1, wxEXPAND);
auto* listPanel = view->listPanel(splitter_);
auto* selectedPanel = view->selectedPanel(splitter_);
if (listPanel == nullptr || selectedPanel == nullptr) return;
@@ -221,7 +267,7 @@ void MainFrame::mountActiveView() {
splitter_->SplitVertically(selectedPanel, listPanel, 360);
}
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
contentHost_->Layout();
view->applyTheme(palette);
applyThemeToWindowTree(selectedPanel, palette, ctx_.config.current().theme);
applyThemeToWindowTree(listPanel, palette, ctx_.config.current().theme);
+342 -27
View File
@@ -1,31 +1,66 @@
#include "ccm/ui/PokemonGameView.hpp"
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/PokemonCardEditDialog.hpp"
#include "ccm/ui/PokemonCardListPanel.hpp"
#include "ccm/ui/PokemonSelectedCardPanel.hpp"
#include "ccm/ui/PokemonSetCompletionPanel.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h>
#include <wx/bmpbuttn.h>
#include <wx/dcclient.h>
#include <wx/panel.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/splitter.h>
#include <wx/stattext.h>
#include <wx/textctrl.h>
#include <wx/window.h>
#include <optional>
#include <string>
namespace ccm::ui {
namespace {
constexpr int kPokeToolbarIconPx = 18;
constexpr const char kPokeFilterHint[] = "Filter";
wxColour lighten(const wxColour& c, int amount) {
auto lift = [amount](unsigned char channel) -> unsigned char {
const int raised = static_cast<int>(channel) + amount;
return static_cast<unsigned char>(raised > 255 ? 255 : raised);
};
return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue()));
}
wxColour darken(const wxColour& c, int amount) {
auto drop = [amount](unsigned char channel) -> unsigned char {
const int lowered = static_cast<int>(channel) - amount;
return static_cast<unsigned char>(lowered < 0 ? 0 : lowered);
};
return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue()));
}
} // namespace
PokemonGameView::PokemonGameView(ConfigService& config,
CollectionService<PokemonCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module)
IGameModule& westModule,
IGameModule& asiaModule,
PokemonSetCatalogService& catalogStore)
: config_(config),
collection_(collection),
sets_(sets),
images_(images),
cardPreview_(cardPreview),
module_(module) {}
westModule_(westModule),
asiaModule_(asiaModule),
catalogStore_(catalogStore) {}
void PokemonGameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
@@ -49,6 +84,208 @@ void PokemonGameView::ensureSetsLoaded() {
loadOrRefresh(Game::JapanesePokemon, setsCacheAsia_);
}
void PokemonGameView::ensureSingleCardsMounted(wxWindow* splitterParent) {
if (singleSplitter_ == nullptr) {
singleSplitter_ = new wxSplitterWindow(splitterParent, wxID_ANY, wxDefaultPosition,
wxDefaultSize, wxSP_LIVE_UPDATE);
singleSplitter_->SetMinimumPaneSize(280);
}
auto* list = listPanel(singleSplitter_);
auto* selected = selectedPanel(singleSplitter_);
if (!singleSplitter_->IsSplit()) {
singleSplitter_->SplitVertically(selected, list, 360);
}
}
void PokemonGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) {
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
auto makeToolBtn = [&](const char* svg, const wxString& tip) {
wxBitmap bmp = svgIconBitmap(svg, kPokeToolbarIconPx, "#000000");
auto* b = new wxBitmapButton(parent, wxID_ANY, bmp, wxDefaultPosition, wxDefaultSize,
wxBU_EXACTFIT);
b->SetToolTip(tip);
return b;
};
toolbarButtons_[0] = makeToolBtn(kSvgToolbarAdd, "Add Card");
toolbarButtons_[1] = makeToolBtn(kSvgToolbarEdit, "Edit");
toolbarButtons_[2] = makeToolBtn(kSvgToolbarDelete, "Delete");
toolbar->AddSpacer(4);
toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->AddStretchSpacer(1);
filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1));
filterInput_->SetHint(kPokeFilterHint);
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
pageSizer->Add(toolbar, 0, wxEXPAND);
toolbarButtons_[0]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onAddCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[1]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onEditCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[2]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onDeleteCard(owner != nullptr ? owner : contentPanel_);
});
filterInput_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) {
if (filterInput_ == nullptr) return;
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
});
}
void PokemonGameView::refreshToolbarIcons(const ThemePalette& palette) {
const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
if (toolbarButtons_[0]) {
toolbarButtons_[0]->SetBitmap(
svgIconBitmap(kSvgToolbarAdd, kPokeToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[1]) {
toolbarButtons_[1]->SetBitmap(
svgIconBitmap(kSvgToolbarEdit, kPokeToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[2]) {
toolbarButtons_[2]->SetBitmap(
svgIconBitmap(kSvgToolbarDelete, kPokeToolbarIconPx, tbHex.c_str()));
}
}
void PokemonGameView::selectTab(int index) {
if (index < 0 || index > 1 || book_ == nullptr) return;
activeTab_ = index;
book_->SetSelection(index);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
}
void PokemonGameView::refreshTabBarTheme(const ThemePalette& palette) {
if (tabBar_ == nullptr) return;
const wxColour barBg = palette.panelBg;
const wxColour tabBg = palette.buttonBg;
tabBar_->SetBackgroundColour(barBg);
tabBar_->SetOwnBackgroundColour(barBg);
for (int i = 0; i < 2; ++i) {
auto* tab = tabPanels_[i];
auto* label = tabLabels_[i];
if (tab == nullptr || label == nullptr) continue;
const bool selected = (i == activeTab_);
tab->SetBackgroundColour(tabBg);
tab->SetOwnBackgroundColour(tabBg);
label->SetBackgroundColour(tabBg);
label->SetOwnBackgroundColour(tabBg);
label->SetForegroundColour(palette.text);
label->SetOwnForegroundColour(palette.text);
wxFont font = label->GetFont();
font.SetWeight(selected ? wxFONTWEIGHT_BOLD : wxFONTWEIGHT_NORMAL);
label->SetFont(font);
tab->Refresh();
label->Refresh();
}
tabBar_->Layout();
tabBar_->Refresh();
}
void PokemonGameView::buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer) {
tabBar_ = new wxPanel(parent, wxID_ANY);
tabBar_->SetBackgroundStyle(wxBG_STYLE_PAINT);
auto* tabSizer = new wxBoxSizer(wxHORIZONTAL);
tabSizer->AddSpacer(4);
const char* labels[2] = {"Single Cards", "Set Completion"};
for (int i = 0; i < 2; ++i) {
auto* tab = new wxPanel(tabBar_, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE);
tab->SetCursor(wxCursor(wxCURSOR_HAND));
tab->SetBackgroundStyle(wxBG_STYLE_PAINT);
auto* label = new wxStaticText(tab, wxID_ANY, wxString::FromUTF8(labels[i]));
auto* inner = new wxBoxSizer(wxVERTICAL);
inner->Add(label, 0, wxALIGN_CENTER | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 5);
tab->SetSizer(inner);
auto onClick = [this, i](wxMouseEvent&) { selectTab(i); };
tab->Bind(wxEVT_LEFT_DOWN, onClick);
label->Bind(wxEVT_LEFT_DOWN, onClick);
tab->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
tab->Bind(wxEVT_PAINT, [this, tab, i](wxPaintEvent&) {
wxPaintDC dc(tab);
const ThemePalette palette = paletteForTheme(config_.current().theme);
const bool dark = config_.current().theme == Theme::Dark;
const bool selected = (i == activeTab_);
const wxColour bg = palette.buttonBg;
const wxColour frame =
dark ? lighten(palette.panelBg, 55) : darken(palette.panelBg, 45);
const wxColour frameSel = dark ? lighten(palette.panelBg, 85) : darken(palette.panelBg, 70);
const wxRect r = tab->GetClientRect();
dc.SetPen(wxPen(selected ? frameSel : frame, 1));
dc.SetBrush(wxBrush(bg));
dc.DrawRectangle(r.x, r.y, r.width, r.height);
if (selected) {
dc.SetPen(wxPen(palette.text, 2));
dc.DrawLine(r.GetLeft() + 4, r.GetBottom() - 1, r.GetRight() - 4,
r.GetBottom() - 1);
}
});
tabPanels_[i] = tab;
tabLabels_[i] = label;
if (i > 0) tabSizer->AddSpacer(4);
tabSizer->Add(tab, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 3);
}
tabSizer->AddStretchSpacer(1);
tabBar_->Bind(wxEVT_PAINT, [this](wxPaintEvent&) {
wxPaintDC dc(tabBar_);
const ThemePalette palette = paletteForTheme(config_.current().theme);
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(palette.panelBg));
dc.DrawRectangle(tabBar_->GetClientRect());
dc.SetPen(wxPen(darken(palette.text, 120), 1));
const wxRect r = tabBar_->GetClientRect();
dc.DrawLine(r.GetLeft(), r.GetBottom(), r.GetRight(), r.GetBottom());
});
tabBar_->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
tabBar_->SetSizer(tabSizer);
rootSizer->Add(tabBar_, 0, wxEXPAND);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
}
wxPanel* PokemonGameView::contentPanel(wxWindow* parent) {
if (contentPanel_ == nullptr) {
contentPanel_ = new wxPanel(parent);
auto* root = new wxBoxSizer(wxVERTICAL);
buildTabBar(contentPanel_, root);
book_ = new wxSimplebook(contentPanel_, wxID_ANY);
auto* singlePage = new wxPanel(book_);
auto* singleSizer = new wxBoxSizer(wxVERTICAL);
buildSingleCardsToolbar(singlePage, singleSizer);
ensureSingleCardsMounted(singlePage);
singleSizer->Add(singleSplitter_, 1, wxEXPAND);
singlePage->SetSizer(singleSizer);
book_->AddPage(singlePage, "Single Cards");
setCompletionPanel_ = new PokemonSetCompletionPanel(book_, catalogStore_);
setCompletionPanel_->reloadFromStore();
book_->AddPage(setCompletionPanel_, "Set Completion");
root->Add(book_, 1, wxEXPAND | wxTOP, 5);
contentPanel_->SetSizer(root);
selectTab(0);
refreshToolbarIcons(paletteForTheme(config_.current().theme));
contentPanel_->CallAfter([this]() {
refreshTabBarTheme(paletteForTheme(config_.current().theme));
});
}
return contentPanel_;
}
wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) {
listPanel_ = new PokemonCardListPanel(parent);
@@ -73,16 +310,23 @@ wxPanel* PokemonGameView::selectedPanel(wxWindow* parent) {
}
void PokemonGameView::refreshCollection() {
if (listPanel_ == nullptr) return;
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::Pokemon);
if (!loaded) {
showThemedMessageDialog(nullptr, "Failed to load Pokemon collection: " + loaded.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
listPanel_->setCards(std::move(loaded).value());
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
auto cards = std::move(loaded).value();
if (listPanel_ != nullptr) {
listPanel_->setCards(cards);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->setCollection(std::move(cards));
}
}
const std::vector<Set>& PokemonGameView::setsForDialog(PokemonRegion region) {
@@ -195,56 +439,127 @@ void PokemonGameView::onDeleteCard(wxWindow* parentWindow) {
}
std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) {
auto westOut = sets_.updateSets(Game::Pokemon);
auto asiaOut = sets_.updateSets(Game::JapanesePokemon);
if (westOut) {
setsCacheWest_ = westOut.value();
}
if (asiaOut) {
setsCacheAsia_ = asiaOut.value();
auto* westSrc = dynamic_cast<PokemonSetSource*>(&westModule_.setSource());
auto* asiaSrc = dynamic_cast<JapanesePokemonSetSource*>(&asiaModule_.setSource());
if (westSrc == nullptr || asiaSrc == nullptr) {
showThemedMessageDialog(parentWindow, "Pokemon set source unavailable.",
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
if (!westOut && !asiaOut) {
auto westBoth = westSrc->fetchAllWithCatalog();
auto asiaBoth = asiaSrc->fetchAllWithCatalog();
std::string westErr;
std::string asiaErr;
std::size_t westSets = 0;
std::size_t asiaSets = 0;
std::size_t westPacks = 0;
std::size_t asiaPacks = 0;
if (westBoth) {
auto savedSets = sets_.saveSets(Game::Pokemon, westBoth.value().sets);
if (!savedSets) {
westErr = savedSets.error();
} else {
auto savedCatalog =
catalogStore_.save(PokemonRegion::West, westBoth.value().catalog);
if (!savedCatalog) {
westErr = "sets saved, but catalog failed: " + savedCatalog.error();
}
setsCacheWest_ = westBoth.value().sets;
westSets = westBoth.value().sets.size();
westPacks = westBoth.value().catalog.packs.size();
}
} else {
westErr = westBoth.error();
}
if (asiaBoth) {
auto savedSets = sets_.saveSets(Game::JapanesePokemon, asiaBoth.value().sets);
if (!savedSets) {
asiaErr = savedSets.error();
} else {
auto savedCatalog =
catalogStore_.save(PokemonRegion::Asia, asiaBoth.value().catalog);
if (!savedCatalog) {
asiaErr = "sets saved, but catalog failed: " + savedCatalog.error();
}
setsCacheAsia_ = asiaBoth.value().sets;
asiaSets = asiaBoth.value().sets.size();
asiaPacks = asiaBoth.value().catalog.packs.size();
}
} else {
asiaErr = asiaBoth.error();
}
if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->reloadFromStore();
if (auto loaded = collection_.list(Game::Pokemon)) {
setCompletionPanel_->setCollection(std::move(loaded).value());
}
}
if (!westErr.empty() && !asiaErr.empty()) {
showThemedMessageDialog(
parentWindow,
"Failed to update West sets: " + westOut.error() +
"\nFailed to update Asia sets: " + asiaOut.error(),
"Failed to update West: " + westErr + "\nFailed to update Asia: " + asiaErr,
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
if (!westOut) {
if (!westErr.empty()) {
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(asiaOut.value().size()) +
" Asia Pokemon sets, but West failed: " + westOut.error(),
"Updated " + std::to_string(asiaSets) + " Asia sets / " +
std::to_string(asiaPacks) + " checklists, but West failed: " + westErr,
"Sets partially updated", wxOK | wxICON_WARNING);
return "Pokemon sets partially updated.";
}
if (!asiaOut) {
if (!asiaErr.empty()) {
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(westOut.value().size()) +
" West Pokemon sets, but Asia failed: " + asiaOut.error(),
"Updated " + std::to_string(westSets) + " West sets / " +
std::to_string(westPacks) + " checklists, but Asia failed: " + asiaErr,
"Sets partially updated", wxOK | wxICON_WARNING);
return "Pokemon sets partially updated.";
}
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(westOut.value().size()) + " West and " +
std::to_string(asiaOut.value().size()) + " Asia Pokemon sets.",
"Updated " + std::to_string(westSets) + " West sets (" +
std::to_string(westPacks) + " checklists) and " + std::to_string(asiaSets) +
" Asia sets (" + std::to_string(asiaPacks) + " checklists).",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Pokemon sets updated.";
}
void PokemonGameView::setFilter(std::string_view filter) {
if (filterInput_ != nullptr) {
const wxString wanted = wxString::FromUTF8(std::string(filter).c_str());
if (filterInput_->GetValue() != wanted) {
filterInput_->ChangeValue(wanted);
if (filter.empty()) {
filterInput_->SetHint(kPokeFilterHint);
filterInput_->Refresh();
}
}
}
if (listPanel_) listPanel_->setFilter(filter);
}
void PokemonGameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette);
refreshToolbarIcons(palette);
refreshTabBarTheme(palette);
if (filterInput_ != nullptr) {
filterInput_->SetBackgroundColour(palette.inputBg);
filterInput_->SetForegroundColour(palette.inputText);
filterInput_->SetOwnBackgroundColour(palette.inputBg);
filterInput_->SetOwnForegroundColour(palette.inputText);
}
}
} // namespace ccm::ui
+395
View File
@@ -0,0 +1,395 @@
#include "ccm/ui/PokemonSetCompletionPanel.hpp"
#include "ccm/services/PokemonSetCompletion.hpp"
#include <wx/button.h>
#include <wx/choice.h>
#include <wx/cursor.h>
#include <wx/gauge.h>
#include <wx/listctrl.h>
#include <wx/scrolwin.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <string>
#include <utility>
namespace ccm::ui {
namespace {
wxColour mutedTextColour(const ThemePalette& palette) {
const auto blend = [](unsigned char a, unsigned char b) -> unsigned char {
return static_cast<unsigned char>((static_cast<int>(a) * 2 + static_cast<int>(b)) / 3);
};
return wxColour(blend(palette.text.Red(), palette.panelBg.Red()),
blend(palette.text.Green(), palette.panelBg.Green()),
blend(palette.text.Blue(), palette.panelBg.Blue()));
}
} // namespace
PokemonSetCompletionPanel::PokemonSetCompletionPanel(wxWindow* parent,
PokemonSetCatalogService& catalogStore)
: wxPanel(parent), catalogStore_(catalogStore) {
palette_ = paletteForTheme(inferThemeFromWindow(this));
auto* filterRow = new wxBoxSizer(wxHORIZONTAL);
auto* regionLabel = new wxStaticText(this, wxID_ANY, "Region");
regionChoice_ = new wxChoice(this, wxID_ANY);
regionChoice_->Append("All regions");
regionChoice_->SetSelection(0);
regionChoice_->Bind(wxEVT_CHOICE, &PokemonSetCompletionPanel::onRegionChoice, this);
filterRow->Add(regionLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
filterRow->Add(regionChoice_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 16);
auto* langLabel = new wxStaticText(this, wxID_ANY, "Language");
languageChoice_ = new wxChoice(this, wxID_ANY);
languageChoice_->Append("All languages");
languageChoice_->SetSelection(0);
languageChoice_->Bind(wxEVT_CHOICE, &PokemonSetCompletionPanel::onLanguageChoice, this);
filterRow->Add(langLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
filterRow->Add(languageChoice_, 0, wxALIGN_CENTER_VERTICAL);
book_ = new wxSimplebook(this, wxID_ANY);
gridPage_ = new wxPanel(book_);
auto* gridRoot = new wxBoxSizer(wxVERTICAL);
emptyLabel_ = new wxStaticText(gridPage_, wxID_ANY, "");
emptyLabel_->Wrap(480);
gridRoot->Add(emptyLabel_, 0, wxALL | wxEXPAND, 12);
scroll_ = new wxScrolledWindow(gridPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxVSCROLL | wxTAB_TRAVERSAL);
scroll_->SetScrollRate(0, 16);
gridSizer_ = new wxBoxSizer(wxVERTICAL);
scroll_->SetSizer(gridSizer_);
gridRoot->Add(scroll_, 1, wxEXPAND);
gridPage_->SetSizer(gridRoot);
book_->AddPage(gridPage_, "Grid");
detailPage_ = new wxPanel(book_);
auto* detailRoot = new wxBoxSizer(wxVERTICAL);
auto* topRow = new wxBoxSizer(wxHORIZONTAL);
auto* backBtn = new wxButton(detailPage_, wxID_ANY, "Back");
backBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { showGridPage(); });
detailTitle_ = new wxStaticText(detailPage_, wxID_ANY, "");
auto titleFont = detailTitle_->GetFont();
titleFont.MakeBold().MakeLarger();
detailTitle_->SetFont(titleFont);
topRow->Add(backBtn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
topRow->Add(detailTitle_, 1, wxALIGN_CENTER_VERTICAL);
detailRoot->Add(topRow, 0, wxEXPAND | wxALL, 8);
checklist_ = new wxListCtrl(detailPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
checklist_->AppendColumn("Card", wxLIST_FORMAT_LEFT, 520);
detailRoot->Add(checklist_, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8);
detailPage_->SetSizer(detailRoot);
book_->AddPage(detailPage_, "Detail");
auto* root = new wxBoxSizer(wxVERTICAL);
root->Add(filterRow, 0, wxEXPAND | wxALL, 8);
root->Add(book_, 1, wxEXPAND);
SetSizer(root);
showGridPage();
}
void PokemonSetCompletionPanel::setCollection(std::vector<PokemonCard> cards) {
collection_ = std::move(cards);
refreshRegionChoice();
refreshLanguageChoice();
rebuildCurrentView();
}
void PokemonSetCompletionPanel::reloadFromStore() {
westCatalogLoaded_ = false;
asiaCatalogLoaded_ = false;
westCatalog_ = {};
asiaCatalog_ = {};
if (catalogStore_.exists(PokemonRegion::West)) {
if (auto loaded = catalogStore_.load(PokemonRegion::West)) {
westCatalog_ = std::move(loaded).value();
westCatalogLoaded_ = true;
}
}
if (catalogStore_.exists(PokemonRegion::Asia)) {
if (auto loaded = catalogStore_.load(PokemonRegion::Asia)) {
asiaCatalog_ = std::move(loaded).value();
asiaCatalogLoaded_ = true;
}
}
showGridPage();
rebuildGrid();
}
void PokemonSetCompletionPanel::applyTheme(const ThemePalette& palette) {
palette_ = palette;
applyThemeToWindowTree(this, palette, inferThemeFromWindow(this));
rebuildCurrentView();
}
void PokemonSetCompletionPanel::showGridPage() {
detailSetId_.clear();
detailSetName_.clear();
book_->SetSelection(0);
}
void PokemonSetCompletionPanel::showChecklistPage(PokemonRegion region,
const std::string& setId,
const std::string& setName) {
detailRegion_ = region;
detailSetId_ = setId;
detailSetName_ = setName;
detailTitle_->SetLabelText(
wxString::FromUTF8(displaySetName(setName, region).c_str()));
rebuildChecklist(region, setId);
book_->SetSelection(1);
}
std::string PokemonSetCompletionPanel::displaySetName(const std::string& setName,
PokemonRegion region) const {
std::string out = setName;
if (!regionFilter_.has_value()) {
out += " (";
out += std::string(to_string(region));
out += ")";
}
if (languageFilter_.has_value()) {
out += " (";
out += std::string(to_string(*languageFilter_));
out += ")";
}
return out;
}
bool PokemonSetCompletionPanel::catalogsReadyForFilter() const {
if (!regionFilter_.has_value()) {
return westCatalogLoaded_ || asiaCatalogLoaded_;
}
if (*regionFilter_ == PokemonRegion::West) return westCatalogLoaded_;
return asiaCatalogLoaded_;
}
void PokemonSetCompletionPanel::refreshRegionChoice() {
const auto previous = regionFilter_;
const auto present =
pokemonRegionsInCollection(collection_, westCatalog_, asiaCatalog_);
regionChoice_->Clear();
regionChoice_->Append("All regions");
for (const PokemonRegion region : present) {
regionChoice_->Append(wxString::FromUTF8(std::string(to_string(region)).c_str()));
}
int selection = 0;
regionFilter_ = std::nullopt;
if (previous.has_value()) {
for (std::size_t i = 0; i < present.size(); ++i) {
if (present[i] == *previous) {
selection = static_cast<int>(i + 1);
regionFilter_ = previous;
break;
}
}
}
regionChoice_->SetSelection(selection);
}
void PokemonSetCompletionPanel::refreshLanguageChoice() {
const auto previous = languageFilter_;
const auto present = pokemonLanguagesInCollection(collection_, regionFilter_);
languageChoice_->Clear();
languageChoice_->Append("All languages");
for (const Language lang : present) {
languageChoice_->Append(wxString::FromUTF8(std::string(to_string(lang)).c_str()));
}
int selection = 0;
languageFilter_ = std::nullopt;
if (previous.has_value()) {
for (std::size_t i = 0; i < present.size(); ++i) {
if (present[i] == *previous) {
selection = static_cast<int>(i + 1);
languageFilter_ = previous;
break;
}
}
}
languageChoice_->SetSelection(selection);
}
void PokemonSetCompletionPanel::onRegionChoice(wxCommandEvent& /*event*/) {
const int sel = regionChoice_->GetSelection();
if (sel <= 0) {
regionFilter_ = std::nullopt;
} else {
const auto present =
pokemonRegionsInCollection(collection_, westCatalog_, asiaCatalog_);
const auto idx = static_cast<std::size_t>(sel - 1);
if (idx < present.size()) {
regionFilter_ = present[idx];
} else {
regionFilter_ = std::nullopt;
regionChoice_->SetSelection(0);
}
}
refreshLanguageChoice();
rebuildCurrentView();
}
void PokemonSetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) {
const int sel = languageChoice_->GetSelection();
if (sel <= 0) {
languageFilter_ = std::nullopt;
} else {
const auto present = pokemonLanguagesInCollection(collection_, regionFilter_);
const auto idx = static_cast<std::size_t>(sel - 1);
if (idx < present.size()) {
languageFilter_ = present[idx];
} else {
languageFilter_ = std::nullopt;
languageChoice_->SetSelection(0);
}
}
rebuildCurrentView();
}
void PokemonSetCompletionPanel::rebuildCurrentView() {
if (book_->GetSelection() == 1 && !detailSetId_.empty()) {
const auto rows = computePokemonSetCompletion(
collection_, westCatalog_, asiaCatalog_, regionFilter_, languageFilter_);
bool stillVisible = false;
for (const auto& row : rows) {
if (row.setId == detailSetId_ && row.region == detailRegion_) {
stillVisible = true;
break;
}
}
if (!stillVisible) {
showGridPage();
rebuildGrid();
return;
}
detailTitle_->SetLabelText(
wxString::FromUTF8(displaySetName(detailSetName_, detailRegion_).c_str()));
rebuildChecklist(detailRegion_, detailSetId_);
} else {
rebuildGrid();
}
}
void PokemonSetCompletionPanel::setEmptyMessage(const wxString& message) {
clearGridTiles();
emptyLabel_->SetLabelText(message);
emptyLabel_->Wrap(480);
emptyLabel_->Show();
scroll_->Hide();
gridPage_->Layout();
}
void PokemonSetCompletionPanel::clearGridTiles() {
if (gridSizer_ == nullptr) return;
gridSizer_->Clear(true);
}
void PokemonSetCompletionPanel::rebuildGrid() {
if (!catalogsReadyForFilter()) {
setEmptyMessage(wxString::FromUTF8(
"Set checklists are not downloaded yet.\n"
"Run Sets → Update Pokemon to enable Set Completion."));
return;
}
const auto rows = computePokemonSetCompletion(
collection_, westCatalog_, asiaCatalog_, regionFilter_, languageFilter_);
if (rows.empty()) {
setEmptyMessage(wxString::FromUTF8(
"No Pokemon sets in progress yet.\n"
"Add cards on the Single Cards tab to track set completion here."));
return;
}
emptyLabel_->Hide();
scroll_->Show();
clearGridTiles();
for (const auto& row : rows) {
auto* tile = new wxPanel(scroll_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxBORDER_SIMPLE);
tile->SetBackgroundColour(palette_.panelBg);
auto* tileSizer = new wxBoxSizer(wxVERTICAL);
const std::string title = displaySetName(row.setName, row.region);
auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(title.c_str()));
auto nameFont = nameLbl->GetFont();
nameFont.MakeBold();
nameLbl->SetFont(nameFont);
nameLbl->SetForegroundColour(palette_.text);
const std::string counts =
std::to_string(row.ownedUnique) + " / " + std::to_string(row.total) + " (" +
std::to_string(row.percent()) + "%)";
auto* countLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(counts.c_str()));
countLbl->SetForegroundColour(palette_.text);
auto* gauge = new wxGauge(tile, wxID_ANY, 100, wxDefaultPosition, wxSize(-1, 14),
wxGA_HORIZONTAL | wxGA_SMOOTH);
gauge->SetValue(row.percent());
tileSizer->Add(nameLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 10);
tileSizer->Add(countLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 6);
tileSizer->Add(gauge, 0, wxEXPAND | wxALL, 10);
tile->SetSizer(tileSizer);
const PokemonRegion region = row.region;
const std::string setId = row.setId;
const std::string setName = row.setName;
auto openDetail = [this, region, setId, setName](wxMouseEvent&) {
showChecklistPage(region, setId, setName);
};
tile->Bind(wxEVT_LEFT_UP, openDetail);
nameLbl->Bind(wxEVT_LEFT_UP, openDetail);
countLbl->Bind(wxEVT_LEFT_UP, openDetail);
gauge->Bind(wxEVT_LEFT_UP, openDetail);
tile->SetCursor(wxCursor(wxCURSOR_HAND));
nameLbl->SetCursor(wxCursor(wxCURSOR_HAND));
countLbl->SetCursor(wxCursor(wxCURSOR_HAND));
gridSizer_->Add(tile, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
}
gridSizer_->AddStretchSpacer(1);
scroll_->FitInside();
gridPage_->Layout();
Layout();
}
void PokemonSetCompletionPanel::rebuildChecklist(PokemonRegion region,
const std::string& setId) {
checklist_->DeleteAllItems();
const auto entries = pokemonChecklistForSet(
collection_, westCatalog_, asiaCatalog_, region, setId, languageFilter_);
const wxColour muted = mutedTextColour(palette_);
const wxColour ownedGreen(46, 160, 67);
long idx = 0;
for (const auto& entry : entries) {
const std::string line =
(entry.owned ? "" : " ") + entry.setNo + "" + entry.name;
const long row = checklist_->InsertItem(idx++, wxString::FromUTF8(line.c_str()));
if (row < 0) continue;
if (entry.owned) {
checklist_->SetItemTextColour(row, ownedGreen);
} else {
checklist_->SetItemTextColour(row, muted);
}
}
checklist_->SetColumnWidth(0, wxLIST_AUTOSIZE);
detailPage_->Layout();
}
} // namespace ccm::ui
+332 -27
View File
@@ -1,32 +1,65 @@
#include "ccm/ui/YuGiOhGameView.hpp"
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
#include "ccm/ui/YuGiOhCardListPanel.hpp"
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/ui/YuGiOhSetCompletionPanel.hpp"
#include <wx/msgdlg.h>
#include <wx/bmpbuttn.h>
#include <wx/cursor.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 <algorithm>
#include <string>
namespace ccm::ui {
YuGiOhGameView::YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module)
namespace {
constexpr int kYgoToolbarIconPx = 18;
constexpr const char kYgoFilterHint[] = "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
YuGiOhGameView::YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module,
YuGiOhSetCatalogService& catalogStore)
: config_(config),
collection_(collection),
sets_(sets),
images_(images),
cardPreview_(cardPreview),
module_(module) {}
module_(module),
catalogStore_(catalogStore) {}
void YuGiOhGameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
@@ -50,6 +83,208 @@ void YuGiOhGameView::ensureSetsLoaded() {
}
}
void YuGiOhGameView::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 YuGiOhGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) {
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
auto makeToolBtn = [&](const char* svg, const wxString& tip) {
wxBitmap bmp = svgIconBitmap(svg, kYgoToolbarIconPx, "#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(kYgoFilterHint);
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 YuGiOhGameView::refreshToolbarIcons(const ThemePalette& palette) {
const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
if (toolbarButtons_[0]) {
toolbarButtons_[0]->SetBitmap(
svgIconBitmap(kSvgToolbarAdd, kYgoToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[1]) {
toolbarButtons_[1]->SetBitmap(
svgIconBitmap(kSvgToolbarEdit, kYgoToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[2]) {
toolbarButtons_[2]->SetBitmap(
svgIconBitmap(kSvgToolbarDelete, kYgoToolbarIconPx, tbHex.c_str()));
}
}
void YuGiOhGameView::selectTab(int index) {
if (index < 0 || index > 1 || book_ == nullptr) return;
activeTab_ = index;
book_->SetSelection(index);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
}
void YuGiOhGameView::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 YuGiOhGameView::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* YuGiOhGameView::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 YuGiOhSetCompletionPanel(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* YuGiOhGameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) {
listPanel_ = new YuGiOhCardListPanel(parent);
@@ -74,16 +309,23 @@ wxPanel* YuGiOhGameView::selectedPanel(wxWindow* parent) {
}
void YuGiOhGameView::refreshCollection() {
if (listPanel_ == nullptr) return;
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::YuGiOh);
if (!loaded) {
showThemedMessageDialog(nullptr, "Failed to load Yu-Gi-Oh! 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>& YuGiOhGameView::setsForDialog() {
@@ -94,8 +336,9 @@ const std::vector<Set>& YuGiOhGameView::setsForDialog() {
setsCache_ = std::move(loaded).value();
std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
} else {
setsCache_.clear();
}
else setsCache_.clear();
return setsCache_;
}
@@ -135,13 +378,18 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
persisted.images = std::move(normalized).value();
auto updated = collection_.update(Game::YuGiOh, persisted);
if (!updated) {
showThemedMessageDialog(parentWindow, "Card added, but image name normalization failed to persist: " + updated.error(),
"Warning", wxOK | wxICON_WARNING);
showThemedMessageDialog(
parentWindow,
"Card added, but image name normalization failed to persist: " +
updated.error(),
"Warning", wxOK | wxICON_WARNING);
}
}
} else {
showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
showThemedMessageDialog(
parentWindow,
"Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection();
}
@@ -150,7 +398,8 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit",
wxOK | wxICON_INFORMATION);
return;
}
if (cardEditModalIsActive()) {
@@ -176,7 +425,8 @@ void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete",
wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
@@ -193,27 +443,82 @@ void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) {
}
std::string YuGiOhGameView::onUpdateSets(wxWindow* parentWindow) {
auto out = sets_.updateSets(Game::YuGiOh);
if (!out) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
auto* ygoSrc = dynamic_cast<YuGiOhSetSource*>(&module_.setSource());
if (ygoSrc == nullptr) {
showThemedMessageDialog(parentWindow, "Yu-Gi-Oh! set source unavailable.",
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
setsCache_ = out.value();
auto both = ygoSrc->fetchAllWithCatalog();
if (!both) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
auto savedSets = sets_.saveSets(Game::YuGiOh, both.value().sets);
if (!savedSets) {
showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
auto savedCatalog = catalogStore_.save(both.value().catalog);
if (!savedCatalog) {
showThemedMessageDialog(parentWindow,
"Sets saved, but set catalog failed: " + savedCatalog.error(),
"Warning", wxOK | wxICON_WARNING);
}
setsCache_ = both.value().sets;
std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Yu-Gi-Oh! sets.",
"Sets updated", wxOK | wxICON_INFORMATION);
if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->reloadFromStore();
if (auto loaded = collection_.list(Game::YuGiOh)) {
setCompletionPanel_->setCollection(std::move(loaded).value());
}
}
const std::size_t setCount = both.value().sets.size();
const std::size_t packCount = both.value().catalog.packs.size();
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(setCount) + " Yu-Gi-Oh! sets and " +
std::to_string(packCount) + " set checklists.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Yu-Gi-Oh! sets updated.";
}
void YuGiOhGameView::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(kYgoFilterHint);
filterInput_->Refresh();
}
}
}
if (listPanel_) listPanel_->setFilter(filter);
}
void YuGiOhGameView::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);
filterInput_->Refresh();
}
}
} // namespace ccm::ui
+311
View File
@@ -0,0 +1,311 @@
#include "ccm/ui/YuGiOhSetCompletionPanel.hpp"
#include "ccm/services/YuGiOhSetCompletion.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) {
// Blend text toward panel background so missing checklist rows read as greyed.
const auto blend = [](unsigned char a, unsigned char b) -> unsigned char {
return static_cast<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
YuGiOhSetCompletionPanel::YuGiOhSetCompletionPanel(wxWindow* parent,
YuGiOhSetCatalogService& catalogStore)
: wxPanel(parent), catalogStore_(catalogStore) {
palette_ = paletteForTheme(inferThemeFromWindow(this));
auto* langRow = new wxBoxSizer(wxHORIZONTAL);
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, &YuGiOhSetCompletionPanel::onLanguageChoice, this);
langRow->Add(langLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
langRow->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(langRow, 0, wxEXPAND | wxALL, 8);
root->Add(book_, 1, wxEXPAND);
SetSizer(root);
showGridPage();
}
void YuGiOhSetCompletionPanel::setCollection(std::vector<YuGiOhCard> cards) {
collection_ = std::move(cards);
refreshLanguageChoice();
rebuildCurrentView();
}
void YuGiOhSetCompletionPanel::reloadFromStore() {
catalogLoaded_ = false;
catalog_ = {};
if (catalogStore_.exists()) {
if (auto loaded = catalogStore_.load()) {
catalog_ = std::move(loaded).value();
catalogLoaded_ = true;
}
}
showGridPage();
rebuildGrid();
}
void YuGiOhSetCompletionPanel::applyTheme(const ThemePalette& palette) {
palette_ = palette;
applyThemeToWindowTree(this, palette, inferThemeFromWindow(this));
rebuildCurrentView();
}
void YuGiOhSetCompletionPanel::showGridPage() {
detailSetId_.clear();
detailSetName_.clear();
book_->SetSelection(0);
}
void YuGiOhSetCompletionPanel::showChecklistPage(const std::string& setId,
const std::string& setName) {
detailSetId_ = setId;
detailSetName_ = setName;
detailTitle_->SetLabelText(wxString::FromUTF8(displaySetName(setName).c_str()));
rebuildChecklist(setId);
book_->SetSelection(1);
}
std::string YuGiOhSetCompletionPanel::displaySetName(const std::string& setName) const {
if (!languageFilter_.has_value()) return setName;
return setName + " (" + std::string(to_string(*languageFilter_)) + ")";
}
void YuGiOhSetCompletionPanel::refreshLanguageChoice() {
const auto previous = languageFilter_;
const auto present = yuGiOhLanguagesInCollection(collection_);
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 YuGiOhSetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) {
const int sel = languageChoice_->GetSelection();
if (sel <= 0) {
languageFilter_ = std::nullopt;
} else {
const auto present = yuGiOhLanguagesInCollection(collection_);
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 YuGiOhSetCompletionPanel::rebuildCurrentView() {
if (book_->GetSelection() == 1 && !detailSetId_.empty()) {
const auto rows =
computeYuGiOhSetCompletion(collection_, catalog_, languageFilter_);
bool stillVisible = false;
for (const auto& row : rows) {
if (row.setId == detailSetId_) {
stillVisible = true;
break;
}
}
if (!stillVisible) {
showGridPage();
rebuildGrid();
return;
}
detailTitle_->SetLabelText(
wxString::FromUTF8(displaySetName(detailSetName_).c_str()));
rebuildChecklist(detailSetId_);
} else {
rebuildGrid();
}
}
void YuGiOhSetCompletionPanel::setEmptyMessage(const wxString& message) {
clearGridTiles();
emptyLabel_->SetLabelText(message);
emptyLabel_->Wrap(480);
emptyLabel_->Show();
scroll_->Hide();
gridPage_->Layout();
}
void YuGiOhSetCompletionPanel::clearGridTiles() {
if (gridSizer_ == nullptr) return;
gridSizer_->Clear(true);
}
void YuGiOhSetCompletionPanel::rebuildGrid() {
if (!catalogLoaded_) {
setEmptyMessage(wxString::FromUTF8(
"Set checklists are not downloaded yet.\n"
"Run Sets → Update Yu-Gi-Oh! to enable Set Completion."));
return;
}
const auto rows = computeYuGiOhSetCompletion(collection_, catalog_, languageFilter_);
if (rows.empty()) {
setEmptyMessage(wxString::FromUTF8(
"No Yu-Gi-Oh! 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);
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 std::string setId = row.setId;
const std::string setName = row.setName;
auto openDetail = [this, setId, setName](wxMouseEvent&) {
showChecklistPage(setId, setName);
};
tile->Bind(wxEVT_LEFT_UP, openDetail);
nameLbl->Bind(wxEVT_LEFT_UP, openDetail);
countLbl->Bind(wxEVT_LEFT_UP, openDetail);
gauge->Bind(wxEVT_LEFT_UP, openDetail);
tile->SetCursor(wxCursor(wxCURSOR_HAND));
nameLbl->SetCursor(wxCursor(wxCURSOR_HAND));
countLbl->SetCursor(wxCursor(wxCURSOR_HAND));
gridSizer_->Add(tile, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
}
gridSizer_->AddStretchSpacer(1);
scroll_->FitInside();
gridPage_->Layout();
Layout();
}
void YuGiOhSetCompletionPanel::rebuildChecklist(const std::string& setId) {
checklist_->DeleteAllItems();
const auto entries =
yuGiOhChecklistForSet(collection_, catalog_, setId, languageFilter_);
const wxColour muted = mutedTextColour(palette_);
// Fixed green so owned checkmarks stay readable in both light and dark themes.
const wxColour ownedGreen(46, 160, 67);
long idx = 0;
for (const auto& entry : entries) {
// Align names: checkmark + two spaces vs four spaces for missing cards.
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