diff --git a/AGENTS.md b/AGENTS.md index 371dc8f..a2e6391 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Run from the **workspace root**. - Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows. - For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light. - The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize). -- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed. +- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme` in `Theme.cpp` (opt out of immersive dark mode + parent `WM_CTLCOLOREDIT` subclass — that message goes to the EDIT's parent, not `MainFrame`). - Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows. - For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations. - Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong. diff --git a/README.md b/README.md index 8344da1..ae55343 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Currently, the application supports the following TCGs: - Magic the Gathering - Pokemon TCG - Yu-Gi-Oh! +- Yu-Gi-Oh! (Bandai) - Digimon (Digi-Battle) ## Screenshots @@ -34,6 +35,13 @@ Currently, the application supports the following TCGs: +
+Yu-Gi-Oh! (Bandai) + +![CCM3 Demo - Yu-Gi-Oh! (Bandai)](docs/assets/images/demo-ygo-bandai.png) + +
+
Digimon (Digi-Battle) diff --git a/app/AGENTS.md b/app/AGENTS.md index 438ea68..f72a66a 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -9,7 +9,7 @@ The `ccm` executable — composition root only. The single place where concrete ## Conventions -1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `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. +1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `YuGiOhBandaiSetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `YuGiOhBandaiGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, `YuGiOhBandaiGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. 2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually. 3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set). 4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`). diff --git a/app/main.cpp b/app/main.cpp index cfb68a2..3b6fe77 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -5,6 +5,7 @@ #include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" #include "ccm/games/digibattle99/DigiBattle99GameModule.hpp" #include "ccm/games/magic/MagicGameModule.hpp" @@ -12,6 +13,7 @@ #include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" #include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp" #include "ccm/games/yugioh/YuGiOhGameModule.hpp" +#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp" #include "ccm/infra/CprHttpClient.hpp" #include "ccm/infra/JsonCollectionRepository.hpp" #include "ccm/infra/JsonSetRepository.hpp" @@ -23,6 +25,7 @@ #include "ccm/services/ConfigService.hpp" #include "ccm/services/DigiBattle99SetCatalogService.hpp" #include "ccm/services/PokemonSetCatalogService.hpp" +#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp" #include "ccm/services/YuGiOhSetCatalogService.hpp" #include "ccm/services/ImageService.hpp" #include "ccm/services/SetService.hpp" @@ -31,6 +34,7 @@ #include "ccm/ui/MagicGameView.hpp" #include "ccm/ui/MainFrame.hpp" #include "ccm/ui/PokemonGameView.hpp" +#include "ccm/ui/YuGiOhBandaiGameView.hpp" #include "ccm/ui/YuGiOhGameView.hpp" #include @@ -54,6 +58,7 @@ std::string dirNameForGame(ccm::Game g) { case ccm::Game::Pokemon: return "pokemon"; case ccm::Game::YuGiOh: return "yugioh"; case ccm::Game::DigiBattle99: return "digibattle99"; + case ccm::Game::YuGiOhBandai: return "yugiohbandai"; case ccm::Game::JapanesePokemon: return "pokemon"; } return "magic"; @@ -91,6 +96,7 @@ public: pokeMod_ = std::make_unique(*http_); ygoMod_ = std::make_unique(*http_); digiBattle99Mod_ = std::make_unique(*http_); + ygoBandaiMod_ = std::make_unique(*http_); ccm::JapanesePokemonEnCatalog jpCatalog; { @@ -112,11 +118,16 @@ public: digiBattle99Repo_ = std::make_unique>( *fs_, *config_, &dirNameForGame); + ygoBandaiRepo_ = + std::make_unique>( + *fs_, *config_, &dirNameForGame); setRepo_ = std::make_unique(*fs_, *config_, &dirNameForGame); digiBattle99CatalogStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); ygoCatalogStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); + ygoBandaiCatalogStore_ = + std::make_unique(*fs_, *config_, &dirNameForGame); pokeCatalogStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); imgStore_ = std::make_unique(*fs_, *config_, &dirNameForGame); @@ -131,11 +142,15 @@ public: digiBattle99CollSvc_ = std::make_unique>( *digiBattle99Repo_, *imgStore_); + ygoBandaiCollSvc_ = + std::make_unique>( + *ygoBandaiRepo_, *imgStore_); setSvc_ = std::make_unique(*setRepo_); setSvc_->registerModule(magicMod_.get()); setSvc_->registerModule(pokeMod_.get()); setSvc_->registerModule(ygoMod_.get()); setSvc_->registerModule(digiBattle99Mod_.get()); + setSvc_->registerModule(ygoBandaiMod_.get()); setSvc_->registerModule(jpPokeMod_.get()); // Disk-backed preview cache lives next to the executable, in the same @@ -161,6 +176,7 @@ public: previewSvc_->registerModule(*pokeMod_); previewSvc_->registerModule(*ygoMod_); previewSvc_->registerModule(*digiBattle99Mod_); + previewSvc_->registerModule(*ygoBandaiMod_); previewSvc_->registerModule(*jpPokeMod_); // Per-game UI bundles. Order here is the order shown in the Game menu. @@ -175,6 +191,9 @@ public: digiBattle99View_ = std::make_unique( *config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *digiBattle99Mod_, *digiBattle99CatalogStore_); + ygoBandaiView_ = std::make_unique( + *config_, *ygoBandaiCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, + *ygoBandaiMod_, *ygoBandaiCatalogStore_); ctx_ = std::make_unique(ccm::ui::AppContext{ *config_, @@ -185,8 +204,10 @@ public: *pokeMod_, *ygoMod_, *digiBattle99Mod_, + *ygoBandaiMod_, *jpPokeMod_, - { magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() }, + { magicView_.get(), pokeView_.get(), ygoView_.get(), ygoBandaiView_.get(), + digiBattle99View_.get() }, }); auto* frame = new ccm::ui::MainFrame(*ctx_); @@ -208,14 +229,17 @@ private: std::unique_ptr pokeMod_; std::unique_ptr ygoMod_; std::unique_ptr digiBattle99Mod_; + std::unique_ptr ygoBandaiMod_; std::unique_ptr jpPokeMod_; std::unique_ptr> magicRepo_; std::unique_ptr> pokeRepo_; std::unique_ptr> ygoRepo_; std::unique_ptr> digiBattle99Repo_; + std::unique_ptr> ygoBandaiRepo_; std::unique_ptr setRepo_; std::unique_ptr digiBattle99CatalogStore_; std::unique_ptr ygoCatalogStore_; + std::unique_ptr ygoBandaiCatalogStore_; std::unique_ptr pokeCatalogStore_; std::unique_ptr imgStore_; std::unique_ptr imgSvc_; @@ -223,6 +247,7 @@ private: std::unique_ptr> pokeCollSvc_; std::unique_ptr> ygoCollSvc_; std::unique_ptr> digiBattle99CollSvc_; + std::unique_ptr> ygoBandaiCollSvc_; std::unique_ptr setSvc_; std::unique_ptr previewCache_; std::unique_ptr previewSvc_; @@ -230,6 +255,7 @@ private: std::unique_ptr pokeView_; std::unique_ptr ygoView_; std::unique_ptr digiBattle99View_; + std::unique_ptr ygoBandaiView_; std::unique_ptr ctx_; }; diff --git a/core/AGENTS.md b/core/AGENTS.md index 9c7fe41..b3a6b9e 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -4,11 +4,11 @@ ## Layer pointers -- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `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/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), `YuGiOhBandaiCard`, `YuGiOhBandaiSetCatalog` (Bandai pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. - `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. -- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` / `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 `fetchAllWithCatalog` (and related catalog parsers) 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/services/` — high-level operations: `ConfigService`, `CollectionService` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `YuGiOhBandaiSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `YuGiOhBandaiSetCatalogService` (`yugiohbandai/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/`, `yugiohbandai/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all expose a fully working set source + card preview source. `YuGiOhSetSource`, `YuGiOhBandaiSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill. - `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested). - `src/` mirrors `include/ccm/` for non-template implementations. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index f89471a..b29518c 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -7,6 +7,8 @@ add_library(ccm_core STATIC src/domain/MagicCard.cpp src/domain/PokemonCard.cpp src/domain/YuGiOhCard.cpp + src/domain/YuGiOhBandaiCard.cpp + src/domain/YuGiOhBandaiSetCatalog.cpp src/domain/DigiBattle99Card.cpp src/domain/DigiBattle99SetCatalog.cpp src/domain/YuGiOhSetCatalog.cpp @@ -24,6 +26,8 @@ add_library(ccm_core STATIC src/services/DigiBattle99SetCatalogService.cpp src/services/YuGiOhSetCompletion.cpp src/services/YuGiOhSetCatalogService.cpp + src/services/YuGiOhBandaiSetCompletion.cpp + src/services/YuGiOhBandaiSetCatalogService.cpp src/services/PokemonSetCompletion.cpp src/services/PokemonSetCatalogService.cpp @@ -47,6 +51,9 @@ add_library(ccm_core STATIC src/games/digibattle99/DigiBattle99SetSource.cpp src/games/digibattle99/DigiBattle99CardPreviewSource.cpp src/games/digibattle99/DigiBattle99GameModule.cpp + src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp + src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp + src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp src/games/pokemonjp/JapanesePokemonEnCatalog.cpp src/games/pokemonjp/JapanesePokemonSetSource.cpp src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp diff --git a/core/include/ccm/domain/Enums.hpp b/core/include/ccm/domain/Enums.hpp index 085bdb3..a532132 100644 --- a/core/include/ccm/domain/Enums.hpp +++ b/core/include/ccm/domain/Enums.hpp @@ -21,6 +21,7 @@ enum class Game { Pokemon, YuGiOh, DigiBattle99, + YuGiOhBandai, JapanesePokemon, // internal Asia sets/preview routing; not in allGames() }; @@ -70,7 +71,7 @@ std::optional conditionFromString(std::string_view s) noexcept; std::optional themeFromString(std::string_view s) noexcept; // User-facing games (Game menu / Settings). JapanesePokemon is internal-only. -const std::array& allGames() noexcept; +const std::array& allGames() noexcept; const std::array& allLanguages() noexcept; const std::array& allConditions() noexcept; const std::array& allThemes() noexcept; diff --git a/core/include/ccm/domain/YuGiOhBandaiCard.hpp b/core/include/ccm/domain/YuGiOhBandaiCard.hpp new file mode 100644 index 0000000..4eb12f6 --- /dev/null +++ b/core/include/ccm/domain/YuGiOhBandaiCard.hpp @@ -0,0 +1,37 @@ +#pragma once + +// YuGiOhBandaiCard - Bandai Carddass (pre-Konami) card model. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +struct YuGiOhBandaiCard { + std::uint32_t id{0}; + std::uint8_t amount{1}; + std::string name; + Set set; + std::string setNo; + std::string rarity; + std::string note; + std::vector images; + Language language{Language::Japanese}; + Condition condition{Condition::NearMint}; + bool holo{false}; + bool signed_{false}; + bool altered{false}; + + friend bool operator==(const YuGiOhBandaiCard&, const YuGiOhBandaiCard&) = default; +}; + +void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c); +void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c); + +} // namespace ccm diff --git a/core/include/ccm/domain/YuGiOhBandaiSetCatalog.hpp b/core/include/ccm/domain/YuGiOhBandaiSetCatalog.hpp new file mode 100644 index 0000000..c143751 --- /dev/null +++ b/core/include/ccm/domain/YuGiOhBandaiSetCatalog.hpp @@ -0,0 +1,52 @@ +#pragma once + +// YuGiOhBandaiSetCatalog: offline pack → card checklist for Bandai set +// completion. Filled from Yugipedia set-gallery wikitext and persisted at +// `/yugiohbandai/set-catalog.json`. + +#include + +#include +#include +#include + +namespace ccm { + +struct YuGiOhBandaiCatalogCard { + std::string setNo; + std::string name; + std::string rarity; + + friend bool operator==(const YuGiOhBandaiCatalogCard&, + const YuGiOhBandaiCatalogCard&) = default; +}; + +struct YuGiOhBandaiSetCatalogPack { + std::string setId; + std::string setName; + std::vector cards; + + friend bool operator==(const YuGiOhBandaiSetCatalogPack&, + const YuGiOhBandaiSetCatalogPack&) = default; +}; + +struct YuGiOhBandaiSetCatalog { + std::vector packs; + + [[nodiscard]] const YuGiOhBandaiSetCatalogPack* findPack( + std::string_view setId) const; + + [[nodiscard]] bool empty() const noexcept { return packs.empty(); } + + friend bool operator==(const YuGiOhBandaiSetCatalog&, + const YuGiOhBandaiSetCatalog&) = default; +}; + +void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c); +void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c); +void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p); +void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p); +void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c); +void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c); + +} // namespace ccm diff --git a/core/include/ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp new file mode 100644 index 0000000..2fc93bc --- /dev/null +++ b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp @@ -0,0 +1,77 @@ +#pragma once + +// YuGiOhBandaiCardPreviewSource: Yugipedia pageimages + SMW ask for Bandai +// Carddass previews and auto-detect (by English name or Bandai number). + +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include +#include + +namespace ccm { + +class YuGiOhBandaiCardPreviewSource final : public ICardPreviewSource { +public: + explicit YuGiOhBandaiCardPreviewSource(IHttpClient& http); + + [[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; } + + Result + fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) override; + + Result detectFirstPrint(std::string_view name, + std::string_view setId) override; + + Result> detectPrintVariants(std::string_view name, + std::string_view setId) override; + + Result detectBySetNo(std::string_view setNo) override; + + Result> detectVariantsBySetNo( + std::string_view setNo) override; + + // Prefer " (Bandai)" / English / Sealdass page depending on setId. + static std::string preferredPageTitle(std::string_view name, + std::string_view setId, + std::string_view setNo); + + static std::string buildPageImagesUrl(std::string_view pageTitle); + + static std::string buildAskByNameUrl(std::string_view englishName); + + static std::string buildAskByNumberUrl(std::string_view setNo); + + // True for Jump/Toei promo codes (J1, TA2, …). Yugipedia's SMW + // `Bandai number` property is numeric-only, so these must use the + // promotional gallery instead of `action=ask`. + [[nodiscard]] static bool isAlphanumericPromoNumber(std::string_view setNo); + + static Result> + parsePromoGalleryResponse(const std::string& body, + std::string_view wantedSetNo); + + static Result + parsePageImagesResponse(const std::string& body); + + static Result> + parseAskResponse(const std::string& body, std::string_view preferredSetId); + + static AutoDetectedPrint enrichPrint(AutoDetectedPrint print, + std::string_view pageTitle); + +private: + Result fetchPageImage(std::string_view pageTitle); + + Result> askByName(std::string_view name, + std::string_view setId); + + Result> askByNumber(std::string_view setNo); + + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp new file mode 100644 index 0000000..fe2b337 --- /dev/null +++ b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp @@ -0,0 +1,29 @@ +#pragma once + +// YuGiOhBandaiGameModule: Bandai Carddass via Yugipedia. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp" +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" + +namespace ccm { + +class YuGiOhBandaiGameModule final : public IGameModule { +public: + explicit YuGiOhBandaiGameModule(IHttpClient& http); + + [[nodiscard]] Game id() const noexcept override { return Game::YuGiOhBandai; } + [[nodiscard]] std::string dirName() const override { return "yugiohbandai"; } + [[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; } + + ISetSource& setSource() override { return setSource_; } + ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; } + + YuGiOhBandaiSetSource& bandaiSetSource() noexcept { return setSource_; } + +private: + YuGiOhBandaiSetSource setSource_; + YuGiOhBandaiCardPreviewSource previewSource_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp new file mode 100644 index 0000000..c967125 --- /dev/null +++ b/core/include/ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp @@ -0,0 +1,67 @@ +#pragma once + +// YuGiOhBandaiSetSource: hardcoded Bandai set manifest + Yugipedia gallery +// wikitext catalogs for set completion. + +#include "ccm/domain/Set.hpp" +#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp" +#include "ccm/games/IGameModule.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include +#include + +namespace ccm { + +class YuGiOhBandaiSetSource final : public ISetSource { +public: + struct FetchWithCatalog { + std::vector sets; + YuGiOhBandaiSetCatalog catalog; + }; + + struct SetManifestEntry { + const char* id; + const char* name; + const char* releaseDate; // YYYY/MM/DD + const char* galleryPage; // Yugipedia page title (may be shared) + // For the shared promo gallery: keep cards whose setNo starts with + // this prefix (empty = keep all from that page into this pack). + const char* setNoPrefix; + }; + + explicit YuGiOhBandaiSetSource(IHttpClient& http); + + Result> fetchAll() override; + + Result fetchAllWithCatalog(); + + [[nodiscard]] static const std::vector& setManifest(); + + static Result> parseResponse(const std::string& /*unused*/); + + // Parse one gallery wikitext body into checklist cards. + static Result> + parseGalleryWikitext(const std::string& wikitext); + + // Map a Bandai number string to a set id (ban1/ban2/ban3/promos/sealdass). + static std::string setIdForNumber(std::string_view setNo); + + static std::string setNameForId(std::string_view setId); + + // Normalize printed numbers: strip leading zeros on pure-decimal values; + // uppercase letter prefixes (j1 → J1). Sealdass stays unpadded decimal. + static std::string normalizeCardNumber(std::string_view setNo); + + static std::string expandRarityCode(std::string_view code); + + static std::string buildGalleryParseUrl(std::string_view pageTitle); + + static std::string englishNameFromGalleryTitle(std::string_view pageTitle); + +private: + IHttpClient& http_; +}; + +} // namespace ccm diff --git a/core/include/ccm/ports/ICardPreviewSource.hpp b/core/include/ccm/ports/ICardPreviewSource.hpp index fb9cfe4..f86614f 100644 --- a/core/include/ccm/ports/ICardPreviewSource.hpp +++ b/core/include/ccm/ports/ICardPreviewSource.hpp @@ -19,6 +19,12 @@ namespace ccm { struct AutoDetectedPrint { std::string setNo; std::string rarity; + // Optional fields used by games that resolve set/name/language during + // auto-detect (e.g. Yu-Gi-Oh! Bandai). Existing games leave them empty. + std::string name; + std::string setId; + std::string setName; + std::string language; // Language enum spelling when known ("Japanese" / "English") }; // Classified error returned by ICardPreviewSource::fetchImageUrl. The kind @@ -79,6 +85,18 @@ public: return Result>::err( "Print variant listing not supported by this game."); } + + // Optional lookup by collector / Bandai number (fills name + set + rarity). + virtual Result detectBySetNo(std::string_view /*setNo*/) { + return Result::err( + "Detect-by-number not supported by this game."); + } + + virtual Result> + detectVariantsBySetNo(std::string_view /*setNo*/) { + return Result>::err( + "Detect-by-number variants not supported by this game."); + } }; } // namespace ccm diff --git a/core/include/ccm/services/CardFilter.hpp b/core/include/ccm/services/CardFilter.hpp index 34570fd..2eb3b10 100644 --- a/core/include/ccm/services/CardFilter.hpp +++ b/core/include/ccm/services/CardFilter.hpp @@ -23,6 +23,7 @@ #include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" #include @@ -47,6 +48,10 @@ namespace ccm { [[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view filter); +// Bandai: name, set.name, setNo, rarity, language, condition, amount, note. +[[nodiscard]] bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card, + std::string_view filter); + // Japanese Pokemon mirrors Pokemon searchable columns (includes setNo). [[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card, std::string_view filter); diff --git a/core/include/ccm/services/CardPreviewService.hpp b/core/include/ccm/services/CardPreviewService.hpp index 9fc2d8b..2804209 100644 --- a/core/include/ccm/services/CardPreviewService.hpp +++ b/core/include/ccm/services/CardPreviewService.hpp @@ -81,6 +81,11 @@ public: std::string_view name, std::string_view setId); + Result detectBySetNo(Game game, std::string_view setNo); + + Result> detectVariantsBySetNo(Game game, + std::string_view setNo); + // Download image bytes from a fully-qualified URL without going through // per-game preview-source resolution. Cached by URL (same LRU bound). Result fetchImageBytesByUrl(std::string_view url); diff --git a/core/include/ccm/services/CardSorter.hpp b/core/include/ccm/services/CardSorter.hpp index 637cf12..427db43 100644 --- a/core/include/ccm/services/CardSorter.hpp +++ b/core/include/ccm/services/CardSorter.hpp @@ -20,6 +20,7 @@ #include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" #include @@ -82,6 +83,20 @@ enum class DigiBattle99SortColumn { Note, }; +enum class YuGiOhBandaiSortColumn { + Name, + SetReleaseDate, + SetNo, + Rarity, + Language, + Condition, + Amount, + Holo, + Signed, + Altered, + Note, +}; + // Japanese Pokemon mirrors Pokemon columns. enum class JapanesePokemonSortColumn { Name, @@ -107,6 +122,9 @@ void sortYuGiOhCards(std::vector& cards, YuGiOhSortColumn column, void sortDigiBattle99Cards(std::vector& cards, DigiBattle99SortColumn column, bool ascending); +void sortYuGiOhBandaiCards(std::vector& cards, + YuGiOhBandaiSortColumn column, + bool ascending); void sortJapanesePokemonCards(std::vector& cards, JapanesePokemonSortColumn column, bool ascending); diff --git a/core/include/ccm/services/YuGiOhBandaiSetCatalogService.hpp b/core/include/ccm/services/YuGiOhBandaiSetCatalogService.hpp new file mode 100644 index 0000000..fdf24c3 --- /dev/null +++ b/core/include/ccm/services/YuGiOhBandaiSetCatalogService.hpp @@ -0,0 +1,35 @@ +#pragma once + +// YuGiOhBandaiSetCatalogService: load/save yugiohbandai/set-catalog.json. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp" +#include "ccm/ports/IFileSystem.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/util/Result.hpp" + +#include +#include + +namespace ccm { + +class YuGiOhBandaiSetCatalogService { +public: + using DirNameFn = std::function; + + YuGiOhBandaiSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName); + + Result load() const; + Result save(const YuGiOhBandaiSetCatalog& catalog); + + [[nodiscard]] bool exists() const; + +private: + IFileSystem& fs_; + ConfigService& config_; + DirNameFn dirName_; + + [[nodiscard]] std::filesystem::path catalogPath() const; +}; + +} // namespace ccm diff --git a/core/include/ccm/services/YuGiOhBandaiSetCompletion.hpp b/core/include/ccm/services/YuGiOhBandaiSetCompletion.hpp new file mode 100644 index 0000000..03de4ae --- /dev/null +++ b/core/include/ccm/services/YuGiOhBandaiSetCompletion.hpp @@ -0,0 +1,51 @@ +#pragma once + +// Pure helpers: Bandai set-completion progress and per-set checklists. +// Ownership keys on (set.id, normalized setNo). Never name-only. + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" +#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp" + +#include +#include +#include +#include +#include + +namespace ccm { + +struct YuGiOhBandaiSetCompletionProgress { + std::string setId; + std::string setName; + std::size_t ownedUnique{0}; + std::size_t total{0}; + + [[nodiscard]] int percent() const noexcept { + if (total == 0) return 0; + return static_cast((ownedUnique * 100) / total); + } +}; + +struct YuGiOhBandaiChecklistEntry { + std::string setNo; + std::string name; + std::string rarity; + bool owned{false}; +}; + +[[nodiscard]] std::vector +yuGiOhBandaiLanguagesInCollection(const std::vector& collection); + +[[nodiscard]] std::vector +computeYuGiOhBandaiSetCompletion(const std::vector& collection, + const YuGiOhBandaiSetCatalog& catalog, + std::optional languageFilter = std::nullopt); + +[[nodiscard]] std::vector +yuGiOhBandaiChecklistForSet(const std::vector& collection, + const YuGiOhBandaiSetCatalog& catalog, + std::string_view setId, + std::optional languageFilter = std::nullopt); + +} // namespace ccm diff --git a/core/src/domain/Enums.cpp b/core/src/domain/Enums.cpp index ae6a88e..f0443a2 100644 --- a/core/src/domain/Enums.cpp +++ b/core/src/domain/Enums.cpp @@ -17,6 +17,7 @@ std::string_view to_string(Game g) noexcept { case Game::Pokemon: return "Pokemon"; case Game::YuGiOh: return "YuGiOh"; case Game::DigiBattle99: return "DigiBattle99"; + case Game::YuGiOhBandai: return "YuGiOhBandai"; case Game::JapanesePokemon: return "JapanesePokemon"; } CCM_UNREACHABLE(); @@ -72,6 +73,7 @@ std::optional gameFromString(std::string_view s) noexcept { if (s == "Pokemon") return Game::Pokemon; if (s == "YuGiOh") return Game::YuGiOh; if (s == "DigiBattle99") return Game::DigiBattle99; + if (s == "YuGiOhBandai") return Game::YuGiOhBandai; if (s == "JapanesePokemon") return Game::JapanesePokemon; return std::nullopt; } @@ -115,9 +117,10 @@ std::optional themeFromString(std::string_view s) noexcept { return std::nullopt; } -const std::array& allGames() noexcept { - static constexpr std::array v{ - Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99}; +const std::array& allGames() noexcept { + static constexpr std::array v{ + Game::Magic, Game::Pokemon, Game::YuGiOh, Game::YuGiOhBandai, + Game::DigiBattle99}; return v; } diff --git a/core/src/domain/YuGiOhBandaiCard.cpp b/core/src/domain/YuGiOhBandaiCard.cpp new file mode 100644 index 0000000..8b74fbb --- /dev/null +++ b/core/src/domain/YuGiOhBandaiCard.cpp @@ -0,0 +1,39 @@ +#include "ccm/domain/YuGiOhBandaiCard.hpp" + +namespace ccm { + +void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c) { + j = nlohmann::json{ + {"id", c.id}, + {"amount", c.amount}, + {"name", c.name}, + {"set", c.set}, + {"setNo", c.setNo}, + {"rarity", c.rarity}, + {"note", c.note}, + {"images", c.images}, + {"language", c.language}, + {"condition", c.condition}, + {"holo", c.holo}, + {"signed", c.signed_}, + {"altered", c.altered}, + }; +} + +void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c) { + j.at("id").get_to(c.id); + j.at("amount").get_to(c.amount); + j.at("name").get_to(c.name); + j.at("set").get_to(c.set); + j.at("setNo").get_to(c.setNo); + j.at("rarity").get_to(c.rarity); + j.at("note").get_to(c.note); + j.at("images").get_to(c.images); + j.at("language").get_to(c.language); + j.at("condition").get_to(c.condition); + j.at("holo").get_to(c.holo); + j.at("signed").get_to(c.signed_); + j.at("altered").get_to(c.altered); +} + +} // namespace ccm diff --git a/core/src/domain/YuGiOhBandaiSetCatalog.cpp b/core/src/domain/YuGiOhBandaiSetCatalog.cpp new file mode 100644 index 0000000..f296a79 --- /dev/null +++ b/core/src/domain/YuGiOhBandaiSetCatalog.cpp @@ -0,0 +1,45 @@ +#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp" + +namespace ccm { + +const YuGiOhBandaiSetCatalogPack* YuGiOhBandaiSetCatalog::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 YuGiOhBandaiCatalogCard& c) { + j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}, {"rarity", c.rarity}}; +} + +void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c) { + j.at("setNo").get_to(c.setNo); + j.at("name").get_to(c.name); + if (j.contains("rarity")) { + j.at("rarity").get_to(c.rarity); + } else { + c.rarity.clear(); + } +} + +void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p) { + j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}}; +} + +void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& 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 YuGiOhBandaiSetCatalog& c) { + j = nlohmann::json{{"packs", c.packs}}; +} + +void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c) { + j.at("packs").get_to(c.packs); +} + +} // namespace ccm diff --git a/core/src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp b/core/src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp new file mode 100644 index 0000000..85a2fee --- /dev/null +++ b/core/src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp @@ -0,0 +1,426 @@ +#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp" + +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +namespace { + +using K = PreviewLookupError::Kind; + +std::string trimCopy(std::string_view s) { + while (!s.empty() && + (s.front() == ' ' || s.front() == '\t' || s.front() == '\n' || + s.front() == '\r')) { + s.remove_prefix(1); + } + while (!s.empty() && + (s.back() == ' ' || s.back() == '\t' || s.back() == '\n' || + s.back() == '\r')) { + s.remove_suffix(1); + } + return std::string(s); +} + +std::string urlEncode(std::string_view s) { + static constexpr char hex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(s.size() * 3); + for (unsigned char c : s) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out.push_back(static_cast(c)); + } else if (c == ' ') { + out.push_back('+'); + } else { + out.push_back('%'); + out.push_back(hex[c >> 4]); + out.push_back(hex[c & 0xF]); + } + } + return out; +} + +std::string wikiTitleEncode(std::string_view title) { + // MediaWiki titles use underscores for spaces in the titles= parameter. + std::string s; + s.reserve(title.size()); + for (char c : title) { + s.push_back(c == ' ' ? '_' : c); + } + return urlEncode(s); +} + +bool endsWith(std::string_view s, std::string_view suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +int askMatchRank(std::string_view pageTitle, std::string_view preferredSetId) { + // Lower is better. + if (preferredSetId == "bansealdass") { + if (endsWith(pageTitle, " (Bandai Sealdass)")) return 0; + if (endsWith(pageTitle, " (Bandai)")) return 1; + return 5; + } + if (preferredSetId == "ban3") { + if (endsWith(pageTitle, " (Bandai)")) return 0; + if (endsWith(pageTitle, " (English Bandai)")) return 1; + if (endsWith(pageTitle, " (Bandai Sealdass)")) return 4; + return 5; + } + if (endsWith(pageTitle, " (Bandai)")) return 0; + if (endsWith(pageTitle, " (English Bandai)")) return 1; + if (endsWith(pageTitle, " (Bandai Sealdass)")) return 3; + return 5; +} + +} // namespace + +YuGiOhBandaiCardPreviewSource::YuGiOhBandaiCardPreviewSource(IHttpClient& http) + : http_(http) {} + +std::string YuGiOhBandaiCardPreviewSource::preferredPageTitle( + std::string_view name, + std::string_view setId, + std::string_view setNo) { + const std::string n = trimCopy(name); + if (n.empty()) return {}; + + const std::string num = YuGiOhBandaiSetSource::normalizeCardNumber(setNo); + if (setId == "bansealdass") { + return n + " (Bandai Sealdass)"; + } + // Promo pages on Yugipedia often omit the "(Bandai)" disambiguator + // (e.g. Blue-Eyes White Dragon's 3-Body Connection for TA2). + if (setId == "banpromo-j" || setId == "banpromo-ta" || + isAlphanumericPromoNumber(num)) { + return n; + } + if (num == "118" || setId == "ban3") { + // Prefer JP Bandai page for most ban3 cards; English #118 uses the + // English Bandai title when setNo is 118. + if (num == "118") return n + " (English Bandai)"; + } + return n + " (Bandai)"; +} + +std::string YuGiOhBandaiCardPreviewSource::buildPageImagesUrl( + std::string_view pageTitle) { + return std::string( + "https://yugipedia.com/api.php?action=query&format=json" + "&prop=pageimages&piprop=original&titles=") + + wikiTitleEncode(pageTitle); +} + +std::string YuGiOhBandaiCardPreviewSource::buildAskByNameUrl( + std::string_view englishName) { + // [[Category:Bandai cards]][[English name::]]|?English name|?Bandai number|?Rarity|limit=20 + std::ostringstream q; + q << "[[Category:Bandai cards]][[English name::" << englishName + << "]]|?English name|?Bandai number|?Rarity|limit=20"; + return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") + + urlEncode(q.str()); +} + +std::string YuGiOhBandaiCardPreviewSource::buildAskByNumberUrl( + std::string_view setNo) { + const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo); + std::ostringstream q; + q << "[[Category:Bandai cards]][[Bandai number::" << n + << "]]|?English name|?Bandai number|?Rarity|limit=20"; + return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") + + urlEncode(q.str()); +} + +bool YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber( + std::string_view setNo) { + const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo); + for (unsigned char c : n) { + if (std::isalpha(c)) return true; + } + return false; +} + +Result> +YuGiOhBandaiCardPreviewSource::parsePromoGalleryResponse( + const std::string& body, + std::string_view wantedSetNo) { + using R = Result>; + const std::string want = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo); + if (want.empty()) return R::err("Card number is empty."); + + std::string wikitext; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("parse") || !j.at("parse").contains("wikitext")) { + return R::err("Yugipedia promo gallery response missing parse.wikitext"); + } + wikitext = j.at("parse").at("wikitext").get(); + } catch (const std::exception& e) { + return R::err(std::string("Yugipedia promo gallery JSON parse error: ") + + e.what()); + } + + auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wikitext); + if (!cards) return R::err(cards.error()); + + std::vector out; + for (const auto& card : cards.value()) { + if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo) != want) continue; + AutoDetectedPrint print; + print.name = card.name; + print.setNo = card.setNo; + print.rarity = card.rarity; + print.setId = YuGiOhBandaiSetSource::setIdForNumber(card.setNo); + print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId); + print.language = "Japanese"; + out.push_back(std::move(print)); + } + return R::ok(std::move(out)); +} + +AutoDetectedPrint YuGiOhBandaiCardPreviewSource::enrichPrint( + AutoDetectedPrint print, + std::string_view pageTitle) { + print.name = YuGiOhBandaiSetSource::englishNameFromGalleryTitle(pageTitle); + + if (endsWith(pageTitle, " (Bandai Sealdass)")) { + print.setId = "bansealdass"; + print.language = "Japanese"; + } else if (endsWith(pageTitle, " (English Bandai)")) { + print.setId = "ban3"; + print.language = "English"; + } else { + if (print.setId.empty() && !print.setNo.empty()) { + print.setId = YuGiOhBandaiSetSource::setIdForNumber(print.setNo); + } + print.language = "Japanese"; + } + if (!print.setId.empty()) { + print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId); + } + return print; +} + +Result +YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body) { + using R = Result; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("query") || !j.at("query").contains("pages")) { + return R::err({K::Transient, "Yugipedia pageimages: missing query.pages"}); + } + const auto& pages = j.at("query").at("pages"); + for (auto it = pages.begin(); it != pages.end(); ++it) { + const auto& page = it.value(); + if (page.contains("missing") || page.contains("invalid")) continue; + if (page.contains("original") && page.at("original").contains("source")) { + const auto url = page.at("original").at("source").get(); + if (!url.empty()) return R::ok(url); + } + if (page.contains("thumbnail") && page.at("thumbnail").contains("original")) { + const auto url = page.at("thumbnail").at("original").get(); + if (!url.empty()) return R::ok(url); + } + } + return R::err({K::NotFound, "Yugipedia pageimages: no image for page"}); + } catch (const std::exception& e) { + return R::err({K::Transient, + std::string("Yugipedia pageimages JSON parse error: ") + e.what()}); + } +} + +Result> +YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body, + std::string_view preferredSetId) { + using R = Result>; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("query") || !j.at("query").contains("results")) { + return R::err("Yugipedia ask: missing query.results"); + } + const auto& results = j.at("query").at("results"); + if (!results.is_object() || results.empty()) { + return R::ok({}); + } + + std::vector> ranked; + for (auto it = results.begin(); it != results.end(); ++it) { + const std::string pageTitle = it.key(); + const auto& printouts = it.value().value("printouts", nlohmann::json::object()); + + AutoDetectedPrint print; + if (printouts.contains("Bandai number") && + printouts.at("Bandai number").is_array() && + !printouts.at("Bandai number").empty()) { + const auto& num = printouts.at("Bandai number").at(0); + if (num.is_number_integer()) { + print.setNo = YuGiOhBandaiSetSource::normalizeCardNumber( + std::to_string(num.get())); + } else if (num.is_string()) { + print.setNo = + YuGiOhBandaiSetSource::normalizeCardNumber(num.get()); + } + } + if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() && + !printouts.at("Rarity").empty()) { + const auto& rar = printouts.at("Rarity").at(0); + if (rar.is_object() && rar.contains("fulltext")) { + print.rarity = rar.at("fulltext").get(); + } else if (rar.is_string()) { + print.rarity = rar.get(); + } + } + if (printouts.contains("English name") && + printouts.at("English name").is_array() && + !printouts.at("English name").empty()) { + print.name = printouts.at("English name").at(0).get(); + } + + print = enrichPrint(std::move(print), pageTitle); + if (print.name.empty()) continue; + ranked.emplace_back(askMatchRank(pageTitle, preferredSetId), std::move(print)); + } + + std::sort(ranked.begin(), ranked.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + + std::vector out; + out.reserve(ranked.size()); + for (auto& [rank, print] : ranked) { + (void)rank; + out.push_back(std::move(print)); + } + return R::ok(std::move(out)); + } catch (const std::exception& e) { + return R::err(std::string("Yugipedia ask JSON parse error: ") + e.what()); + } +} + +Result +YuGiOhBandaiCardPreviewSource::fetchPageImage(std::string_view pageTitle) { + using R = Result; + if (pageTitle.empty()) { + return R::err({K::NotFound, "Empty Bandai page title"}); + } + const std::string url = buildPageImagesUrl(pageTitle); + auto resp = http_.get(url); + if (!resp) return R::err({K::Transient, resp.error()}); + return parsePageImagesResponse(resp.value()); +} + +Result> YuGiOhBandaiCardPreviewSource::askByName( + std::string_view name, + std::string_view setId) { + using R = Result>; + const std::string n = trimCopy(name); + if (n.empty()) return R::err("Card name is empty."); + const std::string url = buildAskByNameUrl(n); + auto resp = http_.get(url); + if (!resp) return R::err(resp.error()); + return parseAskResponse(resp.value(), setId); +} + +Result> YuGiOhBandaiCardPreviewSource::askByNumber( + std::string_view setNo) { + using R = Result>; + const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo); + if (n.empty()) return R::err("Card number is empty."); + + // Promo codes (J1, TA2, …) are not valid values for SMW's numeric + // `Bandai number` property — ask returns a type error. Resolve them from + // the promotional set gallery instead. + if (isAlphanumericPromoNumber(n)) { + static constexpr const char* kPromoGallery = + "Set Card Galleries:Promotional Cards (Bandai)"; + const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery); + auto resp = http_.get(url); + if (!resp) return R::err(resp.error()); + return parsePromoGalleryResponse(resp.value(), n); + } + + const std::string url = buildAskByNumberUrl(n); + auto resp = http_.get(url); + if (!resp) return R::err(resp.error()); + return parseAskResponse(resp.value(), {}); +} + +Result +YuGiOhBandaiCardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) { + using R = Result; + + const std::string title = preferredPageTitle(name, setId, setNo); + auto direct = fetchPageImage(title); + if (direct) return direct; + // Try English Bandai if JP page missed for #118. + if (YuGiOhBandaiSetSource::normalizeCardNumber(setNo) == "118") { + auto en = fetchPageImage(trimCopy(name) + " (English Bandai)"); + if (en) return en; + } + + // Fall back to SMW ask by name, then pageimages on the best hit. + auto variants = askByName(name, setId); + if (!variants) { + // Prefer the original NotFound if ask also failed transiently only + // after a clean miss; otherwise surface ask error as Transient. + if (direct.error().kind == K::NotFound) { + return R::err({K::Transient, variants.error()}); + } + return direct; + } + if (variants.value().empty()) { + return R::err({K::NotFound, "No Bandai card matched the name"}); + } + + const auto& best = variants.value().front(); + std::string askTitle = preferredPageTitle(best.name, best.setId, best.setNo); + if (best.language == "English") { + askTitle = best.name + " (English Bandai)"; + } else if (best.setId == "bansealdass") { + askTitle = best.name + " (Bandai Sealdass)"; + } + return fetchPageImage(askTitle); +} + +Result YuGiOhBandaiCardPreviewSource::detectFirstPrint( + std::string_view name, + std::string_view setId) { + auto list = detectPrintVariants(name, setId); + if (!list) return Result::err(list.error()); + if (list.value().empty()) { + return Result::err("Could not auto-detect Bandai print metadata."); + } + return Result::ok(list.value().front()); +} + +Result> +YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name, + std::string_view setId) { + return askByName(name, setId); +} + +Result YuGiOhBandaiCardPreviewSource::detectBySetNo( + std::string_view setNo) { + auto list = detectVariantsBySetNo(setNo); + if (!list) return Result::err(list.error()); + if (list.value().empty()) { + return Result::err( + "Could not auto-detect Bandai card from number."); + } + return Result::ok(list.value().front()); +} + +Result> +YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setNo) { + return askByNumber(setNo); +} + +} // namespace ccm diff --git a/core/src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp b/core/src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp new file mode 100644 index 0000000..74b9239 --- /dev/null +++ b/core/src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp @@ -0,0 +1,8 @@ +#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp" + +namespace ccm { + +YuGiOhBandaiGameModule::YuGiOhBandaiGameModule(IHttpClient& http) + : setSource_(http), previewSource_(http) {} + +} // namespace ccm diff --git a/core/src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp b/core/src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp new file mode 100644 index 0000000..db45f81 --- /dev/null +++ b/core/src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp @@ -0,0 +1,267 @@ +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" + +#include + +#include +#include +#include +#include + +namespace ccm { + +namespace { + +std::string trimCopy(std::string_view s) { + while (!s.empty() && + (s.front() == ' ' || s.front() == '\t' || s.front() == '\n' || + s.front() == '\r')) { + s.remove_prefix(1); + } + while (!s.empty() && + (s.back() == ' ' || s.back() == '\t' || s.back() == '\n' || + s.back() == '\r')) { + s.remove_suffix(1); + } + return std::string(s); +} + +std::string urlEncode(std::string_view s) { + static constexpr char hex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(s.size() * 3); + for (unsigned char c : s) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + out.push_back(static_cast(c)); + } else if (c == ' ') { + out.push_back('+'); + } else { + out.push_back('%'); + out.push_back(hex[c >> 4]); + out.push_back(hex[c & 0xF]); + } + } + return out; +} + +} // namespace + +YuGiOhBandaiSetSource::YuGiOhBandaiSetSource(IHttpClient& http) : http_(http) {} + +const std::vector& +YuGiOhBandaiSetSource::setManifest() { + static const std::vector kManifest{ + {"ban1", "1st Generation", "1998/09/01", + "Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation", ""}, + {"ban2", "2nd Generation", "1998/11/01", + "Set Card Galleries:2nd Generation (Bandai)", ""}, + {"ban3", "3rd Generation", "1999/03/06", + "Set Card Galleries:3rd Generation (Bandai)", ""}, + {"banpromo-j", "Jump Promos", "1998/01/01", + "Set Card Galleries:Promotional Cards (Bandai)", "J"}, + {"banpromo-ta", "Toei Promos", "1999/03/06", + "Set Card Galleries:Promotional Cards (Bandai)", "TA"}, + {"bansealdass", "Sealdass", "1999/06/01", + "Set Card Galleries:Yu-Gi-Oh! Bandai Sealdass", ""}, + }; + return kManifest; +} + +Result> YuGiOhBandaiSetSource::parseResponse( + const std::string& /*unused*/) { + std::vector out; + for (const auto& e : setManifest()) { + out.push_back(Set{e.id, e.name, e.releaseDate}); + } + return Result>::ok(std::move(out)); +} + +Result> YuGiOhBandaiSetSource::fetchAll() { + return parseResponse({}); +} + +std::string YuGiOhBandaiSetSource::buildGalleryParseUrl(std::string_view pageTitle) { + return std::string( + "https://yugipedia.com/api.php?action=parse&format=json&formatversion=2" + "&prop=wikitext&page=") + + urlEncode(pageTitle); +} + +std::string YuGiOhBandaiSetSource::normalizeCardNumber(std::string_view setNo) { + std::string s = trimCopy(setNo); + if (s.empty()) return {}; + + // Strip a leading '#' if present. + if (s.front() == '#') s.erase(s.begin()); + + // Uppercase letter prefix forms: j1 / ta2. + bool hasAlpha = false; + for (char& c : s) { + if (std::isalpha(static_cast(c))) { + hasAlpha = true; + c = static_cast(std::toupper(static_cast(c))); + } + } + if (hasAlpha) return s; + + // Pure decimal: strip leading zeros but keep a single zero. + std::size_t i = 0; + while (i + 1 < s.size() && s[i] == '0') ++i; + return s.substr(i); +} + +std::string YuGiOhBandaiSetSource::expandRarityCode(std::string_view code) { + const std::string c = trimCopy(code); + if (c == "C") return "Common"; + if (c == "R") return "Rare"; + if (c == "SR") return "Super Rare"; + if (c == "UR") return "Ultra Rare"; + if (c == "HFR" || c == "Holo Seal" || c == "HS") return "Holo Seal"; + if (c.empty()) return {}; + return c; +} + +std::string YuGiOhBandaiSetSource::englishNameFromGalleryTitle( + std::string_view pageTitle) { + std::string name = trimCopy(pageTitle); + const auto stripSuffix = [&](std::string_view suffix) { + if (name.size() > suffix.size() && + name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) { + name.resize(name.size() - suffix.size()); + name = trimCopy(name); + } + }; + stripSuffix(" (Bandai Sealdass)"); + stripSuffix(" (English Bandai)"); + stripSuffix(" (Bandai)"); + return name; +} + +std::string YuGiOhBandaiSetSource::setIdForNumber(std::string_view setNo) { + const std::string n = normalizeCardNumber(setNo); + if (n.empty()) return {}; + if (!n.empty() && (n[0] == 'J' || n[0] == 'j')) return "banpromo-j"; + if (n.size() >= 2 && (n[0] == 'T' || n[0] == 't') && + (n[1] == 'A' || n[1] == 'a')) { + return "banpromo-ta"; + } + + // Pure decimal → generation by range. Callers that need Sealdass must + // pass set context; number alone cannot disambiguate 1–42 vs Sealdass. + bool pureDecimal = true; + for (char c : n) { + if (!std::isdigit(static_cast(c))) { + pureDecimal = false; + break; + } + } + if (!pureDecimal) return {}; + + const int v = std::stoi(n); + if (v >= 1 && v <= 42) return "ban1"; + if (v >= 43 && v <= 88) return "ban2"; + if (v >= 89 && v <= 118) return "ban3"; + return {}; +} + +std::string YuGiOhBandaiSetSource::setNameForId(std::string_view setId) { + for (const auto& e : setManifest()) { + if (e.id == setId) return e.name; + } + return {}; +} + +Result> +YuGiOhBandaiSetSource::parseGalleryWikitext(const std::string& wikitext) { + using R = Result>; + std::vector out; + + // Generation galleries (raw): + // … | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|ja}} + // Promo galleries (often expanded with
): + // … | [[TA2]] ([[SR]])
{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}} + static const std::regex kLine( + R"((?:\{\{pound\}\}|\[\[)([A-Za-z0-9]+)(?:\]\])?(?:\s*\(\[\[([A-Za-z0-9]+)\]\]\))?[^\n]*?\{\{Gallery card names\|([^}|]+))", + std::regex::ECMAScript); + + std::unordered_set seen; + for (std::sregex_iterator it(wikitext.begin(), wikitext.end(), kLine), end; + it != end; ++it) { + const std::smatch& m = *it; + YuGiOhBandaiCatalogCard card; + card.setNo = normalizeCardNumber(m[1].str()); + if (card.setNo.empty()) continue; + if (m[2].matched) { + card.rarity = expandRarityCode(m[2].str()); + } + card.name = englishNameFromGalleryTitle(m[3].str()); + if (card.name.empty()) continue; + if (!seen.insert(card.setNo).second) continue; + out.push_back(std::move(card)); + } + + return R::ok(std::move(out)); +} + +Result +YuGiOhBandaiSetSource::fetchAllWithCatalog() { + using R = Result; + + auto sets = parseResponse({}); + if (!sets) return R::err(sets.error()); + + YuGiOhBandaiSetCatalog catalog; + std::unordered_map pageCache; + + for (const auto& entry : setManifest()) { + const std::string page = entry.galleryPage; + std::string body; + auto cached = pageCache.find(page); + if (cached != pageCache.end()) { + body = cached->second; + } else { + const std::string url = buildGalleryParseUrl(page); + auto resp = http_.get(url); + if (!resp) return R::err(resp.error()); + body = std::move(resp).value(); + pageCache.emplace(page, body); + } + + std::string wikitext; + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("parse") || !j.at("parse").contains("wikitext")) { + return R::err("Yugipedia gallery response missing parse.wikitext"); + } + wikitext = j.at("parse").at("wikitext").get(); + } catch (const std::exception& e) { + return R::err(std::string("Yugipedia gallery JSON parse error: ") + + e.what()); + } + + auto cards = parseGalleryWikitext(wikitext); + if (!cards) return R::err(cards.error()); + + YuGiOhBandaiSetCatalogPack pack; + pack.setId = entry.id; + pack.setName = entry.name; + const std::string prefix = entry.setNoPrefix; + for (const auto& card : cards.value()) { + if (!prefix.empty()) { + if (card.setNo.size() < prefix.size() || + card.setNo.compare(0, prefix.size(), prefix) != 0) { + continue; + } + } + pack.cards.push_back(card); + } + + catalog.packs.push_back(std::move(pack)); + } + + FetchWithCatalog out; + out.sets = std::move(sets).value(); + out.catalog = std::move(catalog); + return R::ok(std::move(out)); +} + +} // namespace ccm diff --git a/core/src/services/CardFilter.cpp b/core/src/services/CardFilter.cpp index 17a33c6..5596919 100644 --- a/core/src/services/CardFilter.cpp +++ b/core/src/services/CardFilter.cpp @@ -83,6 +83,22 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi return false; } +bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card, std::string_view filter) { + if (filter.empty()) return true; + + const std::string needle = asciiLower(filter); + + if (containsLower(card.name, needle)) return true; + if (containsLower(card.set.name, needle)) return true; + if (containsLower(card.setNo, needle)) return true; + if (containsLower(card.rarity, needle)) return true; + if (containsLower(to_string(card.language), needle)) return true; + if (containsLower(to_string(card.condition), needle)) return true; + if (containsLower(std::to_string(card.amount), needle)) return true; + if (containsLower(card.note, needle)) return true; + return false; +} + bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card, std::string_view filter) { if (filter.empty()) return true; diff --git a/core/src/services/CardPreviewService.cpp b/core/src/services/CardPreviewService.cpp index c983e5d..8761cb6 100644 --- a/core/src/services/CardPreviewService.cpp +++ b/core/src/services/CardPreviewService.cpp @@ -262,6 +262,33 @@ Result> CardPreviewService::detectPrintVariants( return it->second->detectPrintVariants(name, setId); } +Result CardPreviewService::detectBySetNo(Game game, + std::string_view setNo) { + auto it = sources_.find(game); + if (it == sources_.end() || it->second == nullptr) { + return Result::err("No preview source registered for this game."); + } + if (!it->second->supportsAutoDetectPrint()) { + return Result::err("Auto-detect not enabled for this game."); + } + return it->second->detectBySetNo(setNo); +} + +Result> CardPreviewService::detectVariantsBySetNo( + Game game, + std::string_view setNo) { + auto it = sources_.find(game); + if (it == sources_.end() || it->second == nullptr) { + return Result>::err( + "No preview source registered for this game."); + } + if (!it->second->supportsAutoDetectPrint()) { + return Result>::err( + "Auto-detect not enabled for this game."); + } + return it->second->detectVariantsBySetNo(setNo); +} + Result CardPreviewService::fetchImageBytesByUrl(std::string_view url) { // The by-URL path is used for fixed per-game card-back fallback images. // A failure there is always transient (the URL itself is constant), so diff --git a/core/src/services/CardSorter.cpp b/core/src/services/CardSorter.cpp index 4cd194d..3470df3 100644 --- a/core/src/services/CardSorter.cpp +++ b/core/src/services/CardSorter.cpp @@ -293,6 +293,79 @@ void sortDigiBattle99Cards(std::vector& cards, } } +void sortYuGiOhBandaiCards(std::vector& cards, + YuGiOhBandaiSortColumn column, + bool ascending) { + switch (column) { + case YuGiOhBandaiSortColumn::Name: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return asciiLower(a.name) < asciiLower(b.name); + }, ascending)); + break; + case YuGiOhBandaiSortColumn::SetReleaseDate: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate); + }, ascending)); + break; + case YuGiOhBandaiSortColumn::SetNo: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return asciiLower(a.setNo) < asciiLower(b.setNo); + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Rarity: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return asciiLower(a.rarity) < asciiLower(b.rarity); + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Language: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language)); + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Condition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition)); + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Amount: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return a.amount < b.amount; + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Holo: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return a.holo < b.holo; + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Signed: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return a.signed_ < b.signed_; + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Altered: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return a.altered < b.altered; + }, ascending)); + break; + case YuGiOhBandaiSortColumn::Note: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) { + return asciiLower(a.note) < asciiLower(b.note); + }, ascending)); + break; + } +} + void sortJapanesePokemonCards(std::vector& cards, JapanesePokemonSortColumn column, bool ascending) { diff --git a/core/src/services/YuGiOhBandaiSetCatalogService.cpp b/core/src/services/YuGiOhBandaiSetCatalogService.cpp new file mode 100644 index 0000000..e63836a --- /dev/null +++ b/core/src/services/YuGiOhBandaiSetCatalogService.cpp @@ -0,0 +1,50 @@ +#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp" + +#include + +#include + +namespace ccm { + +namespace fs = std::filesystem; + +YuGiOhBandaiSetCatalogService::YuGiOhBandaiSetCatalogService(IFileSystem& fs, + ConfigService& config, + DirNameFn dirName) + : fs_(fs), config_(config), dirName_(std::move(dirName)) {} + +fs::path YuGiOhBandaiSetCatalogService::catalogPath() const { + return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOhBandai) / + "set-catalog.json"; +} + +bool YuGiOhBandaiSetCatalogService::exists() const { + return fs_.exists(catalogPath()); +} + +Result YuGiOhBandaiSetCatalogService::load() const { + const auto p = catalogPath(); + if (!fs_.exists(p)) { + return Result::err( + "Yu-Gi-Oh! (Bandai) set catalog not yet downloaded."); + } + auto text = fs_.readText(p); + if (!text) return Result::err(text.error()); + try { + const auto j = nlohmann::json::parse(text.value()); + return Result::ok(j.get()); + } catch (const std::exception& e) { + return Result::err( + std::string("set-catalog.json parse error: ") + e.what()); + } +} + +Result YuGiOhBandaiSetCatalogService::save(const YuGiOhBandaiSetCatalog& catalog) { + const auto p = catalogPath(); + auto dir = fs_.ensureDirectory(p.parent_path()); + if (!dir) return dir; + const nlohmann::json j = catalog; + return fs_.writeText(p, j.dump(2)); +} + +} // namespace ccm diff --git a/core/src/services/YuGiOhBandaiSetCompletion.cpp b/core/src/services/YuGiOhBandaiSetCompletion.cpp new file mode 100644 index 0000000..c35c3af --- /dev/null +++ b/core/src/services/YuGiOhBandaiSetCompletion.cpp @@ -0,0 +1,128 @@ +#include "ccm/services/YuGiOhBandaiSetCompletion.hpp" + +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" + +#include +#include +#include +#include + +namespace ccm { + +namespace { + +using OwnedBySet = std::unordered_map>; + +bool passesLanguageFilter(const YuGiOhBandaiCard& card, + std::optional languageFilter) { + return !languageFilter.has_value() || card.language == *languageFilter; +} + +OwnedBySet ownedSetNosBySetId(const std::vector& collection, + std::optional languageFilter) { + OwnedBySet out; + for (const auto& card : collection) { + if (!passesLanguageFilter(card, languageFilter)) continue; + if (card.set.id.empty()) continue; + const std::string setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo); + if (setNo.empty()) continue; + out[card.set.id].insert(setNo); + } + return out; +} + +} // namespace + +std::vector +yuGiOhBandaiLanguagesInCollection(const std::vector& collection) { + const auto& langs = allLanguages(); + std::array 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 out; + for (std::size_t i = 0; i < langs.size(); ++i) { + if (present[i]) out.push_back(langs[i]); + } + return out; +} + +std::vector +computeYuGiOhBandaiSetCompletion(const std::vector& collection, + const YuGiOhBandaiSetCatalog& catalog, + std::optional languageFilter) { + const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter); + + std::vector out; + out.reserve(owned.size()); + + for (const auto& [setId, ownedNos] : owned) { + const auto* pack = catalog.findPack(setId); + if (pack == nullptr || pack->cards.empty()) continue; + + std::size_t matched = 0; + for (const auto& card : pack->cards) { + const std::string catalogNo = + YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo); + if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched; + } + + YuGiOhBandaiSetCompletionProgress 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 YuGiOhBandaiSetCompletionProgress& a, + const YuGiOhBandaiSetCompletionProgress& b) { + return a.setName < b.setName; + }); + return out; +} + +std::vector +yuGiOhBandaiChecklistForSet(const std::vector& collection, + const YuGiOhBandaiSetCatalog& catalog, + std::string_view setId, + std::optional languageFilter) { + const auto* pack = catalog.findPack(setId); + if (pack == nullptr) return {}; + + std::unordered_set ownedNos; + for (const auto& card : collection) { + if (!passesLanguageFilter(card, languageFilter)) continue; + if (card.set.id != setId) continue; + const std::string setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo); + if (!setNo.empty()) ownedNos.insert(setNo); + } + + std::vector out; + out.reserve(pack->cards.size()); + for (const auto& card : pack->cards) { + YuGiOhBandaiChecklistEntry entry; + entry.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo); + entry.name = card.name; + entry.rarity = card.rarity; + entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0; + out.push_back(std::move(entry)); + } + + std::sort(out.begin(), out.end(), + [](const YuGiOhBandaiChecklistEntry& a, + const YuGiOhBandaiChecklistEntry& b) { + if (a.setNo != b.setNo) return a.setNo < b.setNo; + return a.name < b.name; + }); + return out; +} + +} // namespace ccm diff --git a/docs/AGENTS.md b/docs/AGENTS.md index a300196..efb07b0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -11,13 +11,13 @@ Long-form contributor documentation that lives outside the source tree. - `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting. - `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs. - `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests. -- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon (West + Asia backends), Yu-Gi-Oh!, and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), the Japanese Pokémon EN catalog asset (Asia region), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP). +- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon (West + Asia backends), Yu-Gi-Oh!, Yu-Gi-Oh! (Bandai), and Digimon Digi-Battle modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png` / `digibattle99_card_back.png`), the Japanese Pokémon EN catalog asset (Asia region), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP). - `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching). - `README.md` — index page that clusters docs by area and links to all documents in this directory. ## Subdirectories -- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats. +- `assets/images/` — static screenshots and other binary assets referenced from the documentation (currently `demo-mtg.png`, `demo-pkm.png`, `demo-ygo.png`, `demo-ygo-bandai.png`, `demo-digibattle99.png`). Keep filenames stable so cross-doc links don't break, and prefer compressed PNG/JPEG over uncompressed formats. ## Conventions @@ -28,7 +28,7 @@ Long-form contributor documentation that lives outside the source tree. ## Required follow-ups - After changing per-game seams in `core/` (e.g. `IGameModule`, `ISetSource`, `ICardPreviewSource`, `CollectionService`, `SetService`, `CardPreviewService`, `ImageService`) you **must** update `adding-a-new-game.md` to keep the canonical procedure in sync. The same applies to the UI seams (`IGameView`, `BaseCardListPanel`, `BaseCardEditDialog`, `BaseSelectedCardPanel`) and the composition-root wiring in `app/main.cpp`. -- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior. +- After changing any game's set/preview adapters (`MagicSetSource`, `MagicCardPreviewSource`, `PokemonSetSource`, `PokemonCardPreviewSource`, `YuGiOhSetSource`, `YuGiOhCardPreviewSource`, `YuGiOhBandaiSetSource`, `YuGiOhBandaiCardPreviewSource`, `DigiBattle99SetSource`, `DigiBattle99CardPreviewSource`) — endpoints, response parsing, name/number normalization, or the info-vs-asset split — you **must** update `assets-and-info-apis.md` so the API reference matches the live behavior. - After bumping a key dependency (`nlohmann/json`, `cpr`, `wxWidgets`, `doctest`) in a way that changes a public API used in the guide's examples, update those examples. - After adding a new file under `docs/` (or a new entry under `docs/assets/images/`) you **must** add it to the file list above **and** to `README.md` so the index stays complete. - Do **not** rename, move, or split this file without first updating every other `AGENTS.md` that points at it (root, `core/`, `ui_wx/`, `app/`, `tests/`). diff --git a/docs/adding-a-new-game.md b/docs/adding-a-new-game.md index ddd7307..038a7ac 100644 --- a/docs/adding-a-new-game.md +++ b/docs/adding-a-new-game.md @@ -141,7 +141,7 @@ Mirror `core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp`. The header - `static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);` - `static Result parseResponse(const std::string& body);` -If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Keep both optional per game — default behavior should remain an explicit unsupported error. +If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to `ICardPreviewSource::detectFirstPrint(...)` and route it via `CardPreviewService::detectFirstPrint(...)`. If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate `set_code` or `set_rarity` values), also override `ICardPreviewSource::detectPrintVariants(...)` and expose it through `CardPreviewService::detectPrintVariants(...)`. Games that can resolve metadata from a collector / Bandai number alone (Yu-Gi-Oh! Bandai) should also override `detectBySetNo(...)` / `detectVariantsBySetNo(...)` and wire them through `CardPreviewService`. `AutoDetectedPrint` carries `setNo` + `rarity` for every game; optional `name` / `setId` / `setName` / `language` fields stay empty when unused. Keep all of these optional per game — default behavior should remain an explicit unsupported error. Both `buildSearchUrl` and `parseResponse` are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover: @@ -325,6 +325,7 @@ Derive from `BaseCardEditDialog<Card>`. Override: - `readExtraFromCard()` — copy fields from `constCard()` into your widgets. - `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`. - `updateMenuName()` — return `"Update "`. This is what the dialog's "no sets cached" hint shows the user. +- `validateExtraFields()` — optional; called from OK after name/set checks. Return `false` to block save (show your own themed dialog). Yu-Gi-Oh! (Bandai) requires a non-empty set number here. Optional `BaseCardEditDialog` extension points (defaults keep a single read-only set combo in the **Set** row): diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index bcb61e8..f9ab71d 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -109,6 +109,37 @@ Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `L If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!. +## Yu-Gi-Oh! (Bandai) APIs (Yugipedia) + +Bandai Carddass (pre-Konami) is wired as `Game::YuGiOhBandai` (`dirName` `yugiohbandai`, UI label **Yu-Gi-Oh! (Bandai)**). There is no dedicated Bandai REST API; everything goes through Yugipedia MediaWiki + Semantic MediaWiki. + +### Info API (sets + catalog) + +`YuGiOhBandaiSetSource` keeps an **app-owned set manifest** (stable ids, no fragile category scrape): + +| id | Name | Numbers | +|---|---|---| +| `ban1` | 1st Generation | 1–42 | +| `ban2` | 2nd Generation | 43–88 | +| `ban3` | 3rd Generation | 89–118 | +| `banpromo-j` | Jump Promos | J1–J3 | +| `banpromo-ta` | Toei Promos | TA1–TA2 | +| `bansealdass` | Sealdass | 1–42 | + +`fetchAll()` returns that manifest (offline — no HTTP). `fetchAllWithCatalog()` additionally `GET`s each set’s Yugipedia gallery page via `action=parse&prop=wikitext` and parses lines like `… | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|…}}` into checklist entries `{setNo, name, rarity}` (rarity codes `C`/`R`/`SR` → Common/Rare/Super Rare). The shared promo gallery is split by `setNo` prefix (`J*` vs `TA*`). Persisted at `yugiohbandai/set-catalog.json`. + +**Set Completion** ownership keys on `(set.id, normalized setNo)`. Because `fetchAll()` is offline, Add/Edit can work before any catalog download; the catalog is filled on the first visit to the Set Completion tab (or via **Sets → Update Yu-Gi-Oh! (Bandai)**). Cards without a set number do not count toward progress. + +English Blue-Eyes is **not** a separate set — it is `ban3` card `#118` with language English. + +### Asset API (preview + auto-detect) + +1. **Preview:** `pageimages` on preferred titles `Name (Bandai)` / `Name (English Bandai)` / `Name (Bandai Sealdass)`, falling back to SMW `ask` by English name then `pageimages` on the best hit. +2. **Auto-detect by name:** SMW `ask` `[[Category:Bandai cards]][[English name::…]]` → fills `name`, `setId`/`setName`, `setNo`, `rarity`, `language`. +3. **Auto-detect by number:** SMW `ask` `[[Bandai number::…]]` → same fields. + +Card-back fallback URL: `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`. + ## 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). @@ -315,6 +346,7 @@ Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match C - Pokémon: `https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg` - Japanese Pokémon: `https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg` - Yu-Gi-Oh!: Yugipedia English TCG back — try `https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png`, then `https://ms.yugipedia.com/e/e5/Back-EN.png`; if both fail, load `/assets/ygo_card_back.png` (shipped from `ui_wx/assets/ygo_card_back.png` at link time). `fallbackImageUrlForGame(Game::YuGiOh)` returns the thumbnail URL for helpers that only consult a single string. +- Yu-Gi-Oh! (Bandai): `https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png`. - Digimon (Digi-Battle): no stable public back URL; load `/assets/digibattle99_card_back.png` (shipped from `ui_wx/assets/digibattle99_card_back.png` at link time). If a game module does not provide a preview source (`cardPreviewSource() == nullptr`), preview registration is skipped and the UI behaves as "no remote preview API available." diff --git a/docs/assets/images/demo-ygo-bandai.png b/docs/assets/images/demo-ygo-bandai.png new file mode 100644 index 0000000..83953bf Binary files /dev/null and b/docs/assets/images/demo-ygo-bandai.png differ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4166afa..d2b7404 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -26,6 +26,9 @@ add_executable(ccm_core_tests digibattle99_set_source_tests.cpp digibattle99_card_preview_source_tests.cpp digibattle99_set_completion_tests.cpp + yugiohbandai_set_source_tests.cpp + yugiohbandai_card_preview_source_tests.cpp + yugiohbandai_set_completion_tests.cpp yugioh_set_completion_tests.cpp pokemon_set_completion_tests.cpp set_no_natural_tests.cpp diff --git a/tests/card_filter_tests.cpp b/tests/card_filter_tests.cpp index d976e5b..3d252de 100644 --- a/tests/card_filter_tests.cpp +++ b/tests/card_filter_tests.cpp @@ -10,6 +10,7 @@ #include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" #include "ccm/services/CardFilter.hpp" @@ -287,6 +288,39 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") { } } +TEST_SUITE("CardFilter::matchesYuGiOhBandaiFilter") { + TEST_CASE("matches name set setNo rarity language") { + YuGiOhBandaiCard c; + c.name = "Dark Magician"; + c.set.name = "1st Generation"; + c.setNo = "14"; + c.rarity = "Rare"; + c.language = Language::Japanese; + CHECK(matchesYuGiOhBandaiFilter(c, "magician")); + CHECK(matchesYuGiOhBandaiFilter(c, "1st")); + CHECK(matchesYuGiOhBandaiFilter(c, "14")); + CHECK(matchesYuGiOhBandaiFilter(c, "rare")); + CHECK(matchesYuGiOhBandaiFilter(c, "japanese")); + CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "blue-eyes")); + } + + TEST_CASE("empty filter matches everything") { + YuGiOhBandaiCard c; + c.name = "Dark Magician"; + CHECK(matchesYuGiOhBandaiFilter(c, "")); + } + + TEST_CASE("boolean flag columns are not matched") { + YuGiOhBandaiCard c; + c.name = "Dark Magician"; + c.holo = true; + c.signed_ = true; + c.altered = true; + CHECK_FALSE(matchesYuGiOhBandaiFilter(c, "true")); + CHECK(matchesYuGiOhBandaiFilter(c, "dark")); + } +} + TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") { TEST_CASE("matches by name and set.name") { JapanesePokemonCard c; diff --git a/tests/card_sorter_tests.cpp b/tests/card_sorter_tests.cpp index 7a63836..111612e 100644 --- a/tests/card_sorter_tests.cpp +++ b/tests/card_sorter_tests.cpp @@ -10,6 +10,7 @@ #include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" #include "ccm/domain/Set.hpp" #include "ccm/services/CardSorter.hpp" @@ -548,6 +549,51 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") { } } +TEST_SUITE("CardSorter - YuGiOhBandai columns") { + TEST_CASE("Holo sorts false before true; Rarity and SetNo sort") { + YuGiOhBandaiCard a; + a.id = 1; + a.name = "a"; + a.set = Set{"ban1", "1st Generation", "1998/09/01"}; + a.setNo = "14"; + a.rarity = "Rare"; + a.holo = true; + + YuGiOhBandaiCard b; + b.id = 2; + b.name = "b"; + b.set = Set{"ban1", "1st Generation", "1998/09/01"}; + b.setNo = "9"; + b.rarity = "Common"; + b.holo = false; + + std::vector v = {a, b}; + sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Holo, /*ascending=*/true); + CHECK(v[0].id == 2); + CHECK(v[1].id == 1); + + sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetNo, /*ascending=*/true); + CHECK(v[0].setNo == "14"); + CHECK(v[1].setNo == "9"); + + sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::Rarity, /*ascending=*/true); + CHECK(v[0].rarity == "Common"); + } + + TEST_CASE("Set column sorts by release date") { + YuGiOhBandaiCard a; + a.id = 1; + a.set = Set{"ban3", "3rd Generation", "1999/03/06"}; + YuGiOhBandaiCard b; + b.id = 2; + b.set = Set{"ban1", "1st Generation", "1998/09/01"}; + std::vector v = {a, b}; + sortYuGiOhBandaiCards(v, YuGiOhBandaiSortColumn::SetReleaseDate, /*ascending=*/true); + CHECK(v[0].id == 2); + CHECK(v[1].id == 1); + } +} + TEST_SUITE("CardSorter - JapanesePokemon columns") { TEST_CASE("Holo and FirstEdition sort false before true") { std::vector v = { diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index 4be6b40..6d03435 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -9,6 +9,8 @@ #include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" +#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp" #include "ccm/domain/YuGiOhCard.hpp" #include "ccm/domain/Set.hpp" @@ -31,6 +33,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") { nlohmann::json jDigi = "DigiBattle99"; CHECK(jDigi.get() == Game::DigiBattle99); + nlohmann::json jBandai = "YuGiOhBandai"; + CHECK(jBandai.get() == Game::YuGiOhBandai); + nlohmann::json jJp = "JapanesePokemon"; CHECK(jJp.get() == Game::JapanesePokemon); @@ -77,7 +82,7 @@ TEST_SUITE("domain enums round-trip JSON as strings") { for (const auto game : allGames()) { CHECK(game != Game::JapanesePokemon); } - CHECK(allGames().size() == 4); + CHECK(allGames().size() == 5); CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon); CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon); CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon); @@ -312,6 +317,87 @@ TEST_SUITE("DigiBattle99SetCatalog JSON") { } } +TEST_SUITE("YuGiOhBandaiCard JSON") { + TEST_CASE("round-trips with setNo, rarity, holo and signed alias") { + YuGiOhBandaiCard c; + c.id = 14; + c.amount = 2; + c.name = "Dark Magician"; + c.set = Set{"ban1", "1st Generation", "1998/09/01"}; + c.setNo = "14"; + c.rarity = "Rare"; + c.note = "classic"; + c.images = {"a.png"}; + c.language = Language::Japanese; + c.condition = Condition::NearMint; + c.holo = true; + c.signed_ = true; + c.altered = false; + + nlohmann::json j = c; + CHECK(j.at("setNo") == "14"); + CHECK(j.at("rarity") == "Rare"); + CHECK(j.at("holo") == true); + CHECK(j.at("signed") == true); + CHECK_FALSE(j.contains("firstEdition")); + + const YuGiOhBandaiCard back = j.get(); + CHECK(back == c); + } + + TEST_CASE("missing each required key throws") { + const nlohmann::json full = { + {"id", 14}, + {"amount", 1}, + {"name", "Dark Magician"}, + {"set", nlohmann::json{ + {"id", "ban1"}, + {"name", "1st Generation"}, + {"releaseDate", "1998/09/01"}, + }}, + {"setNo", "14"}, + {"rarity", "Rare"}, + {"note", ""}, + {"images", nlohmann::json::array()}, + {"language", "Japanese"}, + {"condition", "NearMint"}, + {"holo", false}, + {"signed", false}, + {"altered", false}, + }; + + for (const char* key : { + "id", "amount", "name", "set", "setNo", "rarity", "note", "images", + "language", "condition", "holo", "signed", "altered"}) { + nlohmann::json partial = full; + partial.erase(key); + CHECK_THROWS(partial.get()); + } + } +} + +TEST_SUITE("YuGiOhBandaiSetCatalog JSON") { + TEST_CASE("round-trips packs with rarity") { + YuGiOhBandaiSetCatalog catalog; + YuGiOhBandaiSetCatalogPack pack; + pack.setId = "ban1"; + pack.setName = "1st Generation"; + pack.cards.push_back(YuGiOhBandaiCatalogCard{"14", "Dark Magician", "Rare"}); + pack.cards.push_back(YuGiOhBandaiCatalogCard{"9", "Blue-Eyes White Dragon", "Super Rare"}); + catalog.packs.push_back(std::move(pack)); + + nlohmann::json j = catalog; + CHECK(j.at("packs").at(0).at("id") == "ban1"); + CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "14"); + CHECK(j.at("packs").at(0).at("cards").at(0).at("rarity") == "Rare"); + + const YuGiOhBandaiSetCatalog back = j.get(); + CHECK(back == catalog); + CHECK(back.findPack("ban1") != nullptr); + CHECK(back.findPack("missing") == nullptr); + } +} + TEST_SUITE("YuGiOhSetCatalog JSON") { TEST_CASE("round-trips packs and setNo alias") { YuGiOhSetCatalog catalog; diff --git a/tests/game_module_tests.cpp b/tests/game_module_tests.cpp index 8d5ab92..4cc0c2c 100644 --- a/tests/game_module_tests.cpp +++ b/tests/game_module_tests.cpp @@ -5,6 +5,7 @@ #include "ccm/games/pokemon/PokemonGameModule.hpp" #include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp" #include "ccm/games/yugioh/YuGiOhGameModule.hpp" +#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp" #include "ccm/ports/IHttpClient.hpp" using namespace ccm; @@ -65,6 +66,17 @@ TEST_SUITE("game modules expose stable identity and wiring") { CHECK(static_cast(&module.setSource()) != static_cast(module.cardPreviewSource())); } + TEST_CASE("YuGiOhBandai module reports canonical metadata") { + NoopHttpClient http; + YuGiOhBandaiGameModule module(http); + + CHECK(module.id() == Game::YuGiOhBandai); + CHECK(module.dirName() == "yugiohbandai"); + CHECK(module.displayName() == "Yu-Gi-Oh! (Bandai)"); + CHECK(module.cardPreviewSource() != nullptr); + CHECK(static_cast(&module.setSource()) != static_cast(module.cardPreviewSource())); + } + TEST_CASE("JapanesePokemon module reports canonical metadata") { NoopHttpClient http; JapanesePokemonGameModule module(http); diff --git a/tests/set_service_tests.cpp b/tests/set_service_tests.cpp index 91107a4..77551c1 100644 --- a/tests/set_service_tests.cpp +++ b/tests/set_service_tests.cpp @@ -31,6 +31,7 @@ public: if (gameId == Game::Pokemon) return "pokemon"; if (gameId == Game::YuGiOh) return "yugioh"; if (gameId == Game::DigiBattle99) return "digibattle99"; + if (gameId == Game::YuGiOhBandai) return "yugiohbandai"; if (gameId == Game::JapanesePokemon) return "pokemon"; return "yugioh"; } @@ -182,6 +183,43 @@ TEST_SUITE("SetService") { CHECK(digi.source.calls == 1); } + TEST_CASE("YuGiOhBandai module routes independently when all games are registered") { + InMemSetRepo repo; + SetService svc{repo}; + + FakeGameModule magic{Game::Magic}; + magic.source.result = Result>::ok({{"lea", "Alpha", "1993/08/05"}}); + FakeGameModule pokemon{Game::Pokemon}; + pokemon.source.result = Result>::ok({{"base1", "Base", "1999/01/09"}}); + FakeGameModule yugioh{Game::YuGiOh}; + yugioh.source.result = Result>::ok({{"LOB", "Legend of Blue Eyes", "2002/03/08"}}); + FakeGameModule digi{Game::DigiBattle99}; + digi.source.result = Result>::ok( + {{"series-1-starter-set", "Series 1 Starter Set", "1999/06/01"}}); + FakeGameModule bandai{Game::YuGiOhBandai}; + bandai.source.result = Result>::ok( + {{"ban1", "1st Generation", "1998/09/01"}}); + + svc.registerModule(&magic); + svc.registerModule(&pokemon); + svc.registerModule(&yugioh); + svc.registerModule(&digi); + svc.registerModule(&bandai); + + REQUIRE(svc.updateSets(Game::Magic).isOk()); + REQUIRE(svc.updateSets(Game::Pokemon).isOk()); + REQUIRE(svc.updateSets(Game::YuGiOh).isOk()); + REQUIRE(svc.updateSets(Game::DigiBattle99).isOk()); + const auto out = svc.updateSets(Game::YuGiOhBandai); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "ban1"); + CHECK(magic.source.calls == 1); + CHECK(pokemon.source.calls == 1); + CHECK(yugioh.source.calls == 1); + CHECK(digi.source.calls == 1); + CHECK(bandai.source.calls == 1); + } + TEST_CASE("JapanesePokemon module routes independently when all games are registered") { InMemSetRepo repo; SetService svc{repo}; diff --git a/tests/yugiohbandai_card_preview_source_tests.cpp b/tests/yugiohbandai_card_preview_source_tests.cpp new file mode 100644 index 0000000..7a18284 --- /dev/null +++ b/tests/yugiohbandai_card_preview_source_tests.cpp @@ -0,0 +1,220 @@ +#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp" + +#include + +#include + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string body; + std::string lastUrl; + bool fail{false}; + + Result get(std::string_view url) override { + lastUrl = std::string(url); + if (fail) return Result::err("http fail"); + return Result::ok(body); + } +}; + +} // namespace + +TEST_SUITE("YuGiOhBandaiCardPreviewSource helpers") { + TEST_CASE("preferredPageTitle picks Bandai / English / Sealdass") { + CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "ban1", "14") == + "Dark Magician (Bandai)"); + CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Blue-Eyes White Dragon", "ban3", + "118") == + "Blue-Eyes White Dragon (English Bandai)"); + CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle("Dark Magician", "bansealdass", + "2") == + "Dark Magician (Bandai Sealdass)"); + } + + TEST_CASE("buildPageImagesUrl encodes spaces as underscores then percent") { + const auto url = + YuGiOhBandaiCardPreviewSource::buildPageImagesUrl("Dark Magician (Bandai)"); + CHECK(url.find("titles=Dark_Magician_%28Bandai%29") != std::string::npos); + } + + TEST_CASE("buildAskByNameUrl includes English name constraint") { + const auto url = YuGiOhBandaiCardPreviewSource::buildAskByNameUrl("Dark Magician"); + CHECK(url.find("action=ask") != std::string::npos); + CHECK(url.find("query=") != std::string::npos); + } + + TEST_CASE("parsePageImagesResponse returns original source") { + const std::string body = R"JSON({ + "query": { + "pages": { + "1": { + "title": "Dark Magician (Bandai)", + "original": {"source": "https://ms.yugipedia.com/d/d0/DarkMagician.png"} + } + } + } + })JSON"; + auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body); + REQUIRE(out); + CHECK(out.value() == "https://ms.yugipedia.com/d/d0/DarkMagician.png"); + } + + TEST_CASE("parsePageImagesResponse missing page is NotFound") { + const std::string body = R"JSON({ + "query": { "pages": { "-1": { "missing": true, "title": "Nope" } } } + })JSON"; + auto out = YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(body); + REQUIRE_FALSE(out); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); + } + + TEST_CASE("parseAskResponse fills setNo rarity name and setId") { + const std::string body = R"JSON({ + "query": { + "results": { + "Dark Magician (Bandai)": { + "printouts": { + "English name": ["Dark Magician"], + "Bandai number": [14], + "Rarity": [{"fulltext": "Rare"}] + } + } + } + } + })JSON"; + auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1"); + REQUIRE(out); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].name == "Dark Magician"); + CHECK(out.value()[0].setNo == "14"); + CHECK(out.value()[0].rarity == "Rare"); + CHECK(out.value()[0].setId == "ban1"); + CHECK(out.value()[0].language == "Japanese"); + } + + TEST_CASE("parseAskResponse prefers Bandai over Sealdass when set is ban1") { + const std::string body = R"JSON({ + "query": { + "results": { + "Dark Magician (Bandai Sealdass)": { + "printouts": { + "English name": ["Dark Magician"], + "Bandai number": [2], + "Rarity": [{"fulltext": "Common"}] + } + }, + "Dark Magician (Bandai)": { + "printouts": { + "English name": ["Dark Magician"], + "Bandai number": [14], + "Rarity": [{"fulltext": "Rare"}] + } + } + } + } + })JSON"; + auto out = YuGiOhBandaiCardPreviewSource::parseAskResponse(body, "ban1"); + REQUIRE(out); + REQUIRE(out.value().size() == 2); + CHECK(out.value()[0].setNo == "14"); + CHECK(out.value()[0].setId == "ban1"); + } + + TEST_CASE("fetchImageUrl uses pageimages URL") { + FixedHttpClient http; + http.body = R"JSON({ + "query": { + "pages": { + "1": { + "title": "Dark Magician (Bandai)", + "original": {"source": "https://ms.yugipedia.com/x.png"} + } + } + } + })JSON"; + YuGiOhBandaiCardPreviewSource src(http); + auto out = src.fetchImageUrl("Dark Magician", "ban1", "14"); + REQUIRE(out); + CHECK(out.value() == "https://ms.yugipedia.com/x.png"); + CHECK(http.lastUrl.find("pageimages") != std::string::npos); + } + + TEST_CASE("detectFirstPrint uses ask response") { + FixedHttpClient http; + http.body = R"JSON({ + "query": { + "results": { + "Dark Magician (Bandai)": { + "printouts": { + "English name": ["Dark Magician"], + "Bandai number": [14], + "Rarity": [{"fulltext": "Rare"}] + } + } + } + } + })JSON"; + YuGiOhBandaiCardPreviewSource src(http); + auto out = src.detectFirstPrint("Dark Magician", "ban1"); + REQUIRE(out); + CHECK(out.value().setNo == "14"); + CHECK(out.value().rarity == "Rare"); + CHECK(out.value().setId == "ban1"); + } + + TEST_CASE("detectBySetNo uses ask-by-number URL") { + FixedHttpClient http; + http.body = R"JSON({ + "query": { + "results": { + "Dark Magician (Bandai)": { + "printouts": { + "English name": ["Dark Magician"], + "Bandai number": [14], + "Rarity": [{"fulltext": "Rare"}] + } + } + } + } + })JSON"; + YuGiOhBandaiCardPreviewSource src(http); + auto out = src.detectBySetNo("014"); + REQUIRE(out); + CHECK(out.value().name == "Dark Magician"); + CHECK(http.lastUrl.find("action=ask") != std::string::npos); + } + + TEST_CASE("detectBySetNo resolves promo TA2 from gallery parse") { + FixedHttpClient http; + http.body = R"JSON({ + "parse": { + "wikitext": "WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) {{Gallery card names|Wicked Chain|ja}}\nBlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])
{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n" + } + })JSON"; + YuGiOhBandaiCardPreviewSource src(http); + auto out = src.detectBySetNo("ta2"); + REQUIRE(out); + CHECK(out.value().name == "Blue-Eyes White Dragon's 3-Body Connection"); + CHECK(out.value().setNo == "TA2"); + CHECK(out.value().setId == "banpromo-ta"); + CHECK(out.value().rarity == "Super Rare"); + CHECK(http.lastUrl.find("action=parse") != std::string::npos); + CHECK(http.lastUrl.find("Promotional") != std::string::npos); + } + + TEST_CASE("isAlphanumericPromoNumber detects Jump and Toei codes") { + CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("TA2")); + CHECK(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("j1")); + CHECK_FALSE(YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber("14")); + } + + TEST_CASE("preferredPageTitle omits Bandai suffix for promo sets") { + CHECK(YuGiOhBandaiCardPreviewSource::preferredPageTitle( + "Blue-Eyes White Dragon's 3-Body Connection", "banpromo-ta", "TA2") == + "Blue-Eyes White Dragon's 3-Body Connection"); + } +} diff --git a/tests/yugiohbandai_set_completion_tests.cpp b/tests/yugiohbandai_set_completion_tests.cpp new file mode 100644 index 0000000..2ebee16 --- /dev/null +++ b/tests/yugiohbandai_set_completion_tests.cpp @@ -0,0 +1,153 @@ +#include "ccm/domain/Configuration.hpp" +#include "ccm/domain/YuGiOhBandaiCard.hpp" +#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp" +#include "ccm/services/YuGiOhBandaiSetCompletion.hpp" +#include "fakes/InMemoryFileSystem.hpp" + +#include + +#include + +using namespace ccm; +using ccm::testing::InMemoryFileSystem; + +namespace { + +ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) { + Configuration c; + c.dataStorage = dataDir; + c.defaultGame = Game::Magic; + fs.writeText("/app/config.json", nlohmann::json(c).dump()); + ConfigService cfg{fs, "/app/config.json", dataDir}; + cfg.initialize(); + return cfg; +} + +YuGiOhBandaiCard makeOwned(std::string setId, std::string setName, std::string setNo) { + YuGiOhBandaiCard c; + c.set = Set{std::move(setId), std::move(setName), "1998/09/01"}; + c.setNo = std::move(setNo); + c.name = "Card"; + c.language = Language::Japanese; + return c; +} + +} // namespace + +TEST_SUITE("YuGiOhBandaiSetCompletion") { + TEST_CASE("unique setNo within a pack; amount does not inflate") { + YuGiOhBandaiSetCatalog catalog; + YuGiOhBandaiSetCatalogPack pack; + pack.setId = "ban1"; + pack.setName = "1st Generation"; + pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"}); + pack.cards.push_back({"14", "Dark Magician", "Rare"}); + catalog.packs.push_back(pack); + + std::vector coll; + auto a = makeOwned("ban1", "1st Generation", "14"); + a.amount = 5; + coll.push_back(a); + coll.push_back(makeOwned("ban1", "1st Generation", "014")); + + auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog); + REQUIRE(progress.size() == 1); + CHECK(progress[0].ownedUnique == 1); + CHECK(progress[0].total == 2); + CHECK(progress[0].percent() == 50); + } + + TEST_CASE("ownership on one pack does not complete another pack sharing setNo") { + YuGiOhBandaiSetCatalog catalog; + YuGiOhBandaiSetCatalogPack ban1; + ban1.setId = "ban1"; + ban1.setName = "1st Generation"; + ban1.cards.push_back({"14", "Dark Magician", "Rare"}); + YuGiOhBandaiSetCatalogPack seal; + seal.setId = "bansealdass"; + seal.setName = "Sealdass"; + seal.cards.push_back({"14", "Other", "Common"}); + catalog.packs.push_back(ban1); + catalog.packs.push_back(seal); + + std::vector coll{makeOwned("ban1", "1st Generation", "14")}; + auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog); + REQUIRE(progress.size() == 1); + CHECK(progress[0].setId == "ban1"); + } + + TEST_CASE("checklist marks owned rows") { + YuGiOhBandaiSetCatalog catalog; + YuGiOhBandaiSetCatalogPack pack; + pack.setId = "ban1"; + pack.setName = "1st Generation"; + pack.cards.push_back({"9", "Blue-Eyes", "Super Rare"}); + pack.cards.push_back({"14", "Dark Magician", "Rare"}); + catalog.packs.push_back(pack); + + std::vector coll{makeOwned("ban1", "1st Generation", "14")}; + auto list = yuGiOhBandaiChecklistForSet(coll, catalog, "ban1"); + REQUIRE(list.size() == 2); + CHECK(list[0].setNo == "14"); + CHECK(list[0].owned); + CHECK(list[1].setNo == "9"); + CHECK_FALSE(list[1].owned); + } + + TEST_CASE("empty setNo produces no progress rows") { + YuGiOhBandaiSetCatalog catalog; + YuGiOhBandaiSetCatalogPack pack; + pack.setId = "ban1"; + pack.setName = "1st Generation"; + pack.cards.push_back({"14", "Dark Magician", "Rare"}); + catalog.packs.push_back(pack); + + YuGiOhBandaiCard missingNo = makeOwned("ban1", "1st Generation", ""); + YuGiOhBandaiCard whitespaceNo = makeOwned("ban1", "1st Generation", " "); + std::vector coll{missingNo, whitespaceNo}; + auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog); + CHECK(progress.empty()); + } + + TEST_CASE("set.id plus setNo against catalog pack yields a tile") { + YuGiOhBandaiSetCatalog catalog; + YuGiOhBandaiSetCatalogPack pack; + pack.setId = "ban2"; + pack.setName = "2nd Generation"; + pack.cards.push_back({"47", "Time Wizard", "Super Rare"}); + pack.cards.push_back({"48", "Polymerization", "Super Rare"}); + catalog.packs.push_back(pack); + + std::vector coll{makeOwned("ban2", "2nd Generation", "047")}; + auto progress = computeYuGiOhBandaiSetCompletion(coll, catalog); + REQUIRE(progress.size() == 1); + CHECK(progress[0].setId == "ban2"); + CHECK(progress[0].setName == "2nd Generation"); + CHECK(progress[0].ownedUnique == 1); + CHECK(progress[0].total == 2); + CHECK(progress[0].percent() == 50); + } +} + +TEST_SUITE("YuGiOhBandaiSetCatalogService") { + TEST_CASE("catalog service round-trips against InMemoryFileSystem") { + InMemoryFileSystem fs; + auto config = makeConfig(fs, "/data"); + YuGiOhBandaiSetCatalogService svc(fs, config, [](Game) { return "yugiohbandai"; }); + + YuGiOhBandaiSetCatalog catalog; + YuGiOhBandaiSetCatalogPack pack; + pack.setId = "ban1"; + pack.setName = "1st Generation"; + pack.cards.push_back({"14", "Dark Magician", "Rare"}); + catalog.packs.push_back(pack); + + REQUIRE(svc.save(catalog)); + CHECK(svc.exists()); + auto loaded = svc.load(); + REQUIRE(loaded); + CHECK(loaded.value() == catalog); + } +} diff --git a/tests/yugiohbandai_set_source_tests.cpp b/tests/yugiohbandai_set_source_tests.cpp new file mode 100644 index 0000000..0b1c095 --- /dev/null +++ b/tests/yugiohbandai_set_source_tests.cpp @@ -0,0 +1,139 @@ +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" + +#include + +#include + +using namespace ccm; + +namespace { + +class FixedHttpClient final : public IHttpClient { +public: + std::string body; + std::string lastUrl; + bool fail{false}; + + Result get(std::string_view url) override { + lastUrl = std::string(url); + if (fail) return Result::err("http fail"); + return Result::ok(body); + } +}; + +} // namespace + +TEST_SUITE("YuGiOhBandaiSetSource") { + TEST_CASE("parseResponse returns stable manifest ordered by release date") { + auto sets = YuGiOhBandaiSetSource::parseResponse({}); + REQUIRE(sets); + REQUIRE(sets.value().size() == 6); + CHECK(sets.value()[0].id == "ban1"); + CHECK(sets.value()[0].name == "1st Generation"); + CHECK(sets.value()[0].releaseDate == "1998/09/01"); + CHECK(sets.value()[5].id == "bansealdass"); + } + + TEST_CASE("normalizeCardNumber strips leading zeros and uppercases prefixes") { + CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("014") == "14"); + CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("#9") == "9"); + CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("j1") == "J1"); + CHECK(YuGiOhBandaiSetSource::normalizeCardNumber("ta2") == "TA2"); + CHECK(YuGiOhBandaiSetSource::normalizeCardNumber(" ") == ""); + } + + TEST_CASE("expandRarityCode maps gallery abbreviations") { + CHECK(YuGiOhBandaiSetSource::expandRarityCode("C") == "Common"); + CHECK(YuGiOhBandaiSetSource::expandRarityCode("R") == "Rare"); + CHECK(YuGiOhBandaiSetSource::expandRarityCode("SR") == "Super Rare"); + CHECK(YuGiOhBandaiSetSource::expandRarityCode("HFR") == "Holo Seal"); + } + + TEST_CASE("setIdForNumber maps ranges and promo prefixes") { + CHECK(YuGiOhBandaiSetSource::setIdForNumber("14") == "ban1"); + CHECK(YuGiOhBandaiSetSource::setIdForNumber("50") == "ban2"); + CHECK(YuGiOhBandaiSetSource::setIdForNumber("118") == "ban3"); + CHECK(YuGiOhBandaiSetSource::setIdForNumber("J1") == "banpromo-j"); + CHECK(YuGiOhBandaiSetSource::setIdForNumber("TA2") == "banpromo-ta"); + } + + TEST_CASE("parseGalleryWikitext extracts number rarity and English name") { + const std::string wiki = + "DarkMagician-BAN1-JP-R.png | {{pound}}014 ([[R]]) " + "{{Gallery card names|Dark Magician (Bandai)|ja}}\n" + "BlueEyesWhiteDragon-BAN1-JP-SR.png | {{pound}}009 ([[SR]]) " + "{{Gallery card names|Blue-Eyes White Dragon (Bandai)|ja}}\n"; + + auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki); + REQUIRE(cards); + REQUIRE(cards.value().size() == 2); + CHECK(cards.value()[0].setNo == "14"); + CHECK(cards.value()[0].name == "Dark Magician"); + CHECK(cards.value()[0].rarity == "Rare"); + CHECK(cards.value()[1].setNo == "9"); + CHECK(cards.value()[1].rarity == "Super Rare"); + } + + TEST_CASE("parseGalleryWikitext accepts promo [[TA2]] number format") { + const std::string wiki = + "WickedChain-BAN1-JP-SR.png | [[TA1]] ([[SR]]) " + "{{Gallery card names|Wicked Chain|ja}}\n" + "BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]]) " + "{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n" + "MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]]) " + "{{Gallery card names|Mirror Force (Bandai)|ja}}\n"; + + auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki); + REQUIRE(cards); + REQUIRE(cards.value().size() == 3); + CHECK(cards.value()[0].setNo == "TA1"); + CHECK(cards.value()[0].name == "Wicked Chain"); + CHECK(cards.value()[0].rarity == "Super Rare"); + CHECK(cards.value()[1].setNo == "TA2"); + CHECK(cards.value()[1].name == "Blue-Eyes White Dragon's 3-Body Connection"); + CHECK(cards.value()[2].setNo == "J1"); + CHECK(cards.value()[2].name == "Mirror Force"); + } + + TEST_CASE("parseGalleryWikitext tolerates
between rarity and name template") { + // Live Yugipedia promo gallery captions insert
after expansion. + const std::string wiki = + "\n" + "BlueEyesWhiteDragons3BodyConnection-BAN1-JP-SR.png | [[TA2]] ([[SR]])
" + "{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}\n" + "MirrorForce-BAN1-JP-SR.png | [[J1]] ([[SR]])
" + "{{Gallery card names|Mirror Force (Bandai)|ja}}\n" + "
\n"; + + auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wiki); + REQUIRE(cards); + REQUIRE(cards.value().size() == 2); + CHECK(cards.value()[0].setNo == "TA2"); + CHECK(cards.value()[0].name == "Blue-Eyes White Dragon's 3-Body Connection"); + CHECK(cards.value()[0].rarity == "Super Rare"); + CHECK(cards.value()[1].setNo == "J1"); + } + + TEST_CASE("parseGalleryWikitext empty body yields empty ok") { + auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(""); + REQUIRE(cards); + CHECK(cards.value().empty()); + } + + TEST_CASE("fetchAll returns manifest without HTTP") { + FixedHttpClient http; + YuGiOhBandaiSetSource src(http); + auto sets = src.fetchAll(); + REQUIRE(sets); + CHECK(sets.value().size() == 6); + CHECK(http.lastUrl.empty()); + } + + TEST_CASE("buildGalleryParseUrl percent-encodes page title") { + const auto url = YuGiOhBandaiSetSource::buildGalleryParseUrl( + "Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation"); + CHECK(url.find("action=parse") != std::string::npos); + CHECK(url.find("prop=wikitext") != std::string::npos); + CHECK(url.find("page=") != std::string::npos); + } +} diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index fac8b54..b259d2c 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -16,6 +16,7 @@ - `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/YuGiOhBandai*.hpp` + `src/YuGiOhBandai*.cpp` — Yu-Gi-Oh! (Bandai): same notebook layout as Digimon/YGO; dual auto-detect (name or Bandai number) via Yugipedia SMW ask; catalog from `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`). - `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`. @@ -61,7 +62,7 @@ - Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window. - Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background. - Do not call `applyNativeClassTheme(..., "DarkMode_Explorer", "Explorer")` for `wxTextCtrl`; on some Windows builds this causes black typed text in dark mode. Keep text inputs palette-driven (`SetThemeEnabled(false)` in dark/high-contrast as needed). - - If a specific text field still renders wrong while typing (notably `MainFrame`'s filter box), enforce text/background in `MainFrame::MSWWindowProc` via `WM_CTLCOLOREDIT` for that control handle. + - Text inputs are hardened in `Theme.cpp` via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme`: opt the EDIT HWND out of immersive dark mode, clear its visual style, and subclass the **parent** to answer `WM_CTLCOLOREDIT` (that message goes to the parent, not the frame — an earlier frame-level handler never ran for the toolbar filter). - Keep toolbar button behavior stable under dark/high-contrast: avoid changes that break click/tooltip affordances while experimenting with hover contrast fixes. - For dark/high-contrast button readability, do not trust native hover/pressed rendering on Windows; custom state painting in `Theme.cpp` is allowed when native visuals ignore configured colors. - Button event handlers must use per-button state that is refreshed when theme changes. Avoid one-time captures of theme colors/mode in lambdas; these can leak dark-mode behavior into light mode. @@ -90,7 +91,7 @@ 1. Implement three derived classes under `include/ccm/ui/` mirroring the Magic / Pokemon trio: - `CardListPanel : public BaseCardListPanel<Card, SortColumn>` — override `declareTextColumns()`, `declareIconColumns()`, `renderTextCell()`, `isIconColumnSet()`, `sortBy()`, `matchesFilter()`. - `SelectedCardPanel : public BaseSelectedCardPanel<Card>` — override `declareDetailRows()`, `declareFlagIcons()`, `detailValueFor()`, `isFlagSet()`, `previewKey()`, `gameId()`. Define a local `enum` of `DetailKey` constants for clarity. - - `CardEditDialog : public BaseCardEditDialog<Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`. + - `CardEditDialog : public BaseCardEditDialog<Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`, and optionally `validateExtraFields()` (Bandai requires set number). 2. Add a `GameView : public IGameView` that owns those panels and the typed `CollectionService<Card>&`. Bind `EVT_CARD_SELECTED` on the list panel inside `listPanel(parent)` to push the typed selection into the selected panel. The `MagicGameView` / `PokemonGameView` pair is the canonical reference. 3. Re-add the new view to `AppContext::gameViews` in the composition root (`app/main.cpp`). The `Game` and `Sets` menus pick it up automatically. 4. Add SVG glyphs for any new flag columns to `SvgIcons.{hpp,cpp}` (with the `@FILL@` placeholder). diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt index f122ee6..18b545a 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -26,6 +26,11 @@ add_library(ccm_ui_wx STATIC src/DigiBattle99CardEditDialog.cpp src/DigiBattle99GameView.cpp src/DigiBattle99SetCompletionPanel.cpp + src/YuGiOhBandaiCardListPanel.cpp + src/YuGiOhBandaiSelectedCardPanel.cpp + src/YuGiOhBandaiCardEditDialog.cpp + src/YuGiOhBandaiGameView.cpp + src/YuGiOhBandaiSetCompletionPanel.cpp src/SettingsDialog.cpp src/SwitchCtrl.cpp @@ -61,8 +66,10 @@ target_link_libraries(ccm_ui_wx # the per-row flag glyphs with proper transparency. Without msimg32 linked # explicitly the linker fails on `AlphaBlend@44` even though gdi32 is pulled # in transitively by wxWidgets. +# Theme.cpp subclasses EDIT parents via SetWindowSubclass / DefSubclassProc / +# RemoveWindowSubclass (comctl32); those symbols are not pulled in by wx alone. if (WIN32) - target_link_libraries(ccm_ui_wx PRIVATE msimg32) + target_link_libraries(ccm_ui_wx PRIVATE msimg32 comctl32) endif() target_compile_features(ccm_ui_wx PUBLIC cxx_std_20) diff --git a/ui_wx/include/ccm/ui/AppContext.hpp b/ui_wx/include/ccm/ui/AppContext.hpp index 0d2baf6..37e6a61 100644 --- a/ui_wx/include/ccm/ui/AppContext.hpp +++ b/ui_wx/include/ccm/ui/AppContext.hpp @@ -27,6 +27,7 @@ struct AppContext { IGameModule& pokemonModule; IGameModule& yuGiOhModule; IGameModule& digiBattle99Module; + IGameModule& yuGiOhBandaiModule; // Asia Pokemon sets/preview backend (not a separate Game menu entry). IGameModule& japanesePokemonModule; // Active per-game UI bundles. The order is the order shown in the diff --git a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp index 12642a3..6dd48af 100644 --- a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp @@ -136,6 +136,10 @@ protected: // controls cannot outlive the lookup identity. virtual void onCardLookupContextChanged() {} + // Extra validation after name/set checks and writeFromControls(). Return + // false to block OK (subclass should show its own themed dialog). + [[nodiscard]] virtual bool validateExtraFields() { return true; } + // Common helpers ---------------------------------------------------------- void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) { @@ -148,6 +152,8 @@ protected: [[nodiscard]] const TCard& constCard() const noexcept { return card_; } void syncCardFromControls() { writeFromControls(); } [[nodiscard]] wxComboBox* setComboControl() const noexcept { return setCombo_; } + [[nodiscard]] wxTextCtrl* nameControl() const noexcept { return nameCtrl_; } + [[nodiscard]] wxChoice* languageChoiceControl() const noexcept { return languageChoice_; } [[nodiscard]] const Set* selectedSetFromControls() const { const auto& available = availableSets(); @@ -471,6 +477,7 @@ private: "Add card", wxOK | wxICON_INFORMATION); return; } + if (!validateExtraFields()) return; if (mode_ == EditMode::Edit && !(card_ == openingSnapshot_)) { if (showThemedConfirmDialog( this, diff --git a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp index c10a805..1c535c9 100644 --- a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp @@ -307,6 +307,8 @@ private: case Game::DigiBattle99: // No stable public Digi-Battle back URL; UI uses bundled PNG. return {}; + case Game::YuGiOhBandai: + return "https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png"; } return {}; } diff --git a/ui_wx/include/ccm/ui/MainFrame.hpp b/ui_wx/include/ccm/ui/MainFrame.hpp index 6f9451a..ae4e1b8 100644 --- a/ui_wx/include/ccm/ui/MainFrame.hpp +++ b/ui_wx/include/ccm/ui/MainFrame.hpp @@ -51,10 +51,6 @@ private: [[nodiscard]] IGameView* activeView(); -#ifdef __WXMSW__ - WXLRESULT MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) override; -#endif - AppContext& ctx_; Game activeGame_{Game::Magic}; diff --git a/ui_wx/include/ccm/ui/Theme.hpp b/ui_wx/include/ccm/ui/Theme.hpp index b5b0ea2..bc779e0 100644 --- a/ui_wx/include/ccm/ui/Theme.hpp +++ b/ui_wx/include/ccm/ui/Theme.hpp @@ -7,6 +7,7 @@ class wxDialog; class wxWindow; class wxString; +class wxTextCtrl; namespace ccm::ui { @@ -23,6 +24,8 @@ struct ThemePalette { ThemePalette paletteForTheme(Theme theme); Theme inferThemeFromWindow(const wxWindow* window); void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme); +// Force palette colors onto a text input (incl. MSW dark-mode typed-text fix). +void applyPaletteToTextCtrl(wxTextCtrl* text, const ThemePalette& palette, Theme theme); void themeModalDialog(wxDialog* dlg, Theme theme); int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style); int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption); diff --git a/ui_wx/include/ccm/ui/YuGiOhBandaiCardEditDialog.hpp b/ui_wx/include/ccm/ui/YuGiOhBandaiCardEditDialog.hpp new file mode 100644 index 0000000..092f98c --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhBandaiCardEditDialog.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include "ccm/domain/YuGiOhBandaiCard.hpp" +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/services/CardPreviewService.hpp" +#include "ccm/ui/BaseCardEditDialog.hpp" +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace ccm::ui { + +class YuGiOhBandaiCardEditDialog final : public BaseCardEditDialog { +public: + YuGiOhBandaiCardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + CardPreviewService& cardPreview, + EditMode mode, + YuGiOhBandaiCard initial, + const std::vector* preloadedSets = nullptr); + ~YuGiOhBandaiCardEditDialog() override; + +protected: + void buildFlagsRow(wxBoxSizer* flagsBox) override; + void appendExtraRows(wxFlexGridSizer* grid) override; + void readExtraFromCard() override; + void writeExtraToCard() override; + [[nodiscard]] std::span languagesForChoice() const override; + [[nodiscard]] std::string updateMenuName() const override { + return "Update Yu-Gi-Oh! (Bandai)"; + } + void onCardLookupContextChanged() override; + [[nodiscard]] bool validateExtraFields() override; + +private: + struct VariantFetchState { + std::atomic alive{true}; + }; + + void onAutoDetectBySetNo(wxCommandEvent&); + void onNextSetNo(wxCommandEvent&); + void onAutoDetectByName(wxCommandEvent&); + void onRarityChoiceChanged(wxCommandEvent&); + void onSetSelectionChanged(wxCommandEvent&); + + void scheduleDeferredVariantPrefetch(); + void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch); + void requestByNameAsync(unsigned capturedEpoch, std::string name, std::string setId, + bool showFailureDialog); + void requestByNoAsync(unsigned capturedEpoch, std::string setNo, bool showFailureDialog); + void applyDetectedList(unsigned capturedEpoch, + Result> detected, + bool showFailureDialog, bool applyFirst); + void clearCachedPrintVariants(); + void applyDetectedPrint(const AutoDetectedPrint& print); + void applyRarityStringToChoice(const std::string& rarity); + void maybeAutoCheckHoloForRarity(const std::string& rarity); + void refreshVariantNextControls(); + [[nodiscard]] std::size_t findAvailableSetIndex(const std::string& setId) const; + + EditMode dialogMode_; + unsigned variantFetchEpoch_{0}; + CardPreviewService& cardPreview_; + std::shared_ptr variantFetchState_; + + wxTextCtrl* setNoCtrl_{nullptr}; + wxButton* autoSetNoBtn_{nullptr}; + wxButton* nextSetNoBtn_{nullptr}; + wxChoice* rarityChoice_{nullptr}; + wxButton* autoRarityBtn_{nullptr}; + wxCheckBox* holoCheck_{nullptr}; + wxCheckBox* signedCheck_{nullptr}; + wxCheckBox* alteredCheck_{nullptr}; + + std::vector cachedVariants_; + std::size_t variantRingPos_{0}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhBandaiCardListPanel.hpp b/ui_wx/include/ccm/ui/YuGiOhBandaiCardListPanel.hpp new file mode 100644 index 0000000..f86132d --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhBandaiCardListPanel.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "ccm/domain/YuGiOhBandaiCard.hpp" +#include "ccm/services/CardSorter.hpp" +#include "ccm/ui/BaseCardListPanel.hpp" + +namespace ccm::ui { + +class YuGiOhBandaiCardListPanel final + : public BaseCardListPanel { +public: + explicit YuGiOhBandaiCardListPanel(wxWindow* parent); + +protected: + [[nodiscard]] std::vector declareTextColumns() const override; + [[nodiscard]] std::vector declareIconColumns() const override; + [[nodiscard]] std::string renderTextCell(const YuGiOhBandaiCard& card, + std::size_t idx) const override; + [[nodiscard]] bool isIconColumnSet(const YuGiOhBandaiCard& card, + std::size_t idx) const override; + void sortBy(YuGiOhBandaiSortColumn column, bool ascending) override; + [[nodiscard]] bool matchesFilter(const YuGiOhBandaiCard& card, + std::string_view filter) const override; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhBandaiGameView.hpp b/ui_wx/include/ccm/ui/YuGiOhBandaiGameView.hpp new file mode 100644 index 0000000..74fbe51 --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhBandaiGameView.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include "ccm/domain/YuGiOhBandaiCard.hpp" +#include "ccm/games/IGameModule.hpp" +#include "ccm/services/CardPreviewService.hpp" +#include "ccm/services/CollectionService.hpp" +#include "ccm/services/ConfigService.hpp" +#include "ccm/services/ImageService.hpp" +#include "ccm/services/SetService.hpp" +#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp" +#include "ccm/ui/IGameView.hpp" + +#include +#include +#include +#include +#include + +class wxBitmapButton; +class wxBoxSizer; +class wxPanel; +class wxSimplebook; +class wxSplitterWindow; +class wxStaticText; +class wxTextCtrl; + +namespace ccm::ui { + +class YuGiOhBandaiCardListPanel; +class YuGiOhBandaiSelectedCardPanel; +class YuGiOhBandaiSetCompletionPanel; + +class YuGiOhBandaiGameView final : public IGameView { +public: + YuGiOhBandaiGameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module, + YuGiOhBandaiSetCatalogService& catalogStore); + + [[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOhBandai; } + [[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; } + + 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(std::optional selectId = std::nullopt) override; + void onAddCard(wxWindow* parentWindow) override; + void onEditCard(wxWindow* parentWindow) override; + void onDeleteCard(wxWindow* parentWindow) override; + std::string onUpdateSets(wxWindow* parentWindow) override; + void setFilter(std::string_view filter) override; + void nudgeSelection(int delta) override; + void applyTheme(const ThemePalette& palette) override; + [[nodiscard]] std::string updateSetsMenuLabel() const override { + return "Update Yu-Gi-Oh! (Bandai)"; + } + +private: + void ensureSetsLoaded(); + // Fetches sets + checklist catalog from Yugipedia and persists both. + // Returns false on failure (error dialogs already shown). + [[nodiscard]] bool downloadSetsAndCatalog(wxWindow* parentWindow, + std::size_t* setCountOut = nullptr, + std::size_t* packCountOut = nullptr); + // Fetches sets + checklist catalog when set-catalog.json is missing. + // Returns true if the catalog exists afterward. Shows error dialogs on failure. + [[nodiscard]] bool ensureCatalogLoaded(wxWindow* parentWindow); + void refreshSetCompletionFromStore(); + const std::vector& 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& collection_; + SetService& sets_; + ImageService& images_; + CardPreviewService& cardPreview_; + IGameModule& module_; + YuGiOhBandaiSetCatalogService& catalogStore_; + + wxPanel* contentPanel_{nullptr}; + wxPanel* tabBar_{nullptr}; + wxSimplebook* book_{nullptr}; + wxSplitterWindow* singleSplitter_{nullptr}; + YuGiOhBandaiCardListPanel* listPanel_{nullptr}; + YuGiOhBandaiSelectedCardPanel* selectedPanel_{nullptr}; + YuGiOhBandaiSetCompletionPanel* setCompletionPanel_{nullptr}; + std::array tabPanels_{{nullptr, nullptr}}; + std::array tabLabels_{{nullptr, nullptr}}; + int activeTab_{0}; + std::array toolbarButtons_{{nullptr, nullptr, nullptr}}; + wxTextCtrl* filterInput_{nullptr}; + std::vector setsCache_; + bool attemptedInitialSetLoad_{false}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhBandaiSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/YuGiOhBandaiSelectedCardPanel.hpp new file mode 100644 index 0000000..0009190 --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhBandaiSelectedCardPanel.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "ccm/domain/YuGiOhBandaiCard.hpp" +#include "ccm/ui/BaseSelectedCardPanel.hpp" + +namespace ccm::ui { + +class YuGiOhBandaiSelectedCardPanel final : public BaseSelectedCardPanel { +public: + YuGiOhBandaiSelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview); + +protected: + [[nodiscard]] std::vector declareDetailRows() const override; + [[nodiscard]] std::vector declareFlagIcons() const override; + [[nodiscard]] std::string detailValueFor(const YuGiOhBandaiCard& card, + DetailKey key) const override; + [[nodiscard]] bool isFlagSet(const YuGiOhBandaiCard& card, DetailKey key) const override; + [[nodiscard]] std::tuple + previewKey(const YuGiOhBandaiCard& card) const override; + [[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOhBandai; } +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhBandaiSetCompletionPanel.hpp b/ui_wx/include/ccm/ui/YuGiOhBandaiSetCompletionPanel.hpp new file mode 100644 index 0000000..8ecc136 --- /dev/null +++ b/ui_wx/include/ccm/ui/YuGiOhBandaiSetCompletionPanel.hpp @@ -0,0 +1,71 @@ +#pragma once + +// YuGiOhBandaiSetCompletionPanel: 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/YuGiOhBandaiCard.hpp" +#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp" +#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp" +#include "ccm/ui/Theme.hpp" + +#include + +#include +#include +#include + +class wxBoxSizer; +class wxChoice; +class wxListCtrl; +class wxScrolledWindow; +class wxSimplebook; +class wxStaticText; + +namespace ccm::ui { + +class YuGiOhBandaiSetCompletionPanel : public wxPanel { +public: + YuGiOhBandaiSetCompletionPanel(wxWindow* parent, YuGiOhBandaiSetCatalogService& catalogStore); + + void setCollection(std::vector cards); + void reloadFromStore(); + void applyTheme(const ThemePalette& palette); + +private: + void showGridPage(); + void showChecklistPage(const std::string& setId, const std::string& setName); + void rebuildGrid(); + void rebuildChecklist(const std::string& setId); + void setEmptyMessage(const wxString& message); + void clearGridTiles(); + void refreshLanguageChoice(); + void onLanguageChoice(wxCommandEvent& event); + void rebuildCurrentView(); + [[nodiscard]] std::string displaySetName(const std::string& setName) const; + + YuGiOhBandaiSetCatalogService& catalogStore_; + YuGiOhBandaiSetCatalog catalog_; + bool catalogLoaded_{false}; + std::vector collection_; + ThemePalette palette_{}; + std::optional 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 diff --git a/ui_wx/src/DigiBattle99GameView.cpp b/ui_wx/src/DigiBattle99GameView.cpp index fae55f7..e952e97 100644 --- a/ui_wx/src/DigiBattle99GameView.cpp +++ b/ui_wx/src/DigiBattle99GameView.cpp @@ -518,13 +518,7 @@ void DigiBattle99GameView::applyTheme(const ThemePalette& 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(); - } + applyPaletteToTextCtrl(filterInput_, palette, config_.current().theme); } } // namespace ccm::ui diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index 96a17bd..4ef060e 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -29,10 +29,6 @@ #include #include -#ifdef __WXMSW__ -#include -#endif - namespace ccm::ui { namespace { @@ -44,6 +40,7 @@ std::string dirNameForGame(Game g) { case Game::Magic: return "magic"; case Game::Pokemon: return "pokemon"; case Game::YuGiOh: return "yugioh"; + case Game::YuGiOhBandai: return "yugiohbandai"; case Game::DigiBattle99: return "digibattle99"; case Game::JapanesePokemon: return "pokemon"; } @@ -314,11 +311,7 @@ void MainFrame::applyTheme() { SetBackgroundColour(palette.windowBg); SetForegroundColour(palette.text); if (filterInput_ != nullptr) { - filterInput_->SetBackgroundColour(palette.inputBg); - filterInput_->SetForegroundColour(palette.inputText); - filterInput_->SetOwnBackgroundColour(palette.inputBg); - filterInput_->SetOwnForegroundColour(palette.inputText); - filterInput_->Refresh(); + applyPaletteToTextCtrl(filterInput_, palette, currentTheme); } for (auto* view : ctx_.gameViews) { if (view != nullptr) view->applyTheme(palette); @@ -481,25 +474,4 @@ void MainFrame::onDelete(wxCommandEvent&) { if (auto* view = activeView()) view->onDeleteCard(this); } -#ifdef __WXMSW__ -WXLRESULT MainFrame::MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) { - if (message == WM_CTLCOLOREDIT && filterInput_ != nullptr) { - const HWND target = reinterpret_cast(lParam); - const HWND filterHwnd = reinterpret_cast(filterInput_->GetHandle()); - if (target != nullptr && filterHwnd != nullptr && target == filterHwnd) { - const ThemePalette palette = paletteForTheme(ctx_.config.current().theme); - HDC hdc = reinterpret_cast(wParam); - ::SetTextColor(hdc, RGB(palette.inputText.Red(), palette.inputText.Green(), - palette.inputText.Blue())); - ::SetBkColor(hdc, RGB(palette.inputBg.Red(), palette.inputBg.Green(), - palette.inputBg.Blue())); - ::SetDCBrushColor(hdc, RGB(palette.inputBg.Red(), palette.inputBg.Green(), - palette.inputBg.Blue())); - return reinterpret_cast(::GetStockObject(DC_BRUSH)); - } - } - return wxFrame::MSWWindowProc(message, wParam, lParam); -} -#endif - } // namespace ccm::ui diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index 2a14dc3..addecc6 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -601,12 +601,7 @@ void PokemonGameView::applyTheme(const ThemePalette& 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); - } + applyPaletteToTextCtrl(filterInput_, palette, config_.current().theme); } } // namespace ccm::ui diff --git a/ui_wx/src/SettingsDialog.cpp b/ui_wx/src/SettingsDialog.cpp index c8c94c9..e1ba7d1 100644 --- a/ui_wx/src/SettingsDialog.cpp +++ b/ui_wx/src/SettingsDialog.cpp @@ -18,6 +18,7 @@ wxString displayLabelForGame(Game g) { case Game::Pokemon: return "Pokemon"; case Game::YuGiOh: return "Yu-Gi-Oh!"; case Game::DigiBattle99: return "Digimon (Digi-Battle)"; + case Game::YuGiOhBandai: return "Yu-Gi-Oh! (Bandai)"; case Game::JapanesePokemon: return "Pokemon"; // internal; not in allGames() } return wxString::FromUTF8(to_string(g).data()); diff --git a/ui_wx/src/Theme.cpp b/ui_wx/src/Theme.cpp index 74d1ee0..05f345b 100644 --- a/ui_wx/src/Theme.cpp +++ b/ui_wx/src/Theme.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #ifdef __WXMSW__ @@ -216,6 +217,79 @@ void applyNativeClassTheme(wxWindow* window, Theme theme, const wchar_t* darkCla setWindowTheme(hwnd, dark ? darkClass : lightClass, nullptr); } +// WM_CTLCOLOREDIT is sent to the EDIT's parent, not the top-level frame. Immersive +// dark mode can still paint black typed text even when wx colours are set, so we +// subclass each parent once and force text/background from the wxTextCtrl palette. +constexpr UINT_PTR kEditColorSubclassId = 0x43434d45; // 'CCME' +std::unordered_set gEditColorSubclassedParents; +std::unordered_map gPaletteTextCtrls; +std::unordered_set gPaletteTextCtrlDestroyBound; + +LRESULT CALLBACK editColorParentSubclass(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, + UINT_PTR /*subclassId*/, DWORD_PTR /*refData*/) { + if (msg == WM_CTLCOLOREDIT) { + const HWND editHwnd = reinterpret_cast(lParam); + const auto it = gPaletteTextCtrls.find(editHwnd); + if (it != gPaletteTextCtrls.end() && it->second != nullptr) { + wxTextCtrl* text = it->second; + const wxColour fg = text->GetForegroundColour(); + const wxColour bg = text->GetBackgroundColour(); + if (fg.IsOk() && bg.IsOk()) { + HDC hdc = reinterpret_cast(wParam); + ::SetTextColor(hdc, RGB(fg.Red(), fg.Green(), fg.Blue())); + ::SetBkColor(hdc, RGB(bg.Red(), bg.Green(), bg.Blue())); + ::SetDCBrushColor(hdc, RGB(bg.Red(), bg.Green(), bg.Blue())); + return reinterpret_cast(::GetStockObject(DC_BRUSH)); + } + } + } else if (msg == WM_NCDESTROY) { + gEditColorSubclassedParents.erase(hwnd); + ::RemoveWindowSubclass(hwnd, editColorParentSubclass, kEditColorSubclassId); + } + return ::DefSubclassProc(hwnd, msg, wParam, lParam); +} + +void ensureEditColorParentSubclass(wxTextCtrl* text) { + if (text == nullptr) return; + const HWND editHwnd = reinterpret_cast(text->GetHandle()); + if (editHwnd == nullptr) return; + gPaletteTextCtrls[editHwnd] = text; + if (gPaletteTextCtrlDestroyBound.insert(text).second) { + text->Bind(wxEVT_DESTROY, [text, editHwnd](wxWindowDestroyEvent& event) { + gPaletteTextCtrls.erase(editHwnd); + gPaletteTextCtrlDestroyBound.erase(text); + event.Skip(); + }); + } + const HWND parent = ::GetParent(editHwnd); + if (parent == nullptr) return; + if (gEditColorSubclassedParents.count(parent) != 0) return; + if (::SetWindowSubclass(parent, editColorParentSubclass, kEditColorSubclassId, 0) != FALSE) { + gEditColorSubclassedParents.insert(parent); + } +} + +void hardenTextCtrlNativeTheme(wxTextCtrl* text, Theme theme) { + if (text == nullptr) return; + const bool darkLike = isDarkLikeTheme(theme); + text->SetThemeEnabled(!darkLike); + + const HWND hwnd = reinterpret_cast(text->GetHandle()); + if (hwnd == nullptr) return; + + // Opt this EDIT out of immersive dark mode so typed text uses our palette. + if (auto allowDarkModeForWindow = resolveAllowDarkModeForWindow()) { + allowDarkModeForWindow(hwnd, FALSE); + } + if (darkLike) { + if (auto setWindowTheme = resolveSetWindowTheme()) { + // Empty theme class disables visual-style painting of the EDIT contents. + setWindowTheme(hwnd, L"", L""); + } + } + ensureEditColorParentSubclass(text); +} + COLORREF toColorRef(const wxColour& c) { return RGB(c.Red(), c.Green(), c.Blue()); } @@ -412,9 +486,13 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t dynamic_cast(root) != nullptr) { if (auto* text = dynamic_cast(root)) { // On Windows, themed EDIT controls can ignore wx foreground color - // while typing in dark mode; disable native theming there so the - // control consistently uses palette-driven text/background colors. + // while typing in dark mode; disable native theming and force + // WM_CTLCOLOREDIT colours via the parent subclass helper. +#ifdef __WXMSW__ + hardenTextCtrlNativeTheme(text, theme); +#else text->SetThemeEnabled(!isDarkLikeTheme(theme)); +#endif } root->SetBackgroundColour(palette.inputBg); root->SetForegroundColour(palette.inputText); @@ -428,7 +506,7 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t } else if (dynamic_cast(root) != nullptr) { // Do not apply Explorer class theming to edit controls: on some // Windows builds it forces black typed text in dark mode. - // Keep text fields palette-driven via wx colors instead. + // Keep text fields palette-driven via wx colours + CTLCOLOR fix. } else { applyNativeClassTheme(root, theme, L"DarkMode_Explorer", L"Explorer"); } @@ -637,6 +715,20 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t } } +void applyPaletteToTextCtrl(wxTextCtrl* text, const ThemePalette& palette, Theme theme) { + if (text == nullptr) return; +#ifdef __WXMSW__ + hardenTextCtrlNativeTheme(text, theme); +#else + text->SetThemeEnabled(!isDarkLikeTheme(theme)); +#endif + text->SetBackgroundColour(palette.inputBg); + text->SetForegroundColour(palette.inputText); + text->SetOwnBackgroundColour(palette.inputBg); + text->SetOwnForegroundColour(palette.inputText); + text->Refresh(); +} + void themeModalDialog(wxDialog* dlg, Theme theme) { if (dlg == nullptr) return; const ThemePalette palette = paletteForTheme(theme); diff --git a/ui_wx/src/YuGiOhBandaiCardEditDialog.cpp b/ui_wx/src/YuGiOhBandaiCardEditDialog.cpp new file mode 100644 index 0000000..73e547d --- /dev/null +++ b/ui_wx/src/YuGiOhBandaiCardEditDialog.cpp @@ -0,0 +1,359 @@ +#include "ccm/ui/YuGiOhBandaiCardEditDialog.hpp" + +#include "ccm/domain/Enums.hpp" +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" +#include "ccm/ui/Theme.hpp" +#include +#include + +#include +#include +#include +#include + +namespace ccm::ui { + +namespace { +const char* const kRarityOptions[] = { + "Common", + "Rare", + "Super Rare", + "Ultra Rare", + "Holo Seal", +}; +} // namespace + +YuGiOhBandaiCardEditDialog::YuGiOhBandaiCardEditDialog(wxWindow* parent, + ImageService& imageService, + SetService& setService, + CardPreviewService& cardPreview, + EditMode mode, + YuGiOhBandaiCard initial, + const std::vector* preloadedSets) + : BaseCardEditDialog( + parent, + mode == EditMode::Create ? "Add Yu-Gi-Oh! (Bandai) Card" + : "Edit Yu-Gi-Oh! (Bandai) Card", + imageService, setService, mode, std::move(initial), Game::YuGiOhBandai, + preloadedSets), + dialogMode_(mode), + cardPreview_(cardPreview), + variantFetchState_(std::make_shared()) { + buildAndPopulate(); + if (dialogMode_ == EditMode::Edit) { + scheduleDeferredVariantPrefetch(); + } +} + +YuGiOhBandaiCardEditDialog::~YuGiOhBandaiCardEditDialog() { + if (variantFetchState_) { + variantFetchState_->alive.store(false); + } +} + +std::span YuGiOhBandaiCardEditDialog::languagesForChoice() const { + static constexpr Language kLangs[] = {Language::Japanese, Language::English}; + return kLangs; +} + +void YuGiOhBandaiCardEditDialog::onCardLookupContextChanged() { + clearCachedPrintVariants(); +} + +bool YuGiOhBandaiCardEditDialog::validateExtraFields() { + const std::string setNo = + YuGiOhBandaiSetSource::normalizeCardNumber(constCard().setNo); + if (setNo.empty()) { + showThemedMessageDialog( + this, + "Set number (No.) is required for set completion tracking.\n" + "Enter a Bandai number or use Auto detect.", + "Add card", wxOK | wxICON_INFORMATION); + return false; + } + // Persist the normalized form so ownership keys stay stable. + mutableCard().setNo = setNo; + if (setNoCtrl_) setNoCtrl_->ChangeValue(wxString::FromUTF8(setNo.c_str())); + return true; +} + +void YuGiOhBandaiCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) { + holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo"); + signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed"); + alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered"); + flagsBox->Add(holoCheck_, 0, wxRIGHT, 12); + flagsBox->Add(signedCheck_, 0, wxRIGHT, 12); + flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12); +} + +void YuGiOhBandaiCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { + auto* setNoPanel = new wxPanel(this, wxID_ANY); + setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY); + autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect"); + autoSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhBandaiCardEditDialog::onAutoDetectBySetNo, this); + nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next"); + nextSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhBandaiCardEditDialog::onNextSetNo, this); + nextSetNoBtn_->Show(false); + auto* setNoRow = new wxBoxSizer(wxHORIZONTAL); + setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL); + setNoPanel->SetSizer(setNoRow); + + auto* rarityPanel = new wxPanel(this, wxID_ANY); + rarityChoice_ = new wxChoice(rarityPanel, wxID_ANY); + wxArrayString rarityItems; + rarityItems.Alloc(static_cast(sizeof(kRarityOptions) / sizeof(kRarityOptions[0]))); + for (const char* rarity : kRarityOptions) { + rarityItems.Add(wxString::FromUTF8(rarity)); + } + rarityChoice_->Append(rarityItems); + rarityChoice_->Bind(wxEVT_CHOICE, &YuGiOhBandaiCardEditDialog::onRarityChoiceChanged, this); + autoRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Auto detect"); + autoRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhBandaiCardEditDialog::onAutoDetectByName, this); + auto* rarityRow = new wxBoxSizer(wxHORIZONTAL); + rarityRow->Add(rarityChoice_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + rarityRow->Add(autoRarityBtn_, 0, wxALIGN_CENTER_VERTICAL); + rarityPanel->SetSizer(rarityRow); + + appendRow(grid, "No.", setNoPanel); + appendRow(grid, "Rarity", rarityPanel); + + if (auto* setCombo = setComboControl()) { + setCombo->Bind(wxEVT_COMBOBOX, &YuGiOhBandaiCardEditDialog::onSetSelectionChanged, this); + } +} + +void YuGiOhBandaiCardEditDialog::readExtraFromCard() { + clearCachedPrintVariants(); + if (setNoCtrl_) setNoCtrl_->ChangeValue(wxString::FromUTF8(constCard().setNo.c_str())); + applyRarityStringToChoice(constCard().rarity); + if (holoCheck_) holoCheck_->SetValue(constCard().holo); + if (signedCheck_) signedCheck_->SetValue(constCard().signed_); + if (alteredCheck_) alteredCheck_->SetValue(constCard().altered); +} + +void YuGiOhBandaiCardEditDialog::writeExtraToCard() { + if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString(wxConvUTF8); + if (rarityChoice_) mutableCard().rarity = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8); + if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked(); + if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked(); + if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked(); +} + +void YuGiOhBandaiCardEditDialog::applyRarityStringToChoice(const std::string& rarity) { + if (!rarityChoice_) return; + if (rarity.empty()) { + rarityChoice_->SetSelection(0); + return; + } + const wxString wxRare = wxString::FromUTF8(rarity.c_str()); + int idx = rarityChoice_->FindString(wxRare); + if (idx == wxNOT_FOUND) { + rarityChoice_->Append(wxRare); + idx = rarityChoice_->GetCount() - 1; + } + if (idx != wxNOT_FOUND) rarityChoice_->SetSelection(idx); +} + +void YuGiOhBandaiCardEditDialog::maybeAutoCheckHoloForRarity(const std::string& rarity) { + if (rarity == "Holo Seal" && holoCheck_ != nullptr) { + holoCheck_->SetValue(true); + } +} + +void YuGiOhBandaiCardEditDialog::onRarityChoiceChanged(wxCommandEvent&) { + if (!rarityChoice_) return; + maybeAutoCheckHoloForRarity(rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8)); +} + +std::size_t YuGiOhBandaiCardEditDialog::findAvailableSetIndex(const std::string& setId) const { + const auto& available = availableSets(); + auto lowerAscii = [](std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return s; + }; + const std::string target = lowerAscii(setId); + for (std::size_t i = 0; i < available.size(); ++i) { + if (lowerAscii(available[i].id) == target) return i; + } + return available.size(); +} + +void YuGiOhBandaiCardEditDialog::applyDetectedPrint(const AutoDetectedPrint& print) { + if (!print.name.empty()) { + if (auto* name = nameControl()) { + name->ChangeValue(wxString::FromUTF8(print.name.c_str())); + } + } + if (!print.setNo.empty() && setNoCtrl_) { + setNoCtrl_->ChangeValue(wxString::FromUTF8(print.setNo.c_str())); + } + if (!print.rarity.empty()) { + applyRarityStringToChoice(print.rarity); + maybeAutoCheckHoloForRarity(print.rarity); + } + if (!print.language.empty()) { + if (auto lang = languageFromString(print.language)) { + for (const Language l : languagesForChoice()) { + if (l != *lang) continue; + if (auto* choice = languageChoiceControl()) { + const wxString wanted = + wxString::FromUTF8(std::string(to_string(*lang)).c_str()); + const int idx = choice->FindString(wanted); + if (idx != wxNOT_FOUND) choice->SetSelection(idx); + } + break; + } + } + } + if (!print.setId.empty()) { + const std::size_t idx = findAvailableSetIndex(print.setId); + if (idx < availableSets().size()) { + applySetSelectionByIndex(idx); + } + } +} + +void YuGiOhBandaiCardEditDialog::clearCachedPrintVariants() { + ++variantFetchEpoch_; + cachedVariants_.clear(); + variantRingPos_ = 0; + refreshVariantNextControls(); +} + +void YuGiOhBandaiCardEditDialog::refreshVariantNextControls() { + if (!nextSetNoBtn_) return; + nextSetNoBtn_->Show(cachedVariants_.size() > 1); + Layout(); + if (GetSizer()) Fit(); +} + +void YuGiOhBandaiCardEditDialog::scheduleDeferredVariantPrefetch() { + const unsigned epoch = variantFetchEpoch_; + wxTheApp->CallAfter([this, epoch]() { + prefetchVariantsForCurrentCardSilent(epoch); + }); +} + +void YuGiOhBandaiCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) { + if (capturedEpoch != variantFetchEpoch_) return; + if (!cachedVariants_.empty()) return; + const auto& card = constCard(); + if (card.name.empty()) return; + requestByNameAsync(capturedEpoch, card.name, card.set.id, false); +} + +void YuGiOhBandaiCardEditDialog::requestByNameAsync(unsigned capturedEpoch, std::string name, + std::string setId, bool showFailureDialog) { + if (capturedEpoch != variantFetchEpoch_) return; + if (showFailureDialog && autoRarityBtn_) autoRarityBtn_->Disable(); + + auto state = variantFetchState_; + CardPreviewService* svc = &cardPreview_; + YuGiOhBandaiCardEditDialog* self = this; + std::thread([state, svc, self, capturedEpoch, name = std::move(name), + setId = std::move(setId), showFailureDialog]() { + auto detected = svc->detectPrintVariants(Game::YuGiOhBandai, name, setId); + wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected), + showFailureDialog]() mutable { + if (!state->alive.load()) return; + self->applyDetectedList(capturedEpoch, std::move(detected), showFailureDialog, + /*applyFirst=*/true); + }); + }).detach(); +} + +void YuGiOhBandaiCardEditDialog::requestByNoAsync(unsigned capturedEpoch, std::string setNo, + bool showFailureDialog) { + if (capturedEpoch != variantFetchEpoch_) return; + if (showFailureDialog && autoSetNoBtn_) autoSetNoBtn_->Disable(); + + auto state = variantFetchState_; + CardPreviewService* svc = &cardPreview_; + YuGiOhBandaiCardEditDialog* self = this; + std::thread([state, svc, self, capturedEpoch, setNo = std::move(setNo), + showFailureDialog]() { + auto detected = svc->detectVariantsBySetNo(Game::YuGiOhBandai, setNo); + wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected), + showFailureDialog]() mutable { + if (!state->alive.load()) return; + self->applyDetectedList(capturedEpoch, std::move(detected), showFailureDialog, + /*applyFirst=*/true); + }); + }).detach(); +} + +void YuGiOhBandaiCardEditDialog::applyDetectedList( + unsigned capturedEpoch, + Result> detected, + bool showFailureDialog, + bool applyFirst) { + if (capturedEpoch != variantFetchEpoch_) return; + + if (autoSetNoBtn_) autoSetNoBtn_->Enable(); + if (autoRarityBtn_) autoRarityBtn_->Enable(); + + if (!detected) { + if (showFailureDialog) { + showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), + "Auto detect", wxOK | wxICON_WARNING); + } + return; + } + if (detected.value().empty()) { + if (showFailureDialog) { + showThemedMessageDialog(this, "No matching Yu-Gi-Oh! (Bandai) card found.", + "Auto detect", wxOK | wxICON_INFORMATION); + } + return; + } + + cachedVariants_ = std::move(detected).value(); + variantRingPos_ = 0; + if (applyFirst) applyDetectedPrint(cachedVariants_.front()); + refreshVariantNextControls(); +} + +void YuGiOhBandaiCardEditDialog::onAutoDetectBySetNo(wxCommandEvent&) { + syncCardFromControls(); + const std::string setNo = + setNoCtrl_ ? setNoCtrl_->GetValue().ToStdString(wxConvUTF8) : std::string(); + if (setNo.empty()) { + showThemedMessageDialog(this, "Enter a Bandai number first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + const unsigned epoch = variantFetchEpoch_; + requestByNoAsync(epoch, setNo, true); +} + +void YuGiOhBandaiCardEditDialog::onNextSetNo(wxCommandEvent&) { + if (cachedVariants_.size() <= 1) return; + variantRingPos_ = (variantRingPos_ + 1) % cachedVariants_.size(); + applyDetectedPrint(cachedVariants_[variantRingPos_]); +} + +void YuGiOhBandaiCardEditDialog::onAutoDetectByName(wxCommandEvent&) { + syncCardFromControls(); + const auto& card = constCard(); + if (card.name.empty()) { + showThemedMessageDialog(this, "Enter a card name first.", "Auto detect", + wxOK | wxICON_INFORMATION); + return; + } + std::string setId; + if (const Set* set = selectedSetFromControls()) setId = set->id; + + const unsigned epoch = variantFetchEpoch_; + requestByNameAsync(epoch, card.name, setId, true); +} + +void YuGiOhBandaiCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) { + clearCachedPrintVariants(); + scheduleDeferredVariantPrefetch(); + ev.Skip(); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhBandaiCardListPanel.cpp b/ui_wx/src/YuGiOhBandaiCardListPanel.cpp new file mode 100644 index 0000000..6aa081d --- /dev/null +++ b/ui_wx/src/YuGiOhBandaiCardListPanel.cpp @@ -0,0 +1,73 @@ +#include "ccm/ui/YuGiOhBandaiCardListPanel.hpp" + +#include "ccm/services/CardFilter.hpp" +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +YuGiOhBandaiCardListPanel::YuGiOhBandaiCardListPanel(wxWindow* parent) + : BaseCardListPanel(parent) { + buildLayout(); +} + +std::vector +YuGiOhBandaiCardListPanel::declareTextColumns() const { + return { + {"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Name}, + {"Set", 150, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::SetReleaseDate}, + {"No.", 70, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::SetNo}, + {"Rarity", 100, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Rarity}, + {"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhBandaiSortColumn::Amount}, + {"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Condition}, + {"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Language}, + {"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhBandaiSortColumn::Note}, + }; +} + +std::vector +YuGiOhBandaiCardListPanel::declareIconColumns() const { + constexpr int kFlagColWidth = 36; + return { + {kSvgHolo, kFlagColWidth, YuGiOhBandaiSortColumn::Holo}, + {kSvgSigned, kFlagColWidth, YuGiOhBandaiSortColumn::Signed}, + {kSvgAltered, kFlagColWidth, YuGiOhBandaiSortColumn::Altered}, + }; +} + +std::string YuGiOhBandaiCardListPanel::renderTextCell(const YuGiOhBandaiCard& card, + std::size_t idx) const { + switch (idx) { + case 0: return card.name; + case 1: return card.set.name; + case 2: return card.setNo; + case 3: return card.rarity; + case 4: return std::to_string(card.amount); + case 5: return std::string(to_string(card.condition)); + case 6: return std::string(to_string(card.language)); + case 7: return card.note; + } + return {}; +} + +bool YuGiOhBandaiCardListPanel::isIconColumnSet(const YuGiOhBandaiCard& card, + std::size_t idx) const { + switch (idx) { + case 0: return card.holo; + case 1: return card.signed_; + case 2: return card.altered; + } + return false; +} + +void YuGiOhBandaiCardListPanel::sortBy(YuGiOhBandaiSortColumn column, bool ascending) { + sortYuGiOhBandaiCards(mutableCards(), column, ascending); +} + +bool YuGiOhBandaiCardListPanel::matchesFilter(const YuGiOhBandaiCard& card, + std::string_view filter) const { + return matchesYuGiOhBandaiFilter(card, filter); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhBandaiGameView.cpp b/ui_wx/src/YuGiOhBandaiGameView.cpp new file mode 100644 index 0000000..3925e6e --- /dev/null +++ b/ui_wx/src/YuGiOhBandaiGameView.cpp @@ -0,0 +1,552 @@ +#include "ccm/ui/YuGiOhBandaiGameView.hpp" + +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" +#include "ccm/ui/CardEditModalGuard.hpp" +#include "ccm/ui/YuGiOhBandaiCardEditDialog.hpp" +#include "ccm/ui/YuGiOhBandaiCardListPanel.hpp" +#include "ccm/ui/YuGiOhBandaiSelectedCardPanel.hpp" +#include "ccm/ui/YuGiOhBandaiSetCompletionPanel.hpp" +#include "ccm/ui/SvgIcons.hpp" +#include "ccm/ui/Theme.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ccm::ui { + +namespace { +constexpr int kBandaiToolbarIconPx = 18; +constexpr const char kBandaiFilterHint[] = "Filter"; + +wxColour lighten(const wxColour& c, int amount) { + auto lift = [amount](unsigned char channel) -> unsigned char { + const int raised = static_cast(channel) + amount; + return static_cast(raised > 255 ? 255 : raised); + }; + return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue())); +} + +wxColour darken(const wxColour& c, int amount) { + auto drop = [amount](unsigned char channel) -> unsigned char { + const int lowered = static_cast(channel) - amount; + return static_cast(lowered < 0 ? 0 : lowered); + }; + return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue())); +} +} // namespace + +YuGiOhBandaiGameView::YuGiOhBandaiGameView(ConfigService& config, + CollectionService& collection, + SetService& sets, + ImageService& images, + CardPreviewService& cardPreview, + IGameModule& module, + YuGiOhBandaiSetCatalogService& catalogStore) + : config_(config), + collection_(collection), + sets_(sets), + images_(images), + cardPreview_(cardPreview), + module_(module), + catalogStore_(catalogStore) {} + +void YuGiOhBandaiGameView::ensureSetsLoaded() { + if (attemptedInitialSetLoad_) return; + attemptedInitialSetLoad_ = true; + + auto cached = sets_.getSets(Game::YuGiOhBandai); + if (cached) { + setsCache_ = std::move(cached).value(); + if (!setsCache_.empty()) return; + } else { + setsCache_.clear(); + } + + auto refreshed = sets_.updateSets(Game::YuGiOhBandai); + if (refreshed) { + setsCache_ = std::move(refreshed).value(); + } +} + +void YuGiOhBandaiGameView::refreshSetCompletionFromStore() { + if (setCompletionPanel_ == nullptr) return; + setCompletionPanel_->reloadFromStore(); + if (auto loaded = collection_.list(Game::YuGiOhBandai)) { + setCompletionPanel_->setCollection(std::move(loaded).value()); + } +} + +bool YuGiOhBandaiGameView::downloadSetsAndCatalog(wxWindow* parentWindow, + std::size_t* setCountOut, + std::size_t* packCountOut) { + auto* bandaiSrc = dynamic_cast(&module_.setSource()); + if (bandaiSrc == nullptr) { + showThemedMessageDialog(parentWindow, "Yu-Gi-Oh! (Bandai) set source unavailable.", + "Error", wxOK | wxICON_ERROR); + return false; + } + + auto both = bandaiSrc->fetchAllWithCatalog(); + if (!both) { + showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(), + "Error", wxOK | wxICON_ERROR); + return false; + } + + auto savedSets = sets_.saveSets(Game::YuGiOhBandai, both.value().sets); + if (!savedSets) { + showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(), + "Error", wxOK | wxICON_ERROR); + return false; + } + + auto savedCatalog = catalogStore_.save(both.value().catalog); + if (!savedCatalog) { + showThemedMessageDialog(parentWindow, + "Sets saved, but set catalog failed: " + savedCatalog.error(), + "Warning", wxOK | wxICON_WARNING); + return false; + } + + setsCache_ = both.value().sets; + if (setCountOut != nullptr) *setCountOut = both.value().sets.size(); + if (packCountOut != nullptr) *packCountOut = both.value().catalog.packs.size(); + refreshSetCompletionFromStore(); + return true; +} + +bool YuGiOhBandaiGameView::ensureCatalogLoaded(wxWindow* parentWindow) { + if (catalogStore_.exists()) return true; + return downloadSetsAndCatalog(parentWindow, nullptr, nullptr); +} + +void YuGiOhBandaiGameView::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 YuGiOhBandaiGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) { + auto* toolbar = new wxBoxSizer(wxHORIZONTAL); + auto makeToolBtn = [&](const char* svg, const wxString& tip) { + wxBitmap bmp = svgIconBitmap(svg, kBandaiToolbarIconPx, "#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(kBandaiFilterHint); + 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)); + }); + filterInput_->Bind(wxEVT_KEY_DOWN, [this](wxKeyEvent& ev) { + const int code = ev.GetKeyCode(); + if (code == WXK_UP || code == WXK_DOWN) { + nudgeSelection(code == WXK_UP ? -1 : 1); + return; + } + ev.Skip(); + }); +} + +void YuGiOhBandaiGameView::refreshToolbarIcons(const ThemePalette& palette) { + const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); + if (toolbarButtons_[0]) { + toolbarButtons_[0]->SetBitmap( + svgIconBitmap(kSvgToolbarAdd, kBandaiToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[1]) { + toolbarButtons_[1]->SetBitmap( + svgIconBitmap(kSvgToolbarEdit, kBandaiToolbarIconPx, tbHex.c_str())); + } + if (toolbarButtons_[2]) { + toolbarButtons_[2]->SetBitmap( + svgIconBitmap(kSvgToolbarDelete, kBandaiToolbarIconPx, tbHex.c_str())); + } +} + +void YuGiOhBandaiGameView::selectTab(int index) { + if (index < 0 || index > 1 || book_ == nullptr) return; + activeTab_ = index; + book_->SetSelection(index); + refreshTabBarTheme(paletteForTheme(config_.current().theme)); + + // Bandai sets.json can load offline from the hardcoded manifest; the + // set-completion catalog needs a Yugipedia fetch. Pull it on first visit. + if (index == 1 && !catalogStore_.exists()) { + wxWindow* owner = wxGetTopLevelParent(contentPanel_); + // Errors are shown inside ensureCatalogLoaded; empty-state UI remains if it fails. + (void)ensureCatalogLoaded(owner != nullptr ? owner : contentPanel_); + } +} + +void YuGiOhBandaiGameView::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 YuGiOhBandaiGameView::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* YuGiOhBandaiGameView::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 YuGiOhBandaiSetCompletionPanel(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* YuGiOhBandaiGameView::listPanel(wxWindow* parent) { + if (listPanel_ == nullptr) { + listPanel_ = new YuGiOhBandaiCardListPanel(parent); + listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) { + if (selectedPanel_ != nullptr && listPanel_ != nullptr) { + selectedPanel_->setCard(listPanel_->selected()); + } + }); + listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(listPanel_); + onEditCard(owner != nullptr ? owner : static_cast(listPanel_)); + }); + } + return listPanel_; +} + +wxPanel* YuGiOhBandaiGameView::selectedPanel(wxWindow* parent) { + if (selectedPanel_ == nullptr) { + selectedPanel_ = new YuGiOhBandaiSelectedCardPanel(parent, images_, cardPreview_); + } + return selectedPanel_; +} + +void YuGiOhBandaiGameView::refreshCollection(std::optional selectId) { + // Ensure the Bandai 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::YuGiOhBandai); + if (!loaded) { + showThemedMessageDialog( + nullptr, + "Failed to load Yu-Gi-Oh! (Bandai) collection: " + loaded.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + auto cards = std::move(loaded).value(); + if (listPanel_ != nullptr) { + listPanel_->setCards(cards, selectId); + listPanel_->activateSelection(); + if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); + } + if (setCompletionPanel_ != nullptr) { + setCompletionPanel_->setCollection(std::move(cards)); + } +} + +const std::vector& YuGiOhBandaiGameView::setsForDialog() { + ensureSetsLoaded(); + if (!setsCache_.empty()) return setsCache_; + auto loaded = sets_.getSets(Game::YuGiOhBandai); + if (loaded) setsCache_ = std::move(loaded).value(); + else setsCache_.clear(); + return setsCache_; +} + +void YuGiOhBandaiGameView::onAddCard(wxWindow* parentWindow) { + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION); + return; + } + YuGiOhBandaiCard fresh; + fresh.amount = 1; + fresh.condition = Condition::NearMint; + // language defaults to Language::Japanese via the domain type. + + YuGiOhBandaiCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, + fresh, &setsForDialog()); + themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; + if (dlg.ShowModal() != wxID_OK) return; + + auto added = collection_.add(Game::YuGiOhBandai, dlg.card()); + if (!added) { + showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + + YuGiOhBandaiCard persisted = dlg.card(); + persisted.id = added.value(); + auto normalized = images_.normalizeNamesForPersistedCard( + Game::YuGiOhBandai, persisted.id, persisted.set.name, persisted.name, persisted.images); + if (normalized) { + if (normalized.value() != persisted.images) { + persisted.images = std::move(normalized).value(); + auto updated = collection_.update(Game::YuGiOhBandai, persisted); + if (!updated) { + 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); + } + refreshCollection(added.value()); +} + +void YuGiOhBandaiGameView::onEditCard(wxWindow* parentWindow) { + if (listPanel_ == nullptr) return; + auto sel = listPanel_->selected(); + if (!sel) { + showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", + wxOK | wxICON_INFORMATION); + return; + } + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION); + return; + } + YuGiOhBandaiCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, + *sel, &setsForDialog()); + themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; + if (dlg.ShowModal() != wxID_OK) return; + auto updated = collection_.update(Game::YuGiOhBandai, dlg.card()); + if (!updated) { + showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + refreshCollection(); +} + +void YuGiOhBandaiGameView::onDeleteCard(wxWindow* parentWindow) { + if (listPanel_ == nullptr) return; + auto sel = listPanel_->selected(); + if (!sel) { + showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", + wxOK | wxICON_INFORMATION); + return; + } + if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", + "Confirm") != wxID_YES) { + return; + } + auto removed = collection_.remove(Game::YuGiOhBandai, sel->id); + if (!removed) { + showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(), + "Error", wxOK | wxICON_ERROR); + return; + } + refreshCollection(); +} + +std::string YuGiOhBandaiGameView::onUpdateSets(wxWindow* parentWindow) { + std::size_t setCount = 0; + std::size_t packCount = 0; + if (!downloadSetsAndCatalog(parentWindow, &setCount, &packCount)) { + return "Update failed"; + } + + showThemedMessageDialog( + parentWindow, + "Updated " + std::to_string(setCount) + " Yu-Gi-Oh! (Bandai) sets and " + + std::to_string(packCount) + " set checklists.", + "Sets updated", wxOK | wxICON_INFORMATION); + return "Yu-Gi-Oh! (Bandai) sets updated."; +} + +void YuGiOhBandaiGameView::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(kBandaiFilterHint); + filterInput_->Refresh(); + } + } + } + if (listPanel_) listPanel_->setFilter(filter); +} + +void YuGiOhBandaiGameView::nudgeSelection(int delta) { + if (listPanel_) listPanel_->nudgeSelection(delta); +} + +void YuGiOhBandaiGameView::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); + applyPaletteToTextCtrl(filterInput_, palette, config_.current().theme); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhBandaiSelectedCardPanel.cpp b/ui_wx/src/YuGiOhBandaiSelectedCardPanel.cpp new file mode 100644 index 0000000..aa5c1de --- /dev/null +++ b/ui_wx/src/YuGiOhBandaiSelectedCardPanel.cpp @@ -0,0 +1,83 @@ +#include "ccm/ui/YuGiOhBandaiSelectedCardPanel.hpp" + +#include "ccm/ui/SvgIcons.hpp" + +#include + +namespace ccm::ui { + +namespace { +enum YuGiOhBandaiDetailKey : int { + kName = 0, + kSet, + kSetNo, + kRarity, + kAmount, + kCondition, + kLanguage, + kHolo, + kSigned, + kAltered, +}; +} // namespace + +YuGiOhBandaiSelectedCardPanel::YuGiOhBandaiSelectedCardPanel(wxWindow* parent, + ImageService& imageService, + CardPreviewService& cardPreview) + : BaseSelectedCardPanel(parent, imageService, cardPreview) { + buildLayout(); +} + +std::vector +YuGiOhBandaiSelectedCardPanel::declareDetailRows() const { + return { + {"Name", kName, "(no card selected)"}, + {"Set", kSet, ""}, + {"No.", kSetNo, ""}, + {"Rarity", kRarity, ""}, + {"Amount", kAmount, ""}, + {"Condition", kCondition, ""}, + {"Language", kLanguage, ""}, + }; +} + +std::vector +YuGiOhBandaiSelectedCardPanel::declareFlagIcons() const { + return { + {kSvgHolo, "Holo", kHolo}, + {kSvgSigned, "Signed", kSigned}, + {kSvgAltered, "Altered", kAltered}, + }; +} + +std::string YuGiOhBandaiSelectedCardPanel::detailValueFor(const YuGiOhBandaiCard& card, + DetailKey key) const { + switch (key) { + case kName: return card.name; + case kSet: return card.set.name; + case kSetNo: return card.setNo; + case kRarity: return card.rarity; + case kAmount: return std::to_string(card.amount); + case kCondition: return std::string(to_string(card.condition)); + case kLanguage: return std::string(to_string(card.language)); + case kNoteKey: return card.note; + } + return {}; +} + +bool YuGiOhBandaiSelectedCardPanel::isFlagSet(const YuGiOhBandaiCard& card, + DetailKey key) const { + switch (key) { + case kHolo: return card.holo; + case kSigned: return card.signed_; + case kAltered: return card.altered; + } + return false; +} + +std::tuple +YuGiOhBandaiSelectedCardPanel::previewKey(const YuGiOhBandaiCard& card) const { + return {card.name, card.set.id, card.setNo}; +} + +} // namespace ccm::ui diff --git a/ui_wx/src/YuGiOhBandaiSetCompletionPanel.cpp b/ui_wx/src/YuGiOhBandaiSetCompletionPanel.cpp new file mode 100644 index 0000000..037d33d --- /dev/null +++ b/ui_wx/src/YuGiOhBandaiSetCompletionPanel.cpp @@ -0,0 +1,336 @@ +#include "ccm/ui/YuGiOhBandaiSetCompletionPanel.hpp" + +#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp" +#include "ccm/services/YuGiOhBandaiSetCompletion.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ccm::ui { + +namespace { + +wxColour mutedTextColour(const ThemePalette& palette) { + // Blend text toward panel background so missing checklist rows read as greyed. + const auto blend = [](unsigned char a, unsigned char b) -> unsigned char { + return static_cast((static_cast(a) * 2 + static_cast(b)) / 3); + }; + return wxColour(blend(palette.text.Red(), palette.panelBg.Red()), + blend(palette.text.Green(), palette.panelBg.Green()), + blend(palette.text.Blue(), palette.panelBg.Blue())); +} + +} // namespace + +YuGiOhBandaiSetCompletionPanel::YuGiOhBandaiSetCompletionPanel( + wxWindow* parent, YuGiOhBandaiSetCatalogService& 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, &YuGiOhBandaiSetCompletionPanel::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 YuGiOhBandaiSetCompletionPanel::setCollection(std::vector cards) { + collection_ = std::move(cards); + refreshLanguageChoice(); + rebuildCurrentView(); +} + +void YuGiOhBandaiSetCompletionPanel::reloadFromStore() { + catalogLoaded_ = false; + catalog_ = {}; + if (catalogStore_.exists()) { + if (auto loaded = catalogStore_.load()) { + catalog_ = std::move(loaded).value(); + catalogLoaded_ = true; + } + } + showGridPage(); + rebuildGrid(); +} + +void YuGiOhBandaiSetCompletionPanel::applyTheme(const ThemePalette& palette) { + palette_ = palette; + applyThemeToWindowTree(this, palette, inferThemeFromWindow(this)); + rebuildCurrentView(); +} + +void YuGiOhBandaiSetCompletionPanel::showGridPage() { + detailSetId_.clear(); + detailSetName_.clear(); + book_->SetSelection(0); +} + +void YuGiOhBandaiSetCompletionPanel::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 YuGiOhBandaiSetCompletionPanel::displaySetName(const std::string& setName) const { + if (!languageFilter_.has_value()) return setName; + return setName + " (" + std::string(to_string(*languageFilter_)) + ")"; +} + +void YuGiOhBandaiSetCompletionPanel::refreshLanguageChoice() { + const auto previous = languageFilter_; + const auto present = yuGiOhBandaiLanguagesInCollection(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(i + 1); + languageFilter_ = previous; + break; + } + } + } + languageChoice_->SetSelection(selection); +} + +void YuGiOhBandaiSetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) { + const int sel = languageChoice_->GetSelection(); + if (sel <= 0) { + languageFilter_ = std::nullopt; + } else { + const auto present = yuGiOhBandaiLanguagesInCollection(collection_); + const auto idx = static_cast(sel - 1); + if (idx < present.size()) { + languageFilter_ = present[idx]; + } else { + languageFilter_ = std::nullopt; + languageChoice_->SetSelection(0); + } + } + rebuildCurrentView(); +} + +void YuGiOhBandaiSetCompletionPanel::rebuildCurrentView() { + if (book_->GetSelection() == 1 && !detailSetId_.empty()) { + const auto rows = + computeYuGiOhBandaiSetCompletion(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 YuGiOhBandaiSetCompletionPanel::setEmptyMessage(const wxString& message) { + clearGridTiles(); + emptyLabel_->SetLabelText(message); + emptyLabel_->Wrap(480); + emptyLabel_->Show(); + scroll_->Hide(); + gridPage_->Layout(); +} + +void YuGiOhBandaiSetCompletionPanel::clearGridTiles() { + if (gridSizer_ == nullptr) return; + gridSizer_->Clear(true); +} + +void YuGiOhBandaiSetCompletionPanel::rebuildGrid() { + if (!catalogLoaded_) { + setEmptyMessage(wxString::FromUTF8( + "Set checklists are not downloaded yet.\n" + "Run Sets \xE2\x86\x92 Update Yu-Gi-Oh! (Bandai) to enable Set Completion.")); + return; + } + + const auto rows = computeYuGiOhBandaiSetCompletion(collection_, catalog_, languageFilter_); + if (rows.empty()) { + if (collection_.empty()) { + setEmptyMessage(wxString::FromUTF8( + "No Yu-Gi-Oh! (Bandai) sets in progress yet.\n" + "Add cards on the Single Cards tab to track set completion here.")); + } else { + bool anyCountable = false; + for (const auto& card : collection_) { + if (card.set.id.empty()) continue; + if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo).empty()) continue; + anyCountable = true; + break; + } + if (!anyCountable) { + setEmptyMessage(wxString::FromUTF8( + "Your cards need a set number (No.) to track set completion.\n" + "Edit each card and enter its Bandai number, or use Auto detect.")); + } else { + setEmptyMessage(wxString::FromUTF8( + "None of your cards match a downloaded set checklist.\n" + "Run Sets \xE2\x86\x92 Update Yu-Gi-Oh! (Bandai), and confirm " + "each card's set and No.")); + } + } + 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 YuGiOhBandaiSetCompletionPanel::rebuildChecklist(const std::string& setId) { + checklist_->DeleteAllItems(); + const auto entries = + yuGiOhBandaiChecklistForSet(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. + std::string line = + (entry.owned ? "\xE2\x9C\x93 " : " ") + entry.setNo + " \xE2\x80\x94 " + entry.name; + if (!entry.rarity.empty()) { + line += " (" + entry.rarity + ")"; + } + 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 diff --git a/ui_wx/src/YuGiOhGameView.cpp b/ui_wx/src/YuGiOhGameView.cpp index 543b602..78d3706 100644 --- a/ui_wx/src/YuGiOhGameView.cpp +++ b/ui_wx/src/YuGiOhGameView.cpp @@ -524,13 +524,7 @@ void YuGiOhGameView::applyTheme(const ThemePalette& 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(); - } + applyPaletteToTextCtrl(filterInput_, palette, config_.current().theme); } } // namespace ccm::ui