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)
+
+
+
+
+
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 `