From c9e6bc2b6b77be7ddf93c1dc246ebd590d187364 Mon Sep 17 00:00:00 2001 From: Sebastian Dine Date: Wed, 22 Jul 2026 11:13:42 +0200 Subject: [PATCH] Minor: Add Asian Pokemon Card Support (#18) --- .gitignore | 3 + AGENTS.md | 2 +- app/AGENTS.md | 9 +- app/CMakeLists.txt | 30 +- app/main.cpp | 32 +- core/AGENTS.md | 6 +- core/CMakeLists.txt | 5 + core/include/ccm/domain/Enums.hpp | 31 +- .../ccm/domain/JapanesePokemonCard.hpp | 38 + core/include/ccm/domain/PokemonCard.hpp | 4 +- core/include/ccm/games/IGameModule.hpp | 4 + .../JapanesePokemonCardPreviewSource.hpp | 68 + .../pokemonjp/JapanesePokemonEnCatalog.hpp | 71 + .../pokemonjp/JapanesePokemonGameModule.hpp | 34 + .../pokemonjp/JapanesePokemonSetSource.hpp | 39 + core/include/ccm/infra/JsonSetRepository.hpp | 6 +- core/include/ccm/ports/ISetRepository.hpp | 4 +- core/include/ccm/services/CardFilter.hpp | 7 +- .../ccm/services/CardPreviewService.hpp | 11 +- core/include/ccm/services/CardSorter.hpp | 18 + core/src/domain/Configuration.cpp | 5 + core/src/domain/Enums.cpp | 114 +- core/src/domain/JapanesePokemonCard.cpp | 39 + core/src/domain/PokemonCard.cpp | 3 + .../JapanesePokemonCardPreviewSource.cpp | 442 + .../pokemonjp/JapanesePokemonEnCatalog.cpp | 159 + .../pokemonjp/JapanesePokemonGameModule.cpp | 11 + .../pokemonjp/JapanesePokemonSetSource.cpp | 207 + core/src/infra/JsonSetRepository.cpp | 51 +- core/src/services/CardFilter.cpp | 17 + core/src/services/CardPreviewService.cpp | 60 +- core/src/services/CardSorter.cpp | 70 + core/src/services/SetService.cpp | 8 +- docs/AGENTS.md | 2 +- docs/adding-a-new-game.md | 2 +- docs/assets-and-info-apis.md | 121 +- docs/dow-doc-build-locally.md | 2 +- tests/AGENTS.md | 2 +- tests/CMakeLists.txt | 3 + tests/card_filter_tests.cpp | 47 + tests/card_preview_service_tests.cpp | 66 + tests/card_sorter_tests.cpp | 64 + tests/domain_json_tests.cpp | 128 + tests/game_module_tests.cpp | 12 + ...nese_pokemon_card_preview_source_tests.cpp | 593 + tests/japanese_pokemon_en_catalog_tests.cpp | 152 + tests/japanese_pokemon_set_source_tests.cpp | 317 + tests/json_set_repository_tests.cpp | 52 +- tests/local_image_store_tests.cpp | 1 + tests/set_service_tests.cpp | 38 + tools/pokemon_jp/README.md | 147 + .../enrich_neo_image_urls.cpython-314.pyc | Bin 0 -> 15405 bytes ...ch_unnumbered_promo_images.cpython-314.pyc | Bin 0 -> 30146 bytes tools/pokemon_jp/_bulba_fossils.txt | 1 + tools/pokemon_jp/_tcgdex_sets.json | 1302 + tools/pokemon_jp/build_catalog.py | 81 + tools/pokemon_jp/build_set_en_catalog.py | 160 + tools/pokemon_jp/classic_missing_prints.json | 5502 ++ tools/pokemon_jp/classic_missing_sets.json | 57 + tools/pokemon_jp/enrich_neo_image_urls.py | 289 + tools/pokemon_jp/enrich_preview_images.py | 471 + .../enrich_unnumbered_promo_images.py | 567 + tools/pokemon_jp/fetch_classic_gym_images.py | 76 + tools/pokemon_jp/fetch_serebii_set_names.py | 115 + tools/pokemon_jp/harvest_unnumbered_promos.py | 239 + tools/pokemon_jp/merge_classic_missing.py | 114 + tools/pokemon_jp/merge_set_en_catalog.py | 77 + tools/pokemon_jp/non_pokemon_en_by_ja.json | 263 + tools/pokemon_jp/set_en_names.json | 175 + tools/pokemon_jp/species_en.json | 1027 + ui_wx/AGENTS.md | 6 +- ui_wx/CMakeLists.txt | 1 + .../pokemon_jp_classic/TamamushiCG/016.jpg | Bin 0 -> 48591 bytes ui_wx/assets/pokemon_jp_en_catalog.json | 63513 ++++++++++++++++ ui_wx/include/ccm/ui/AppContext.hpp | 2 + ui_wx/include/ccm/ui/BaseCardEditDialog.hpp | 57 +- .../include/ccm/ui/BaseSelectedCardPanel.hpp | 11 +- .../include/ccm/ui/PokemonCardEditDialog.hpp | 45 +- ui_wx/include/ccm/ui/PokemonGameView.hpp | 11 +- .../ccm/ui/PokemonSelectedCardPanel.hpp | 3 + .../ccm/ui/VariantImagePreviewDialog.hpp | 45 + ui_wx/src/MainFrame.cpp | 9 +- ui_wx/src/PokemonCardEditDialog.cpp | 371 +- ui_wx/src/PokemonGameView.cpp | 95 +- ui_wx/src/PokemonSelectedCardPanel.cpp | 3 + ui_wx/src/SettingsDialog.cpp | 9 +- ui_wx/src/VariantImagePreviewDialog.cpp | 176 + 87 files changed, 78068 insertions(+), 162 deletions(-) create mode 100644 core/include/ccm/domain/JapanesePokemonCard.hpp create mode 100644 core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp create mode 100644 core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp create mode 100644 core/include/ccm/games/pokemonjp/JapanesePokemonGameModule.hpp create mode 100644 core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp create mode 100644 core/src/domain/JapanesePokemonCard.cpp create mode 100644 core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp create mode 100644 core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp create mode 100644 core/src/games/pokemonjp/JapanesePokemonGameModule.cpp create mode 100644 core/src/games/pokemonjp/JapanesePokemonSetSource.cpp create mode 100644 tests/japanese_pokemon_card_preview_source_tests.cpp create mode 100644 tests/japanese_pokemon_en_catalog_tests.cpp create mode 100644 tests/japanese_pokemon_set_source_tests.cpp create mode 100644 tools/pokemon_jp/README.md create mode 100644 tools/pokemon_jp/__pycache__/enrich_neo_image_urls.cpython-314.pyc create mode 100644 tools/pokemon_jp/__pycache__/enrich_unnumbered_promo_images.cpython-314.pyc create mode 100644 tools/pokemon_jp/_bulba_fossils.txt create mode 100644 tools/pokemon_jp/_tcgdex_sets.json create mode 100644 tools/pokemon_jp/build_catalog.py create mode 100644 tools/pokemon_jp/build_set_en_catalog.py create mode 100644 tools/pokemon_jp/classic_missing_prints.json create mode 100644 tools/pokemon_jp/classic_missing_sets.json create mode 100644 tools/pokemon_jp/enrich_neo_image_urls.py create mode 100644 tools/pokemon_jp/enrich_preview_images.py create mode 100644 tools/pokemon_jp/enrich_unnumbered_promo_images.py create mode 100644 tools/pokemon_jp/fetch_classic_gym_images.py create mode 100644 tools/pokemon_jp/fetch_serebii_set_names.py create mode 100644 tools/pokemon_jp/harvest_unnumbered_promos.py create mode 100644 tools/pokemon_jp/merge_classic_missing.py create mode 100644 tools/pokemon_jp/merge_set_en_catalog.py create mode 100644 tools/pokemon_jp/non_pokemon_en_by_ja.json create mode 100644 tools/pokemon_jp/set_en_names.json create mode 100644 tools/pokemon_jp/species_en.json create mode 100644 ui_wx/assets/pokemon_jp_classic/TamamushiCG/016.jpg create mode 100644 ui_wx/assets/pokemon_jp_en_catalog.json create mode 100644 ui_wx/include/ccm/ui/VariantImagePreviewDialog.hpp create mode 100644 ui_wx/src/VariantImagePreviewDialog.cpp diff --git a/.gitignore b/.gitignore index 50a0ac1..3aafb1f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ config.json configure.log build.log test.log + +# Offline ETL caches (large third-party extracts) +tools/pokemon_jp/_tcgdex_cards_database/ diff --git a/AGENTS.md b/AGENTS.md index 9386bc3..be09f33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ Run from the **workspace root**. - Card preview round-trips are slow (HTTPS handshake + image GET, often two hosts). The three amortizations in place — all game-agnostic — must stay. The full update mechanic (key-driven invalidation, positive↔negative same-key replacement, eviction, manual cache clearing) is documented in `docs/caching.md` → "Updating cached entries"; do **not** add a side-channel `clearCache(...)` API to `CardPreviewService` — keep updates flowing through cache keys so the in-memory and disk tiers stay aligned automatically. - `CardPreviewService` keeps a bounded in-memory LRU (`kCacheCapacity`) of preview bytes keyed by `(game, name, setId, setNo)` plus a by-URL cache for the per-game card-back fallback. Re-selecting a row already viewed in this session is decode-only, no HTTP. Source failures are split by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is **negative-cached** so subsequent clicks short-circuit to the card-back placeholder without HTTP, while `Transient` (HTTP/network/parse) is **never** cached so a brief outage can recover on the next selection. Editing a lookup-relevant field changes the cache key and invalidates the negative entry automatically. - `LocalPreviewByteCache` (port `IPreviewByteCache`) extends the LRU with an on-disk byte cache rooted at `/.cache/preview-cache/` — **next to the executable, in the same scope as `config.json`, NOT inside the user-configurable `dataStorage` path** so previews don't follow the user's collection when the data-storage path is reconfigured (the umbrella `.cache/` directory is reserved for any future computed-from-network caches). Both positive previews and `NotFound` verdicts **survive app restarts**. Lookup order is memory → disk → source/HTTP; a disk hit (positive or negative) is promoted into the in-memory tier so the follow-up call stays decode-only. Total `.bin` payload size is capped (default 64 MiB) and oldest-by-mtime entries are evicted when a new write would exceed the cap; tiny `.neg` markers are not counted against the cap. The persistent tier is fire-and-forget: any I/O error is swallowed by the adapter so disk problems can never break the preview path. - - `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview. + - `CprHttpClient` owns a single long-lived `cpr::Session` (and therefore a single libcurl easy handle) with keep-alive enabled, so repeat HTTPS calls to the same host (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) reuse the existing TLS connection. Concurrent callers are serialized through a mutex — easy handles are not thread-safe and the preview path is single-flight already. Session default **`Accept: */*`** keeps JSON info APIs and binary image GETs on one client; **`CardPreviewService::fetchAndCache`** rejects empty HTTP bodies so a bogus 200 cannot masquerade as a cached preview. ## Windows UI theming guardrails diff --git a/app/AGENTS.md b/app/AGENTS.md index 4062cb8..f9e1da4 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -5,14 +5,14 @@ The `ccm` executable — composition root only. The single place where concrete ## File pointers - `main.cpp` — the entire app. Defines `CcmApp : public wxApp`, builds the dependency graph in `OnInit()`, then hands an `AppContext` to `MainFrame`. -- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$/assets/` and copies `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` there so Yu-Gi-Oh! / Digi-Battle preview fallbacks work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`). +- `CMakeLists.txt` — declares the `ccm` target. Sets `WIN32_EXECUTABLE TRUE` on Windows so no console window appears. Links `ccm_core`, `ccm_ui_wx`, `ccm_warnings`. **`POST_BUILD`**: creates `$/assets/` and copies `ui_wx/assets/ygo_card_back.png`, `ui_wx/assets/digibattle99_card_back.png`, and `ui_wx/assets/pokemon_jp_en_catalog.json` there so Yu-Gi-Oh! / Digi-Battle preview fallbacks and Japanese Pokémon EN names work offline (see `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`). ## Conventions -1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `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`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. 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`). +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`). 5. **`config.json` location** is the executable's parent directory, resolved via `wxStandardPaths::Get().GetExecutablePath()`. Do not change this — existing installations rely on that location. 6. **Image format handlers** must be registered via `wxImage::AddHandler(new wxPNGHandler)` and `new wxJPEGHandler` before any image is loaded. They are added in `OnInit()` first thing — keep it that way. 7. **Card preview source ownership** lives inside the `IGameModule`. The composition root never constructs an `CardPreviewSource` directly; it calls `previewSvc_->registerModule(*Mod_)` and the service pulls the module's preview source via `IGameModule::cardPreviewSource()` (returning `nullptr` is silently skipped). @@ -21,7 +21,8 @@ The `ccm` executable — composition root only. The single place where concrete ## Required follow-ups -- The **`POST_BUILD` copy of `ygo_card_back.png` / `digibattle99_card_back.png`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`. +- The **`POST_BUILD` copy of `ygo_card_back.png` / `digibattle99_card_back.png` / `pokemon_jp_en_catalog.json`** must stay in sync with `ui_wx/assets/`; if you relocate install layout or add more bundled assets, mirror the pattern (`make_directory` + `copy_if_different`) and document under `docs/assets-and-info-apis.md` / `ui_wx/AGENTS.md`. +- On MinGW-w64 Windows, POST_BUILD also copies `libstdc++-6.dll` / `libgcc_s_seh-1.dll` / `libwinpthread-1.dll` from the compiler directory into `$` so the exe does not load a mismatched runtime from `PATH`. - After adding a new game module you **must**: (1) add a `unique_ptr<GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(Mod_.get())`, (4) call `previewSvc_->registerModule(*Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<Card>` + `CollectionService<Card>` if the game has a custom card type, (7) construct a `GameView` and append its raw pointer to the `AppContext::gameViews` vector, (8) make sure the view's `unique_ptr<>` member sits **after** all its deps (typed services + `IGameModule`). - After adding a new core service you **must** add a `unique_ptr<...>` member, construct it in `OnInit()` after its deps, and add a reference field to `AppContext`. - After adding a new dependency edge you **must** verify destruction order is still correct: deps **before** dependents in the member list. diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 1f2e059..b4a3a35 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -19,13 +19,37 @@ target_link_libraries(ccm ccm_warnings ) -# Yu-Gi-Oh! / Digi-Battle preview fallback images (used when network card-back -# URLs fail or no public URL exists). +# Yu-Gi-Oh! / Digi-Battle preview fallback images and the Japanese Pokémon +# EN name catalog (used when network card-back URLs fail or no public URL +# exists / for JP English display names). add_custom_command(TARGET ccm POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets" + COMMAND ${CMAKE_COMMAND} -E make_directory "$/assets/pokemon_jp_classic" COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png" "$/assets/ygo_card_back.png" COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png" - "$/assets/digibattle99_card_back.png") + "$/assets/digibattle99_card_back.png" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_en_catalog.json" + "$/assets/pokemon_jp_en_catalog.json" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_classic" + "$/assets/pokemon_jp_classic") + +# MinGW-w64: ship the toolchain runtime next to ccm3.exe so Explorer / IDE +# launches do not pick a mismatched libstdc++ off PATH (symptoms: Entry Point +# Not Found for __emutls_v._ZSt11__once_call in libcpr.dll). +if(WIN32 AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + get_filename_component(_ccm_mingw_bin "${CMAKE_CXX_COMPILER}" DIRECTORY) + foreach(_ccm_rt_dll IN ITEMS libstdc++-6.dll libgcc_s_seh-1.dll libwinpthread-1.dll) + if(EXISTS "${_ccm_mingw_bin}/${_ccm_rt_dll}") + add_custom_command(TARGET ccm POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_ccm_mingw_bin}/${_ccm_rt_dll}" + "$/${_ccm_rt_dll}" + VERBATIM) + endif() + endforeach() +endif() diff --git a/app/main.cpp b/app/main.cpp index 03d1815..bfbfb23 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -9,6 +9,8 @@ #include "ccm/games/digibattle99/DigiBattle99GameModule.hpp" #include "ccm/games/magic/MagicGameModule.hpp" #include "ccm/games/pokemon/PokemonGameModule.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp" #include "ccm/games/yugioh/YuGiOhGameModule.hpp" #include "ccm/infra/CprHttpClient.hpp" #include "ccm/infra/JsonCollectionRepository.hpp" @@ -45,10 +47,11 @@ namespace { // need for the repositories to know about concrete game module classes. std::string dirNameForGame(ccm::Game g) { switch (g) { - case ccm::Game::Magic: return "magic"; - case ccm::Game::Pokemon: return "pokemon"; - case ccm::Game::YuGiOh: return "yugioh"; - case ccm::Game::DigiBattle99: return "digibattle99"; + case ccm::Game::Magic: return "magic"; + case ccm::Game::Pokemon: return "pokemon"; + case ccm::Game::YuGiOh: return "yugioh"; + case ccm::Game::DigiBattle99: return "digibattle99"; + case ccm::Game::JapanesePokemon: return "pokemon"; } return "magic"; } @@ -86,6 +89,17 @@ public: ygoMod_ = std::make_unique(*http_); digiBattle99Mod_ = std::make_unique(*http_); + ccm::JapanesePokemonEnCatalog jpCatalog; + { + const auto catalogPath = exeDir / "assets" / "pokemon_jp_en_catalog.json"; + if (auto text = fs_->readText(catalogPath); text) { + if (auto parsed = ccm::JapanesePokemonEnCatalog::parse(text.value()); parsed) { + jpCatalog = std::move(parsed).value(); + } + } + } + jpPokeMod_ = std::make_unique(*http_, std::move(jpCatalog)); + magicRepo_ = std::make_unique>( *fs_, *config_, &dirNameForGame); pokeRepo_ = std::make_unique>( @@ -113,6 +127,7 @@ public: setSvc_->registerModule(pokeMod_.get()); setSvc_->registerModule(ygoMod_.get()); setSvc_->registerModule(digiBattle99Mod_.get()); + setSvc_->registerModule(jpPokeMod_.get()); // Disk-backed preview cache lives next to the executable, in the same // location scope as config.json - NOT inside the user's data-storage @@ -128,11 +143,16 @@ public: previewCache_ = std::make_unique( *fs_, exeDir / ".cache" / "preview-cache"); - previewSvc_ = std::make_unique(*http_, previewCache_.get()); + previewSvc_ = std::make_unique( + *http_, + previewCache_.get(), + fs_.get(), + exeDir / "assets"); previewSvc_->registerModule(*magicMod_); previewSvc_->registerModule(*pokeMod_); previewSvc_->registerModule(*ygoMod_); previewSvc_->registerModule(*digiBattle99Mod_); + previewSvc_->registerModule(*jpPokeMod_); // Per-game UI bundles. Order here is the order shown in the Game menu. magicView_ = std::make_unique( @@ -154,6 +174,7 @@ public: *pokeMod_, *ygoMod_, *digiBattle99Mod_, + *jpPokeMod_, { magicView_.get(), pokeView_.get(), ygoView_.get(), digiBattle99View_.get() }, }); @@ -176,6 +197,7 @@ private: std::unique_ptr pokeMod_; std::unique_ptr ygoMod_; std::unique_ptr digiBattle99Mod_; + std::unique_ptr jpPokeMod_; std::unique_ptr> magicRepo_; std::unique_ptr> pokeRepo_; std::unique_ptr> ygoRepo_; diff --git a/core/AGENTS.md b/core/AGENTS.md index 2e74c00..c2a9e7d 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -4,11 +4,11 @@ ## Layer pointers -- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `DigiBattle99Card`, `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`, `DigiBattle99Card`, `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. - `include/ccm/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/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). They depend only on ports. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. -- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, and `digibattle99/` are the reference implementations — all four expose a fully working set source + card preview source. +- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect. - `include/ccm/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. @@ -27,7 +27,7 @@ 8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result` is a binary buffer, not text, so callers can use it for image payloads directly. 9. **Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.** `YuGiOhCardPreviewSource::fetchImageUrl` queries Yugipedia's MediaWiki API with a batched list of deterministic file names (`----.`) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are **always English** (`EN`/`NA`/`EU`/`AU`) regardless of `card.language`; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see `parseFallbackImageUrl`) for cards Yugipedia hasn't scanned yet, and as the source for `detectFirstPrint` / `detectPrintVariants` (`parsePrintVariants` enumerates distinct printings for the edit dialog). **Do not** restore a YGOPRODeck-only image path: that endpoint's `card_images` array is keyed by art-treatment passcode, not by physical printing, and adding `cardset=` only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; `YuGiOhSelectedCardPanel::previewKey()` packs it into the third tuple slot as `||||<1E|UE>` so the candidate list can prioritize the correct edition without changing the generic `ICardPreviewSource` interface. 10. **Preview byte cache (`CardPreviewService`) is by `(game, name, setId, setNo)` across two tiers, with classified failure caching and a single update mechanic.** Successful `fetchPreviewBytes` results and successful `fetchImageBytesByUrl` results are stored first in a bounded in-memory LRU (`kCacheCapacity` entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (`IPreviewByteCache`, normally `LocalPreviewByteCache` rooted at `/.cache/preview-cache/` — next to the executable, **not** under `dataStorage`, so previews don't follow the user's collection when the data-storage path is reconfigured). **`fetchAndCache` rejects empty response bodies** (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is **memory → disk → source/HTTP**, and a disk hit (positive *or* negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. **Failures are split by `PreviewLookupError::Kind`**: `NotFound` is negative-cached in both tiers (memory `CacheEntry::negative=true`, disk `.neg` marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable previews. Per-game `ICardPreviewSource::fetchImageUrl` implementations must classify their errors honestly — `NotFound` only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is `Transient`. **The cache update mechanic is entirely key-driven and has no side-channel API:** (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (`store` removes any `.neg` for that hash; `storeNegative` removes any `.bin`) so `.bin` and `.neg` for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime `.bin` files on the disk tier; `.neg` markers don't count against the size cap and are not actively evicted). **Do not add a `clearCache(...)` / `invalidate(...)` method** to `CardPreviewService`: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future `editionTag` slot), pack it into one of the existing key fields (see `YuGiOhSelectedCardPanel::previewKey()`'s `||`-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is **fire-and-forget**: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path. -11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact. +11. **`CprHttpClient` keeps one persistent `cpr::Session` for the app's lifetime.** All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (`api.scryfall.com`, `api.pokemontcg.io`, `api.tcgdex.net`, `assets.tcgdex.net`, `product-images.tcgplayer.com`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact. ## Adding a new game diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 43dce74..fc99e45 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(ccm_core STATIC src/domain/PokemonCard.cpp src/domain/YuGiOhCard.cpp src/domain/DigiBattle99Card.cpp + src/domain/JapanesePokemonCard.cpp src/domain/Configuration.cpp src/services/ConfigService.cpp @@ -35,6 +36,10 @@ add_library(ccm_core STATIC src/games/digibattle99/DigiBattle99SetSource.cpp src/games/digibattle99/DigiBattle99CardPreviewSource.cpp src/games/digibattle99/DigiBattle99GameModule.cpp + src/games/pokemonjp/JapanesePokemonEnCatalog.cpp + src/games/pokemonjp/JapanesePokemonSetSource.cpp + src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp + src/games/pokemonjp/JapanesePokemonGameModule.cpp src/util/FsNames.cpp ) diff --git a/core/include/ccm/domain/Enums.hpp b/core/include/ccm/domain/Enums.hpp index dd3854f..085bdb3 100644 --- a/core/include/ccm/domain/Enums.hpp +++ b/core/include/ccm/domain/Enums.hpp @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -20,6 +21,12 @@ enum class Game { Pokemon, YuGiOh, DigiBattle99, + JapanesePokemon, // internal Asia sets/preview routing; not in allGames() +}; + +enum class PokemonRegion { + West, + Asia, }; enum class Language { @@ -28,8 +35,10 @@ enum class Language { French, Spanish, Italian, - Chinese, + SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted) + TraditionalChinese, // JSON / display: "T-Chinese" Japanese, + Korean, Russian, }; @@ -49,24 +58,34 @@ enum class Theme { }; std::string_view to_string(Game g) noexcept; +std::string_view to_string(PokemonRegion r) noexcept; std::string_view to_string(Language l) noexcept; std::string_view to_string(Condition c) noexcept; std::string_view to_string(Theme t) noexcept; -std::optional gameFromString(std::string_view s) noexcept; -std::optional languageFromString(std::string_view s) noexcept; -std::optional conditionFromString(std::string_view s) noexcept; -std::optional themeFromString(std::string_view s) noexcept; +std::optional gameFromString(std::string_view s) noexcept; +std::optional pokemonRegionFromString(std::string_view s) noexcept; +std::optional languageFromString(std::string_view s) noexcept; +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& allLanguages() noexcept; +const std::array& allLanguages() noexcept; const std::array& allConditions() noexcept; const std::array& allThemes() noexcept; +[[nodiscard]] std::span languagesForPokemonRegion(PokemonRegion r) noexcept; +[[nodiscard]] Game pokemonBackendGame(PokemonRegion r) noexcept; +[[nodiscard]] Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept; + // nlohmann/json hooks - serialize as plain strings, matching Rust serde. void to_json(nlohmann::json& j, Game v); void from_json(const nlohmann::json& j, Game& v); +void to_json(nlohmann::json& j, PokemonRegion v); +void from_json(const nlohmann::json& j, PokemonRegion& v); + void to_json(nlohmann::json& j, Language v); void from_json(const nlohmann::json& j, Language& v); diff --git a/core/include/ccm/domain/JapanesePokemonCard.hpp b/core/include/ccm/domain/JapanesePokemonCard.hpp new file mode 100644 index 0000000..825a266 --- /dev/null +++ b/core/include/ccm/domain/JapanesePokemonCard.hpp @@ -0,0 +1,38 @@ +#pragma once + +// JapanesePokemonCard - Japanese Pokémon TCG collection model. +// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered). + +#include "ccm/domain/Enums.hpp" +#include "ccm/domain/Set.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +struct JapanesePokemonCard { + std::uint32_t id{0}; + std::uint8_t amount{1}; + std::string name; + Set set; + std::string setNo; + std::string note; + std::vector images; + Language language{Language::Japanese}; + Condition condition{Condition::NearMint}; + bool firstEdition{false}; + bool holo{false}; + bool signed_{false}; + bool altered{false}; + + friend bool operator==(const JapanesePokemonCard&, const JapanesePokemonCard&) = default; +}; + +void to_json(nlohmann::json& j, const JapanesePokemonCard& c); +void from_json(const nlohmann::json& j, JapanesePokemonCard& c); + +} // namespace ccm diff --git a/core/include/ccm/domain/PokemonCard.hpp b/core/include/ccm/domain/PokemonCard.hpp index 92a1085..1204dec 100644 --- a/core/include/ccm/domain/PokemonCard.hpp +++ b/core/include/ccm/domain/PokemonCard.hpp @@ -1,7 +1,8 @@ #pragma once // PokemonCard - faithful port of pokemon/card_services.rs::Card. -// Same established JSON shape (with `setNo` and `firstEdition` aliases). +// Same established JSON shape (with `setNo` and `firstEdition` aliases), +// plus `region` (West/Asia) for unified West+Asia collections. #include "ccm/domain/Enums.hpp" #include "ccm/domain/Set.hpp" @@ -28,6 +29,7 @@ struct PokemonCard { bool holo{false}; bool signed_{false}; bool altered{false}; + PokemonRegion region{PokemonRegion::West}; friend bool operator==(const PokemonCard&, const PokemonCard&) = default; }; diff --git a/core/include/ccm/games/IGameModule.hpp b/core/include/ccm/games/IGameModule.hpp index cd8584a..b7f4d7e 100644 --- a/core/include/ccm/games/IGameModule.hpp +++ b/core/include/ccm/games/IGameModule.hpp @@ -23,6 +23,10 @@ public: // Implementations return a vector that has already been filtered // (e.g. no digital-only sets) and sorted by release date ascending. virtual Result> fetchAll() = 0; + + // Optional post-process for locally cached set lists (e.g. inject products + // the upstream API omits). Default is a no-op. Called by SetService::getSets. + virtual void augmentCachedSets(std::vector& /*sets*/) const {} }; class IGameModule { diff --git a/core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp b/core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp new file mode 100644 index 0000000..bf0aea4 --- /dev/null +++ b/core/include/ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp @@ -0,0 +1,68 @@ +#pragma once + +// JapanesePokemonCardPreviewSource: TCGdex ja localId-based preview + variants. +// Image URLs use /high.png (wxImage decodes PNG/JPEG, not webp). + +#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" +#include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include +#include + +namespace ccm { + +class JapanesePokemonCardPreviewSource final : public ICardPreviewSource { +public: + JapanesePokemonCardPreviewSource(IHttpClient& http, + const JapanesePokemonEnCatalog& catalog); + + [[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; + + static std::string normalizeLocalId(std::string_view setNo); + static std::string buildSetDetailUrl(std::string_view setId); + static std::string buildCardUrl(std::string_view setId, std::string_view localId); + static std::string imageUrlFromBase(std::string_view imageBase); + + // Parse set-detail body; optionally filter by name (EN catalog / JA) and/or localId. + struct SetCardRow { + std::string localId; + std::string nameJa; + std::string imageBase; // empty when TCGdex has no scan + std::string rarity; + }; + + static Result, PreviewLookupError> + parseSetCards(const std::string& body); + + static Result + parseCardImageUrl(const std::string& body); + + static Result> + parsePrintVariants(const std::string& body, + std::string_view setId, + std::string_view wantedCardName, + const JapanesePokemonEnCatalog& catalog); + + // Catalog-only Auto-detect when TCGdex has no set detail (theme decks, etc.). + static Result> + detectPrintVariantsFromCatalog(std::string_view setId, + std::string_view wantedCardName, + const JapanesePokemonEnCatalog& catalog); + +private: + IHttpClient& http_; + const JapanesePokemonEnCatalog& catalog_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp b/core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp new file mode 100644 index 0000000..05f93c1 --- /dev/null +++ b/core/include/ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp @@ -0,0 +1,71 @@ +#pragma once + +// JapanesePokemonEnCatalog - bundled English name layer for Japanese Pokémon. +// Loaded from assets/pokemon_jp_en_catalog.json (generated offline). Missing +// entries fall through to TCGdex Japanese names at runtime. + +#include "ccm/util/Result.hpp" + +#include +#include +#include +#include +#include + +namespace ccm { + +struct JapanesePokemonSetEnInfo { + std::string nameEn; + std::string nameJa; + std::string releaseDate; // YYYY/MM/DD when known; may be empty +}; + +struct JapanesePokemonPrintEnInfo { + std::string setId; + std::string localId; + std::string nameEn; + std::string nameJa; + std::string nameEnSource; // bulbapedia | species-table | manual + // Classic JA gap-fill when TCGdex has no CDN scan (optional). + std::string imageUrl; // explicit HTTPS URL, preferred when set + std::string tcgplayerId; // TCGPlayer product id → product-images CDN +}; + +class JapanesePokemonEnCatalog { +public: + [[nodiscard]] static Result + parse(const std::string& jsonBody); + + [[nodiscard]] bool empty() const noexcept { + return sets_.empty() && printsByKey_.empty(); + } + + [[nodiscard]] std::optional + findSet(std::string_view setId) const; + + [[nodiscard]] std::optional + findPrint(std::string_view setId, std::string_view localId) const; + + // Case-insensitive match of nameEn or nameJa within a set. + // Also matches qualified English titles: wanted "Mewtwo" hits + // "Mewtwo (CoroCoro promo)" (prefix + " ("). + [[nodiscard]] std::vector + findPrintsByName(std::string_view setId, std::string_view cardName) const; + + [[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept; + + // TCGPlayer product-image CDN URL for classic JA gap-fill. + [[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId); + + // Prefer imageUrl; else build from tcgplayerId; else empty. + [[nodiscard]] static std::string previewImageUrlFromPrint( + const JapanesePokemonPrintEnInfo& print); + +private: + std::unordered_map sets_; + std::unordered_map printsByKey_; + // setId -> print keys for name scans + std::unordered_map> printKeysBySet_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/pokemonjp/JapanesePokemonGameModule.hpp b/core/include/ccm/games/pokemonjp/JapanesePokemonGameModule.hpp new file mode 100644 index 0000000..6cbb621 --- /dev/null +++ b/core/include/ccm/games/pokemonjp/JapanesePokemonGameModule.hpp @@ -0,0 +1,34 @@ +#pragma once + +// JapanesePokemonGameModule: Japanese Pokémon TCG via TCGdex ja + EN catalog. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" + +namespace ccm { + +class JapanesePokemonGameModule final : public IGameModule { +public: + explicit JapanesePokemonGameModule(IHttpClient& http, + JapanesePokemonEnCatalog catalog = {}); + + [[nodiscard]] Game id() const noexcept override { return Game::JapanesePokemon; } + [[nodiscard]] std::string dirName() const override { return "pokemon"; } + [[nodiscard]] std::string displayName() const override { return "Pokemon (Japan)"; } + + ISetSource& setSource() override { return setSource_; } + ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; } + + [[nodiscard]] const JapanesePokemonEnCatalog& catalog() const noexcept { + return catalog_; + } + +private: + JapanesePokemonEnCatalog catalog_; + JapanesePokemonSetSource setSource_; + JapanesePokemonCardPreviewSource previewSource_; +}; + +} // namespace ccm diff --git a/core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp b/core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp new file mode 100644 index 0000000..8055734 --- /dev/null +++ b/core/include/ccm/games/pokemonjp/JapanesePokemonSetSource.hpp @@ -0,0 +1,39 @@ +#pragma once + +// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release +// dates. English display names come from JapanesePokemonEnCatalog when present. + +#include "ccm/games/IGameModule.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" +#include "ccm/ports/IHttpClient.hpp" + +namespace ccm { + +class JapanesePokemonSetSource final : public ISetSource { +public: + static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets"; + + JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog); + + Result> fetchAll() override; + + void augmentCachedSets(std::vector& sets) const override; + + // Pure parsers for hermetic tests. + static Result> parseListResponse(const std::string& body); + static Result parseReleaseDate(const std::string& detailBody); + static bool shouldExcludeSetId(std::string_view setId) noexcept; + static std::string applySetNameOverride(std::string_view setId, + std::string nameJa); + static std::string rewriteReleaseDate(std::string_view isoDate); + static std::string buildSetDetailUrl(std::string_view setId); + + // Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id. + static void appendMissingClassicProducts(std::vector& sets); + +private: + IHttpClient& http_; + const JapanesePokemonEnCatalog& catalog_; +}; + +} // namespace ccm diff --git a/core/include/ccm/infra/JsonSetRepository.hpp b/core/include/ccm/infra/JsonSetRepository.hpp index f462a50..c13e84d 100644 --- a/core/include/ccm/infra/JsonSetRepository.hpp +++ b/core/include/ccm/infra/JsonSetRepository.hpp @@ -1,6 +1,8 @@ #pragma once -// JsonSetRepository: persists vector to `//sets.json`. +// JsonSetRepository: persists vector under `//`. +// Most games use `sets.json`. Pokemon West/Asia share dir `pokemon` with +// `sets-west.json` / `sets-asia.json` (migrate-on-load from legacy paths). #include "ccm/games/IGameModule.hpp" #include "ccm/ports/IFileSystem.hpp" @@ -27,6 +29,8 @@ private: DirNameFn dirName_; [[nodiscard]] std::filesystem::path setsPath(Game game) const; + [[nodiscard]] std::filesystem::path legacySetsPath(Game game) const; + [[nodiscard]] Result> parseSetsText(const std::string& text) const; }; } // namespace ccm diff --git a/core/include/ccm/ports/ISetRepository.hpp b/core/include/ccm/ports/ISetRepository.hpp index 95d348f..da40cbb 100644 --- a/core/include/ccm/ports/ISetRepository.hpp +++ b/core/include/ccm/ports/ISetRepository.hpp @@ -1,6 +1,8 @@ #pragma once -// ISetRepository - persistence port for the cached `sets.json` of a game. +// ISetRepository - persistence port for the cached set list of a game. +// Typical layout: `//sets.json`. Pokemon West/Asia use +// `sets-west.json` / `sets-asia.json` under the shared `pokemon/` directory. // Stored as a flat list to mirror the original Rust file layout. #include "ccm/domain/Enums.hpp" diff --git a/core/include/ccm/services/CardFilter.hpp b/core/include/ccm/services/CardFilter.hpp index 25013f6..34570fd 100644 --- a/core/include/ccm/services/CardFilter.hpp +++ b/core/include/ccm/services/CardFilter.hpp @@ -20,6 +20,7 @@ // `.includes("")` returns true. #include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -35,7 +36,7 @@ namespace ccm { std::string_view filter); // Pokemon value-key columns from PokemonTable.tsx tableFields list: -// name, set.name, setNo, language, condition, amount, note. +// name, set.name, setNo, language, condition, amount, note, region. // Holo/FirstEdition/Signed/Altered are bool-typed and excluded. [[nodiscard]] bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter); @@ -46,4 +47,8 @@ namespace ccm { [[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view filter); +// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo). +[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card, + std::string_view filter); + } // namespace ccm diff --git a/core/include/ccm/services/CardPreviewService.hpp b/core/include/ccm/services/CardPreviewService.hpp index cd9ceba..9fc2d8b 100644 --- a/core/include/ccm/services/CardPreviewService.hpp +++ b/core/include/ccm/services/CardPreviewService.hpp @@ -15,11 +15,13 @@ #include "ccm/domain/Enums.hpp" #include "ccm/games/IGameModule.hpp" #include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IFileSystem.hpp" #include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IPreviewByteCache.hpp" #include "ccm/util/Result.hpp" #include +#include #include #include #include @@ -32,7 +34,9 @@ namespace ccm { class CardPreviewService { public: explicit CardPreviewService(IHttpClient& http, - IPreviewByteCache* persistentCache = nullptr); + IPreviewByteCache* persistentCache = nullptr, + IFileSystem* fs = nullptr, + std::filesystem::path assetRoot = {}); // Register a game module's preview source. Calling this with a module // whose `cardPreviewSource()` returns nullptr is a no-op (the game has @@ -96,6 +100,9 @@ private: Result fetchAndCache(const std::string& cacheKey, std::string_view url); + Result fetchAssetAndCache( + const std::string& cacheKey, + std::string_view assetUrl); // Returns the kind of in-memory cache entry for `key`. On Hit the // payload is copied into `outPayload`; on NegativeHit `outPayload` is @@ -107,6 +114,8 @@ private: IHttpClient& http_; IPreviewByteCache* persistentCache_{nullptr}; + IFileSystem* fs_{nullptr}; + std::filesystem::path assetRoot_; std::unordered_map sources_; // LRU: list holds entries in MRU-first order; map points at list nodes diff --git a/core/include/ccm/services/CardSorter.hpp b/core/include/ccm/services/CardSorter.hpp index b54f16c..637cf12 100644 --- a/core/include/ccm/services/CardSorter.hpp +++ b/core/include/ccm/services/CardSorter.hpp @@ -17,6 +17,7 @@ // (e.g. sort by name, then by set => grouped by set, name-sorted within each). #include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -81,6 +82,20 @@ enum class DigiBattle99SortColumn { Note, }; +// Japanese Pokemon mirrors Pokemon columns. +enum class JapanesePokemonSortColumn { + Name, + SetReleaseDate, + Language, + Condition, + Amount, + Holo, + FirstEdition, + Signed, + Altered, + Note, +}; + // Stable in-place sort. `ascending=false` runs the same comparator with // inverted sign, matching `byField(field, asc)` semantics. void sortMagicCards(std::vector& cards, MagicSortColumn column, @@ -92,5 +107,8 @@ void sortYuGiOhCards(std::vector& cards, YuGiOhSortColumn column, void sortDigiBattle99Cards(std::vector& cards, DigiBattle99SortColumn column, bool ascending); +void sortJapanesePokemonCards(std::vector& cards, + JapanesePokemonSortColumn column, + bool ascending); } // namespace ccm diff --git a/core/src/domain/Configuration.cpp b/core/src/domain/Configuration.cpp index 520c488..b4d73ce 100644 --- a/core/src/domain/Configuration.cpp +++ b/core/src/domain/Configuration.cpp @@ -13,6 +13,11 @@ void to_json(nlohmann::json& j, const Configuration& c) { void from_json(const nlohmann::json& j, Configuration& c) { j.at("dataStorage").get_to(c.dataStorage); j.at("defaultGame").get_to(c.defaultGame); + // JapanesePokemon was folded into Pokemon (West/Asia region). Coerce so + // older config.json files keep a valid user-facing default game. + if (c.defaultGame == Game::JapanesePokemon) { + c.defaultGame = Game::Pokemon; + } c.theme = j.value("theme", Theme::Light); } diff --git a/core/src/domain/Enums.cpp b/core/src/domain/Enums.cpp index bf2406e..ae6a88e 100644 --- a/core/src/domain/Enums.cpp +++ b/core/src/domain/Enums.cpp @@ -13,24 +13,35 @@ namespace ccm { std::string_view to_string(Game g) noexcept { switch (g) { - case Game::Magic: return "Magic"; - case Game::Pokemon: return "Pokemon"; - case Game::YuGiOh: return "YuGiOh"; - case Game::DigiBattle99: return "DigiBattle99"; + case Game::Magic: return "Magic"; + case Game::Pokemon: return "Pokemon"; + case Game::YuGiOh: return "YuGiOh"; + case Game::DigiBattle99: return "DigiBattle99"; + case Game::JapanesePokemon: return "JapanesePokemon"; + } + CCM_UNREACHABLE(); +} + +std::string_view to_string(PokemonRegion r) noexcept { + switch (r) { + case PokemonRegion::West: return "West"; + case PokemonRegion::Asia: return "Asia"; } CCM_UNREACHABLE(); } std::string_view to_string(Language l) noexcept { switch (l) { - case Language::English: return "English"; - case Language::German: return "German"; - case Language::French: return "French"; - case Language::Spanish: return "Spanish"; - case Language::Italian: return "Italian"; - case Language::Chinese: return "Chinese"; - case Language::Japanese: return "Japanese"; - case Language::Russian: return "Russian"; + case Language::English: return "English"; + case Language::German: return "German"; + case Language::French: return "French"; + case Language::Spanish: return "Spanish"; + case Language::Italian: return "Italian"; + case Language::SimplifiedChinese: return "S-Chinese"; + case Language::TraditionalChinese: return "T-Chinese"; + case Language::Japanese: return "Japanese"; + case Language::Korean: return "Korean"; + case Language::Russian: return "Russian"; } CCM_UNREACHABLE(); } @@ -57,22 +68,33 @@ std::string_view to_string(Theme t) noexcept { } std::optional gameFromString(std::string_view s) noexcept { - if (s == "Magic") return Game::Magic; - if (s == "Pokemon") return Game::Pokemon; - if (s == "YuGiOh") return Game::YuGiOh; - if (s == "DigiBattle99") return Game::DigiBattle99; + if (s == "Magic") return Game::Magic; + if (s == "Pokemon") return Game::Pokemon; + if (s == "YuGiOh") return Game::YuGiOh; + if (s == "DigiBattle99") return Game::DigiBattle99; + if (s == "JapanesePokemon") return Game::JapanesePokemon; + return std::nullopt; +} + +std::optional pokemonRegionFromString(std::string_view s) noexcept { + if (s == "West") return PokemonRegion::West; + if (s == "Asia") return PokemonRegion::Asia; return std::nullopt; } std::optional languageFromString(std::string_view s) noexcept { - if (s == "English") return Language::English; - if (s == "German") return Language::German; - if (s == "French") return Language::French; - if (s == "Spanish") return Language::Spanish; - if (s == "Italian") return Language::Italian; - if (s == "Chinese") return Language::Chinese; - if (s == "Japanese") return Language::Japanese; - if (s == "Russian") return Language::Russian; + if (s == "English") return Language::English; + if (s == "German") return Language::German; + if (s == "French") return Language::French; + if (s == "Spanish") return Language::Spanish; + if (s == "Italian") return Language::Italian; + if (s == "S-Chinese") return Language::SimplifiedChinese; + if (s == "T-Chinese") return Language::TraditionalChinese; + // Legacy single Chinese spelling → Simplified. + if (s == "Chinese") return Language::SimplifiedChinese; + if (s == "Japanese") return Language::Japanese; + if (s == "Korean") return Language::Korean; + if (s == "Russian") return Language::Russian; return std::nullopt; } @@ -99,10 +121,11 @@ const std::array& allGames() noexcept { return v; } -const std::array& allLanguages() noexcept { - static constexpr std::array v{ +const std::array& allLanguages() noexcept { + static constexpr std::array v{ Language::English, Language::German, Language::French, Language::Spanish, - Language::Italian, Language::Chinese, Language::Japanese, Language::Russian + Language::Italian, Language::SimplifiedChinese, Language::TraditionalChinese, + Language::Japanese, Language::Korean, Language::Russian }; return v; } @@ -120,16 +143,45 @@ const std::array& allThemes() noexcept { return v; } -void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); } -void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); } -void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); } -void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); } +std::span languagesForPokemonRegion(PokemonRegion r) noexcept { + static constexpr std::array kWest{ + Language::English, Language::German, Language::French, + Language::Spanish, Language::Italian, Language::Russian}; + static constexpr std::array kAsia{ + Language::Japanese, Language::SimplifiedChinese, + Language::TraditionalChinese, Language::Korean}; + switch (r) { + case PokemonRegion::West: return kWest; + case PokemonRegion::Asia: return kAsia; + } + CCM_UNREACHABLE(); + return kWest; +} + +Game pokemonBackendGame(PokemonRegion r) noexcept { + return r == PokemonRegion::Asia ? Game::JapanesePokemon : Game::Pokemon; +} + +Language defaultLanguageForPokemonRegion(PokemonRegion r) noexcept { + return r == PokemonRegion::Asia ? Language::Japanese : Language::English; +} + +void to_json(nlohmann::json& j, Game v) { j = std::string(to_string(v)); } +void to_json(nlohmann::json& j, PokemonRegion v) { j = std::string(to_string(v)); } +void to_json(nlohmann::json& j, Language v) { j = std::string(to_string(v)); } +void to_json(nlohmann::json& j, Condition v) { j = std::string(to_string(v)); } +void to_json(nlohmann::json& j, Theme v) { j = std::string(to_string(v)); } void from_json(const nlohmann::json& j, Game& v) { auto parsed = gameFromString(j.get()); if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get()); v = *parsed; } +void from_json(const nlohmann::json& j, PokemonRegion& v) { + auto parsed = pokemonRegionFromString(j.get()); + if (!parsed) throw std::invalid_argument("Unknown PokemonRegion value: " + j.get()); + v = *parsed; +} void from_json(const nlohmann::json& j, Language& v) { auto parsed = languageFromString(j.get()); if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get()); diff --git a/core/src/domain/JapanesePokemonCard.cpp b/core/src/domain/JapanesePokemonCard.cpp new file mode 100644 index 0000000..90efd02 --- /dev/null +++ b/core/src/domain/JapanesePokemonCard.cpp @@ -0,0 +1,39 @@ +#include "ccm/domain/JapanesePokemonCard.hpp" + +namespace ccm { + +void to_json(nlohmann::json& j, const JapanesePokemonCard& c) { + j = nlohmann::json{ + {"id", c.id}, + {"amount", c.amount}, + {"name", c.name}, + {"set", c.set}, + {"setNo", c.setNo}, + {"note", c.note}, + {"images", c.images}, + {"language", c.language}, + {"condition", c.condition}, + {"firstEdition", c.firstEdition}, + {"holo", c.holo}, + {"signed", c.signed_}, + {"altered", c.altered}, + }; +} + +void from_json(const nlohmann::json& j, JapanesePokemonCard& 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("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("firstEdition").get_to(c.firstEdition); + 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/PokemonCard.cpp b/core/src/domain/PokemonCard.cpp index 22d4517..6e9cfc0 100644 --- a/core/src/domain/PokemonCard.cpp +++ b/core/src/domain/PokemonCard.cpp @@ -17,6 +17,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) { {"holo", c.holo}, {"signed", c.signed_}, {"altered", c.altered}, + {"region", c.region}, }; } @@ -34,6 +35,8 @@ void from_json(const nlohmann::json& j, PokemonCard& c) { j.at("holo").get_to(c.holo); j.at("signed").get_to(c.signed_); j.at("altered").get_to(c.altered); + // Missing `region` defaults to West so pre-merge West-only files still load. + c.region = j.value("region", PokemonRegion::West); } } // namespace ccm diff --git a/core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp b/core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp new file mode 100644 index 0000000..33b3e84 --- /dev/null +++ b/core/src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp @@ -0,0 +1,442 @@ +#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" + +#include "ccm/util/Rfc3986.hpp" + +#include + +#include +#include +#include + +namespace ccm { + +namespace { + +std::string trim(std::string s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) { + s.erase(s.begin()); + } + while (!s.empty() && std::isspace(static_cast(s.back()))) { + s.pop_back(); + } + return s; +} + +std::string asciiLower(std::string s) { + for (char& ch : s) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return s; +} + +std::string stripLeadingZeros(std::string_view s) { + std::size_t i = 0; + while (i + 1 < s.size() && s[i] == '0') ++i; + return std::string(s.substr(i)); +} + +bool localIdsMatch(std::string_view a, std::string_view b) { + if (a == b) return true; + return stripLeadingZeros(a) == stripLeadingZeros(b); +} + +bool catalogPrintMatchesRow(const JapanesePokemonPrintEnInfo& print, + const JapanesePokemonCardPreviewSource::SetCardRow& row) { + // Reject stale catalog rows whose Japanese name disagrees with TCGdex. + // Seed data historically mapped Charmander→001 / Charizard→004; those + // localIds are Bulbasaur / Weedle on PMCG1. + if (print.nameJa.empty()) return true; + return asciiLower(print.nameJa) == asciiLower(row.nameJa); +} + +bool nameMatchesRow(std::string_view wantedLower, + const JapanesePokemonCardPreviewSource::SetCardRow& row, + std::string_view setId, + const JapanesePokemonEnCatalog& catalog) { + if (wantedLower.empty()) return true; + if (asciiLower(row.nameJa) == wantedLower) return true; + if (auto print = catalog.findPrint(setId, row.localId)) { + if (!catalogPrintMatchesRow(*print, row)) return false; + if (asciiLower(print->nameEn) == wantedLower) return true; + if (asciiLower(print->nameJa) == wantedLower) return true; + } + return false; +} + +} // namespace + +JapanesePokemonCardPreviewSource::JapanesePokemonCardPreviewSource( + IHttpClient& http, const JapanesePokemonEnCatalog& catalog) + : http_(http), catalog_(catalog) {} + +std::string JapanesePokemonCardPreviewSource::normalizeLocalId(std::string_view setNo) { + std::string s = trim(std::string(setNo)); + const auto slash = s.find('/'); + if (slash != std::string::npos) s.erase(slash); + return s; +} + +std::string JapanesePokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) { + return std::string("https://api.tcgdex.net/v2/ja/sets/") + + rfc3986PercentEncode(setId); +} + +std::string JapanesePokemonCardPreviewSource::buildCardUrl(std::string_view setId, + std::string_view localId) { + std::string id = std::string(setId) + "-" + std::string(localId); + return std::string("https://api.tcgdex.net/v2/ja/cards/") + + rfc3986PercentEncode(id); +} + +std::string JapanesePokemonCardPreviewSource::imageUrlFromBase(std::string_view imageBase) { + if (imageBase.empty()) return {}; + std::string url(imageBase); + while (!url.empty() && (url.back() == '/' || url.back() == ' ')) url.pop_back(); + return url + "/high.png"; +} + +Result, PreviewLookupError> +JapanesePokemonCardPreviewSource::parseSetCards(const std::string& body) { + using R = Result, PreviewLookupError>; + using K = PreviewLookupError::Kind; + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_object() || !j.contains("cards") || !j.at("cards").is_array()) { + return R::err({K::Transient, + "TCGdex JA set detail missing 'cards' array."}); + } + std::vector out; + out.reserve(j.at("cards").size()); + for (const auto& card : j.at("cards")) { + SetCardRow row; + row.localId = card.value("localId", ""); + if (row.localId.empty() && card.contains("id") && card.at("id").is_string()) { + // Fallback: take suffix after last '-' from card id. + const std::string id = card.at("id").get(); + const auto dash = id.rfind('-'); + if (dash != std::string::npos) row.localId = id.substr(dash + 1); + } + row.nameJa = card.value("name", ""); + row.rarity = card.value("rarity", ""); + if (card.contains("image") && card.at("image").is_string()) { + row.imageBase = card.at("image").get(); + } + if (row.localId.empty()) continue; + out.push_back(std::move(row)); + } + return R::ok(std::move(out)); + } catch (const std::exception& e) { + return R::err({K::Transient, + std::string("TCGdex JA set detail JSON parse error: ") + e.what()}); + } +} + +Result +JapanesePokemonCardPreviewSource::parseCardImageUrl(const std::string& body) { + using R = Result; + using K = PreviewLookupError::Kind; + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_object()) { + return R::err({K::Transient, "TCGdex JA card response is not a JSON object."}); + } + if (!j.contains("image") || j.at("image").is_null()) { + return R::err({K::NotFound, "TCGdex JA card has no image."}); + } + if (!j.at("image").is_string()) { + return R::err({K::Transient, "TCGdex JA card image field is not a string."}); + } + const std::string base = j.at("image").get(); + if (base.empty()) { + return R::err({K::NotFound, "TCGdex JA card has no image."}); + } + return R::ok(imageUrlFromBase(base)); + } catch (const std::exception& e) { + return R::err({K::Transient, + std::string("TCGdex JA card JSON parse error: ") + e.what()}); + } +} + +Result> +JapanesePokemonCardPreviewSource::parsePrintVariants( + const std::string& body, + std::string_view setId, + std::string_view wantedCardName, + const JapanesePokemonEnCatalog& catalog) { + using R = Result>; + auto rows = parseSetCards(body); + if (!rows) { + return R::err(rows.error().message); + } + + const std::string wantedLower = asciiLower(trim(std::string(wantedCardName))); + std::vector out; + std::unordered_set seen; + std::unordered_set seenCatalogUrls; + + // Prefer catalog EN matches first so typed English names resolve — but + // only when the catalog localId exists in the set and name_ja agrees + // with TCGdex (guards against stale seed mappings). + std::vector withPreview; + std::vector withoutPreview; + if (!wantedLower.empty()) { + for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) { + const SetCardRow* row = nullptr; + for (const auto& r : rows.value()) { + if (localIdsMatch(r.localId, p.localId)) { + row = &r; + break; + } + } + const std::string previewUrl = + JapanesePokemonEnCatalog::previewImageUrlFromPrint(p); + if (row == nullptr) { + // Set detail sometimes omits cards[]; keep catalog-only hits + // (UnnumberedPromo). Dedupe only among non-empty preview URLs + // so empty-image prints still appear in the Next ring. + if (!rows.value().empty()) continue; + if (!previewUrl.empty() && + !seenCatalogUrls.insert(previewUrl).second) { + continue; + } + } else if (!catalogPrintMatchesRow(p, *row)) { + continue; + } + if (!seen.insert(p.localId).second) continue; + AutoDetectedPrint print; + print.setNo = p.localId; + if (row != nullptr) print.rarity = row->rarity; + if (previewUrl.empty()) { + withoutPreview.push_back(std::move(print)); + } else { + withPreview.push_back(std::move(print)); + } + } + } + + for (auto& print : withPreview) out.push_back(std::move(print)); + for (auto& print : withoutPreview) out.push_back(std::move(print)); + + for (const auto& row : rows.value()) { + if (!nameMatchesRow(wantedLower, row, setId, catalog)) continue; + if (!seen.insert(row.localId).second) continue; + AutoDetectedPrint print; + print.setNo = row.localId; + print.rarity = row.rarity; + out.push_back(std::move(print)); + } + + if (out.empty() && !wantedLower.empty()) { + return R::err("No matching Japanese Pokemon prints for that name in the set."); + } + return R::ok(std::move(out)); +} + +Result> +JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + std::string_view setId, + std::string_view wantedCardName, + const JapanesePokemonEnCatalog& catalog) { + using R = Result>; + if (!catalog.hasPrintsForSet(setId)) { + return R::err("No matching Japanese Pokemon prints for that name in the set."); + } + const std::string wantedLower = asciiLower(trim(std::string(wantedCardName))); + std::vector out; + std::unordered_set seen; + std::unordered_set seenUrls; + if (wantedLower.empty()) { + return R::ok(std::move(out)); + } + std::vector withPreview; + std::vector withoutPreview; + for (const auto& p : catalog.findPrintsByName(setId, wantedCardName)) { + if (!seen.insert(p.localId).second) continue; + // Dedupe only among non-empty preview URLs so identical art is not + // cycled; empty-image prints still join the Next ring (card-back). + // Emit imaged prints first so Auto-detect lands on real art. + const std::string previewUrl = + JapanesePokemonEnCatalog::previewImageUrlFromPrint(p); + if (!previewUrl.empty() && !seenUrls.insert(previewUrl).second) { + continue; + } + AutoDetectedPrint print; + print.setNo = p.localId; + if (previewUrl.empty()) { + withoutPreview.push_back(std::move(print)); + } else { + withPreview.push_back(std::move(print)); + } + } + for (auto& print : withPreview) out.push_back(std::move(print)); + for (auto& print : withoutPreview) out.push_back(std::move(print)); + if (out.empty()) { + return R::err("No matching Japanese Pokemon prints for that name in the set."); + } + return R::ok(std::move(out)); +} + +Result +JapanesePokemonCardPreviewSource::fetchImageUrl(std::string_view name, + std::string_view setId, + std::string_view setNo) { + using R = Result; + using K = PreviewLookupError::Kind; + + const std::string localId = normalizeLocalId(setNo); + if (setId.empty()) { + return R::err({K::NotFound, "Japanese Pokemon preview requires a set id."}); + } + + auto catalogPreviewFor = [&](std::string_view lid) -> Result { + if (lid.empty()) return R::err({K::NotFound, "No catalog preview for print."}); + if (auto print = catalog_.findPrint(setId, lid)) { + const std::string catalogUrl = + JapanesePokemonEnCatalog::previewImageUrlFromPrint(*print); + if (!catalogUrl.empty()) return R::ok(catalogUrl); + } + return R::err({K::NotFound, "TCGdex JA card has no image."}); + }; + + // Prefer direct card fetch when we have a localId. + if (!localId.empty()) { + auto cardResp = http_.get(buildCardUrl(setId, localId)); + if (cardResp) { + auto img = parseCardImageUrl(cardResp.value()); + if (img) return img; + // NotFound from card object: fall through to set list / catalog. + if (img.error().kind == K::Transient) return img; + } else { + // Synthetic / classic products: try catalog gap-fill before set detail. + auto catalogImg = catalogPreviewFor(localId); + if (catalogImg) return catalogImg; + // Known catalog print with no preview URL: honest miss (do not + // borrow a sibling print's art via name match). + if (catalog_.findPrint(setId, localId)) { + return R::err({K::NotFound, "TCGdex JA card has no image."}); + } + } + } + + auto setResp = http_.get(buildSetDetailUrl(setId)); + if (!setResp) { + // Catalog-only products (City Gym theme decks, etc.) are not on TCGdex. + if (!localId.empty()) { + auto catalogImg = catalogPreviewFor(localId); + if (catalogImg) return catalogImg; + if (catalog_.findPrint(setId, localId)) { + return R::err({K::NotFound, "TCGdex JA card has no image."}); + } + // Network failure and no catalog entry: Transient so a brief outage + // is not negative-cached as a permanent miss. + return R::err({K::Transient, setResp.error()}); + } + if (catalog_.hasPrintsForSet(setId)) { + const std::string wantedLower = asciiLower(trim(std::string(name))); + if (!wantedLower.empty()) { + for (const auto& p : catalog_.findPrintsByName(setId, name)) { + auto catalogImg = catalogPreviewFor(p.localId); + if (catalogImg) return catalogImg; + } + } + return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."}); + } + return R::err({K::Transient, setResp.error()}); + } + auto rows = parseSetCards(setResp.value()); + if (!rows) return R::err(rows.error()); + + const std::string wantedLower = asciiLower(trim(std::string(name))); + const SetCardRow* chosen = nullptr; + for (const auto& row : rows.value()) { + if (!localId.empty() && localIdsMatch(row.localId, localId)) { + chosen = &row; + break; + } + } + // Name match only when setNo was not provided — never borrow a sibling + // print's art for a concrete localId. + if (chosen == nullptr && localId.empty() && !wantedLower.empty()) { + for (const auto& row : rows.value()) { + if (nameMatchesRow(wantedLower, row, setId, catalog_)) { + chosen = &row; + break; + } + } + } + if (chosen == nullptr) { + // Empty cards[] with catalog prints: resolve from catalog. + if (rows.value().empty() && catalog_.hasPrintsForSet(setId)) { + if (!localId.empty()) { + auto catalogImg = catalogPreviewFor(localId); + if (catalogImg) return catalogImg; + if (catalog_.findPrint(setId, localId)) { + return R::err({K::NotFound, "TCGdex JA card has no image."}); + } + return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."}); + } + if (!wantedLower.empty()) { + for (const auto& p : catalog_.findPrintsByName(setId, name)) { + auto catalogImg = catalogPreviewFor(p.localId); + if (catalogImg) return catalogImg; + } + } + } + return R::err({K::NotFound, "No matching Japanese Pokemon card for preview."}); + } + if (!chosen->imageBase.empty()) { + return R::ok(imageUrlFromBase(chosen->imageBase)); + } + + // Try full card object — set résumé sometimes omits image. + auto cardResp = http_.get(buildCardUrl(setId, chosen->localId)); + if (cardResp) { + auto img = parseCardImageUrl(cardResp.value()); + if (img) return img; + if (img.error().kind == K::Transient) return img; + } else { + // Odd localId padding can 404; still try catalog gap-fill below. + } + + // Classic JA sets often have image:null on TCGdex. Prefer a catalog + // printing-accurate TCGPlayer product image for this exact setId+localId + // (never search other printings by Pokémon name). + return catalogPreviewFor(chosen->localId); +} + +Result +JapanesePokemonCardPreviewSource::detectFirstPrint(std::string_view name, + std::string_view setId) { + auto variants = detectPrintVariants(name, setId); + if (!variants) return Result::err(variants.error()); + if (variants.value().empty()) { + return Result::err( + "No matching Japanese Pokemon prints for that name in the set."); + } + return Result::ok(variants.value().front()); +} + +Result> +JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name, + std::string_view setId) { + if (setId.empty()) { + return Result>::err( + "Select a set before auto-detecting Japanese Pokemon prints."); + } + auto setResp = http_.get(buildSetDetailUrl(setId)); + if (!setResp) { + if (catalog_.hasPrintsForSet(setId)) { + return detectPrintVariantsFromCatalog(setId, name, catalog_); + } + return Result>::err(setResp.error()); + } + auto parsed = parsePrintVariants(setResp.value(), setId, name, catalog_); + if (parsed) return parsed; + // Empty/unusable TCGdex detail: fall back to catalog prints when present. + if (catalog_.hasPrintsForSet(setId)) { + return detectPrintVariantsFromCatalog(setId, name, catalog_); + } + return parsed; +} + +} // namespace ccm diff --git a/core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp b/core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp new file mode 100644 index 0000000..160551a --- /dev/null +++ b/core/src/games/pokemonjp/JapanesePokemonEnCatalog.cpp @@ -0,0 +1,159 @@ +#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" + +#include + +#include +#include +#include +#include + +namespace ccm { + +namespace { + +std::string asciiLower(std::string s) { + for (char& ch : s) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return s; +} + +std::string printKey(std::string_view setId, std::string_view localId) { + return std::string(setId) + '\0' + std::string(localId); +} + +bool isAsciiAlnumToken(std::string_view s) { + if (s.empty()) return false; + for (unsigned char ch : s) { + if (!std::isalnum(ch)) return false; + } + return true; +} + +/// True when `needle` appears in `hay` as a whole alphanumeric token +/// (e.g. "mewtwo" in "team gr's mewtwo" / "mewtwo strikes back", but not +/// "mew" inside "mewtwo"). ASCII needles only. +bool containsWholeAsciiToken(std::string_view hay, std::string_view needle) { + if (!isAsciiAlnumToken(needle)) return false; + const std::size_t n = needle.size(); + for (std::size_t i = 0; i + n <= hay.size(); ++i) { + if (hay.compare(i, n, needle) != 0) continue; + const bool leftOk = i == 0 || !std::isalnum(static_cast(hay[i - 1])); + const bool rightOk = + i + n == hay.size() || + !std::isalnum(static_cast(hay[i + n])); + if (leftOk && rightOk) return true; + } + return false; +} + +} // namespace + +Result +JapanesePokemonEnCatalog::parse(const std::string& jsonBody) { + try { + const auto j = nlohmann::json::parse(jsonBody); + JapanesePokemonEnCatalog out; + + if (j.contains("sets") && j.at("sets").is_object()) { + for (auto it = j.at("sets").begin(); it != j.at("sets").end(); ++it) { + JapanesePokemonSetEnInfo info; + info.nameEn = it.value().value("name_en", ""); + info.nameJa = it.value().value("name_ja", ""); + info.releaseDate = it.value().value("releaseDate", ""); + out.sets_[it.key()] = std::move(info); + } + } + + if (j.contains("prints") && j.at("prints").is_array()) { + for (const auto& entry : j.at("prints")) { + JapanesePokemonPrintEnInfo info; + info.setId = entry.value("set_id", ""); + info.localId = entry.value("local_id", ""); + info.nameEn = entry.value("name_en", ""); + info.nameJa = entry.value("name_ja", ""); + info.nameEnSource = entry.value("name_en_source", ""); + info.imageUrl = entry.value("image_url", ""); + if (entry.contains("tcgplayer_id")) { + const auto& tp = entry.at("tcgplayer_id"); + if (tp.is_string()) { + info.tcgplayerId = tp.get(); + } else if (tp.is_number_integer()) { + info.tcgplayerId = std::to_string(tp.get()); + } else if (tp.is_number_unsigned()) { + info.tcgplayerId = std::to_string(tp.get()); + } + } + if (info.setId.empty() || info.localId.empty()) continue; + const std::string key = printKey(info.setId, info.localId); + out.printKeysBySet_[info.setId].push_back(key); + out.printsByKey_[key] = std::move(info); + } + } + + return Result::ok(std::move(out)); + } catch (const std::exception& e) { + return Result::err( + std::string("Japanese Pokemon EN catalog JSON parse error: ") + e.what()); + } +} + +std::optional +JapanesePokemonEnCatalog::findSet(std::string_view setId) const { + const auto it = sets_.find(std::string(setId)); + if (it == sets_.end()) return std::nullopt; + return it->second; +} + +std::optional +JapanesePokemonEnCatalog::findPrint(std::string_view setId, + std::string_view localId) const { + const auto it = printsByKey_.find(printKey(setId, localId)); + if (it == printsByKey_.end()) return std::nullopt; + return it->second; +} + +std::vector +JapanesePokemonEnCatalog::findPrintsByName(std::string_view setId, + std::string_view cardName) const { + std::vector out; + if (cardName.empty()) return out; + const std::string wanted = asciiLower(std::string(cardName)); + const auto keysIt = printKeysBySet_.find(std::string(setId)); + if (keysIt == printKeysBySet_.end()) return out; + for (const auto& key : keysIt->second) { + const auto pit = printsByKey_.find(key); + if (pit == printsByKey_.end()) continue; + const auto& p = pit->second; + const std::string enLower = asciiLower(p.nameEn); + const std::string jaLower = asciiLower(p.nameJa); + // Exact, qualified "Mewtwo (...)", or whole-token in a longer title + // ("Team GR's Mewtwo", "Mewtwo Strikes Back (...)"). + if (enLower == wanted || jaLower == wanted || + enLower.starts_with(wanted + " (") || + containsWholeAsciiToken(enLower, wanted) || + containsWholeAsciiToken(jaLower, wanted)) { + out.push_back(p); + } + } + return out; +} + +bool JapanesePokemonEnCatalog::hasPrintsForSet(std::string_view setId) const noexcept { + const auto it = printKeysBySet_.find(std::string(setId)); + return it != printKeysBySet_.end() && !it->second.empty(); +} + +std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) { + if (productId.empty()) return {}; + return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") + + std::string(productId) + ".jpg"; +} + +std::string JapanesePokemonEnCatalog::previewImageUrlFromPrint( + const JapanesePokemonPrintEnInfo& print) { + if (!print.imageUrl.empty()) return print.imageUrl; + return tcgplayerImageUrl(print.tcgplayerId); +} + +} // namespace ccm diff --git a/core/src/games/pokemonjp/JapanesePokemonGameModule.cpp b/core/src/games/pokemonjp/JapanesePokemonGameModule.cpp new file mode 100644 index 0000000..1dcff24 --- /dev/null +++ b/core/src/games/pokemonjp/JapanesePokemonGameModule.cpp @@ -0,0 +1,11 @@ +#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp" + +namespace ccm { + +JapanesePokemonGameModule::JapanesePokemonGameModule(IHttpClient& http, + JapanesePokemonEnCatalog catalog) + : catalog_(std::move(catalog)), + setSource_(http, catalog_), + previewSource_(http, catalog_) {} + +} // namespace ccm diff --git a/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp b/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp new file mode 100644 index 0000000..a04b211 --- /dev/null +++ b/core/src/games/pokemonjp/JapanesePokemonSetSource.cpp @@ -0,0 +1,207 @@ +#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" + +#include "ccm/util/Rfc3986.hpp" + +#include + +#include +#include +#include +#include + +namespace ccm { + +namespace { + +struct ClassicMissingProduct { + const char* id; + const char* nameEn; + const char* releaseDate; // YYYY/MM/DD +}; + +// Keep in sync with tools/pokemon_jp/classic_missing_sets.json and +// docs/assets-and-info-apis.md (Japanese Pokémon Info API). +constexpr std::array kMissingClassicProducts{{ + // Day after Pokémon Jungle (PMCG2, 1997/03/05) so the set list places + // Unnumbered Promo immediately after Jungle when sorted by releaseDate. + {"UnnumberedPromo", "Unnumbered Promotional cards", "1997/03/06"}, + {"ExpSheet1", "Expansion Sheet Series 1", "1998/03/23"}, + {"NiviCG", "Nivi City Gym", "1998/04/26"}, + {"HanadaCG", "Hanada City Gym", "1998/04/26"}, + {"ExpSheet2", "Expansion Sheet Series 2", "1998/06/17"}, + {"KuchibaCG", "Kuchiba City Gym", "1998/07/25"}, + {"TamamushiCG", "Tamamushi City Gym", "1998/07/25"}, + {"ExpSheet3", "Expansion Sheet Series 3", "1998/11/24"}, + {"YamabukiCG", "Yamabuki City Gym", "1999/02/26"}, + {"GurenTG", "Guren Town Gym", "1999/02/26"}, + {"SouthernIslands", "Southern Islands", "1999/07/17"}, +}}; + +const std::unordered_map& setNameJaOverrides() { + // Field-level corrections for known TCGdex JA mislabels (never edit cache). + static const std::unordered_map kOverrides{ + {"SV4a", "シャイニートレジャーex"}, + }; + return kOverrides; +} + +[[nodiscard]] bool containsCjk(std::string_view s) noexcept { + // Detect hiragana / katakana / CJK unified (UTF-8 lead bytes 0xE3–0xE9). + // Do NOT treat Latin-1 accents (e.g. é in "Pokémon", lead 0xC3) as CJK — + // that used to wipe catalog English names back to the set id. + for (unsigned char ch : s) { + if (ch >= 0xE3 && ch <= 0xE9) return true; + } + return false; +} + +} // namespace + +JapanesePokemonSetSource::JapanesePokemonSetSource( + IHttpClient& http, const JapanesePokemonEnCatalog& catalog) + : http_(http), catalog_(catalog) {} + +bool JapanesePokemonSetSource::shouldExcludeSetId(std::string_view setId) noexcept { + // Chinese-region CS* entries are mislabeled on the JA endpoint. + return setId.size() >= 2 && setId[0] == 'C' && setId[1] == 'S'; +} + +std::string JapanesePokemonSetSource::applySetNameOverride(std::string_view setId, + std::string nameJa) { + const auto& overrides = setNameJaOverrides(); + const auto it = overrides.find(std::string(setId)); + if (it != overrides.end()) return it->second; + return nameJa; +} + +std::string JapanesePokemonSetSource::rewriteReleaseDate(std::string_view isoDate) { + std::string out(isoDate); + for (char& ch : out) { + if (ch == '-') ch = '/'; + } + return out; +} + +std::string JapanesePokemonSetSource::buildSetDetailUrl(std::string_view setId) { + return std::string("https://api.tcgdex.net/v2/ja/sets/") + + rfc3986PercentEncode(setId); +} + +Result> +JapanesePokemonSetSource::parseListResponse(const std::string& body) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.is_array()) { + return Result>::err( + "TCGdex JA sets response is not a JSON array."); + } + std::vector out; + out.reserve(j.size()); + for (const auto& entry : j) { + Set s; + s.id = entry.value("id", ""); + if (s.id.empty() || shouldExcludeSetId(s.id)) continue; + s.name = applySetNameOverride(s.id, entry.value("name", "")); + s.releaseDate = {}; // filled from catalog or set detail + out.push_back(std::move(s)); + } + appendMissingClassicProducts(out); + return Result>::ok(std::move(out)); + } catch (const std::exception& e) { + return Result>::err( + std::string("TCGdex JA sets JSON parse error: ") + e.what()); + } +} + +void JapanesePokemonSetSource::appendMissingClassicProducts(std::vector& sets) { + for (const auto& product : kMissingClassicProducts) { + auto it = std::find_if(sets.begin(), sets.end(), [&](const Set& s) { + return s.id == product.id; + }); + if (it != sets.end()) { + // Keep curated display name / sort date in sync (e.g. UnnumberedPromo + // placement after Pokémon Jungle) even when the id was already cached. + it->name = product.nameEn; + it->releaseDate = product.releaseDate; + continue; + } + Set s; + s.id = product.id; + s.name = product.nameEn; + s.releaseDate = product.releaseDate; + sets.push_back(std::move(s)); + } +} + +Result +JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) { + try { + const auto j = nlohmann::json::parse(detailBody); + if (!j.is_object()) { + return Result::err( + "TCGdex JA set detail response is not a JSON object."); + } + const std::string raw = j.value("releaseDate", ""); + if (raw.empty()) { + return Result::ok(std::string{}); + } + return Result::ok(rewriteReleaseDate(raw)); + } catch (const std::exception& e) { + return Result::err( + std::string("TCGdex JA set detail JSON parse error: ") + e.what()); + } +} + +Result> JapanesePokemonSetSource::fetchAll() { + auto listResp = http_.get(kListEndpoint); + if (!listResp) return Result>::err(listResp.error()); + + auto parsed = parseListResponse(listResp.value()); + if (!parsed) return parsed; + + std::vector out = std::move(parsed).value(); + for (auto& s : out) { + // Prefer catalog English; never leave Japanese TCGdex names in Set.name + // (the set picker must stay English-only). + if (auto en = catalog_.findSet(s.id)) { + if (!en->nameEn.empty()) s.name = en->nameEn; + if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate; + } + if (s.name.empty() || containsCjk(s.name)) { + s.name = s.id; + } + if (!s.releaseDate.empty()) continue; + + auto detail = http_.get(buildSetDetailUrl(s.id)); + if (!detail) continue; // keep set with empty date rather than fail all + auto date = parseReleaseDate(detail.value()); + if (date && !date.value().empty()) { + s.releaseDate = std::move(date).value(); + } + } + + std::sort(out.begin(), out.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); + return Result>::ok(std::move(out)); +} + +void JapanesePokemonSetSource::augmentCachedSets(std::vector& sets) const { + // Stale caches may store set ids (or Japanese) as Set.name — re-apply the + // bundled EN catalog so names like "Pokémon Jungle" are searchable again. + for (auto& s : sets) { + if (auto en = catalog_.findSet(s.id)) { + if (!en->nameEn.empty()) s.name = en->nameEn; + if (!en->releaseDate.empty() && s.releaseDate.empty()) { + s.releaseDate = en->releaseDate; + } + } + if (s.name.empty() || containsCjk(s.name)) { + s.name = s.id; + } + } + appendMissingClassicProducts(sets); + std::sort(sets.begin(), sets.end(), + [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); +} + +} // namespace ccm diff --git a/core/src/infra/JsonSetRepository.cpp b/core/src/infra/JsonSetRepository.cpp index 0842fcd..e4b2417 100644 --- a/core/src/infra/JsonSetRepository.cpp +++ b/core/src/infra/JsonSetRepository.cpp @@ -12,24 +12,59 @@ JsonSetRepository::JsonSetRepository(IFileSystem& fs, ConfigService& config, Dir : fs_(fs), config_(config), dirName_(std::move(dirName)) {} fs::path JsonSetRepository::setsPath(Game game) const { - return fs::path(config_.current().dataStorage) / dirName_(game) / "sets.json"; + const fs::path root = fs::path(config_.current().dataStorage) / dirName_(game); + switch (game) { + case Game::Pokemon: return root / "sets-west.json"; + case Game::JapanesePokemon: return root / "sets-asia.json"; + default: return root / "sets.json"; + } } -Result> JsonSetRepository::load(Game game) { - const auto p = setsPath(game); - if (!fs_.exists(p)) { - return Result>::err("Set list not yet downloaded for this game."); +fs::path JsonSetRepository::legacySetsPath(Game game) const { + const fs::path dataRoot(config_.current().dataStorage); + switch (game) { + case Game::Pokemon: + // Pre-flatten: pokemon/sets.json + return dataRoot / "pokemon" / "sets.json"; + case Game::JapanesePokemon: + // Pre-flatten: pokemonjp/sets.json + return dataRoot / "pokemonjp" / "sets.json"; + default: + return {}; } - auto text = fs_.readText(p); - if (!text) return Result>::err(text.error()); +} + +Result> JsonSetRepository::parseSetsText(const std::string& text) const { try { - auto j = nlohmann::json::parse(text.value()); + auto j = nlohmann::json::parse(text); return Result>::ok(j.get>()); } catch (const std::exception& e) { return Result>::err(std::string("sets.json parse error: ") + e.what()); } } +Result> JsonSetRepository::load(Game game) { + const auto p = setsPath(game); + if (fs_.exists(p)) { + auto text = fs_.readText(p); + if (!text) return Result>::err(text.error()); + return parseSetsText(text.value()); + } + + const auto legacy = legacySetsPath(game); + if (!legacy.empty() && fs_.exists(legacy)) { + auto text = fs_.readText(legacy); + if (!text) return Result>::err(text.error()); + auto parsed = parseSetsText(text.value()); + if (!parsed) return parsed; + // Best-effort promote to the new path; UI still gets the sets if write fails. + (void)save(game, parsed.value()); + return parsed; + } + + return Result>::err("Set list not yet downloaded for this game."); +} + Result JsonSetRepository::save(Game game, const std::vector& sets) { const auto p = setsPath(game); auto dir = fs_.ensureDirectory(p.parent_path()); diff --git a/core/src/services/CardFilter.cpp b/core/src/services/CardFilter.cpp index 47ea555..17a33c6 100644 --- a/core/src/services/CardFilter.cpp +++ b/core/src/services/CardFilter.cpp @@ -47,6 +47,7 @@ bool matchesPokemonFilter(const PokemonCard& card, std::string_view filter) { 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; + if (containsLower(to_string(card.region), needle)) return true; return false; } @@ -82,4 +83,20 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi return false; } +bool matchesJapanesePokemonFilter(const JapanesePokemonCard& 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(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; +} + } // namespace ccm diff --git a/core/src/services/CardPreviewService.cpp b/core/src/services/CardPreviewService.cpp index d5eab54..c983e5d 100644 --- a/core/src/services/CardPreviewService.cpp +++ b/core/src/services/CardPreviewService.cpp @@ -1,5 +1,6 @@ #include "ccm/services/CardPreviewService.hpp" +#include #include #include #include @@ -36,11 +37,18 @@ std::string makeUrlKey(std::string_view url) { return k; } +constexpr std::string_view kAssetScheme = "asset:"; + } // namespace CardPreviewService::CardPreviewService(IHttpClient& http, - IPreviewByteCache* persistentCache) - : http_(http), persistentCache_(persistentCache) {} + IPreviewByteCache* persistentCache, + IFileSystem* fs, + std::filesystem::path assetRoot) + : http_(http), + persistentCache_(persistentCache), + fs_(fs), + assetRoot_(std::move(assetRoot)) {} void CardPreviewService::registerModule(IGameModule& module) { if (auto* src = module.cardPreviewSource(); src != nullptr) { @@ -122,6 +130,37 @@ Result CardPreviewService::fetchAndCache(const std::string& cacheKe return Result::ok(std::move(payload)); } +Result CardPreviewService::fetchAssetAndCache( + const std::string& cacheKey, + std::string_view assetUrl) { + using R = Result; + using K = PreviewLookupError::Kind; + + if (fs_ == nullptr || assetRoot_.empty()) { + return R::err({K::Transient, "Asset preview path is not configured."}); + } + if (!assetUrl.starts_with(kAssetScheme)) { + return R::err({K::Transient, "Asset preview URL is missing the asset: prefix."}); + } + std::filesystem::path rel(std::string(assetUrl.substr(kAssetScheme.size()))); + const auto fullPath = assetRoot_ / rel; + auto bytes = fs_->readText(fullPath); + if (!bytes) { + return R::err({K::NotFound, + "Bundled preview asset not found: " + fullPath.generic_string()}); + } + std::string payload = std::move(bytes).value(); + if (payload.empty()) { + return R::err({K::NotFound, + "Bundled preview asset is empty: " + fullPath.generic_string()}); + } + cacheStore(cacheKey, payload); + if (persistentCache_ != nullptr) { + persistentCache_->store(cacheKey, payload); + } + return R::ok(std::move(payload)); +} + Result CardPreviewService::fetchPreviewBytes(Game game, std::string_view name, std::string_view setId, @@ -179,6 +218,18 @@ Result CardPreviewService::fetchPreviewBytes(Game game, } return Result::err(err.message); } + if (url.value().starts_with(kAssetScheme)) { + auto asset = fetchAssetAndCache(key, url.value()); + if (!asset) { + const auto err = std::move(asset).error(); + if (err.kind == PreviewLookupError::Kind::NotFound) { + cacheStoreNegative(key); + if (persistentCache_ != nullptr) persistentCache_->storeNegative(key); + } + return Result::err(err.message); + } + return Result::ok(std::move(asset).value()); + } return fetchAndCache(key, url.value()); } @@ -231,6 +282,11 @@ Result CardPreviewService::fetchImageBytesByUrl(std::string_view ur return Result::ok(disk.payload); } } + if (url.starts_with(kAssetScheme)) { + auto asset = fetchAssetAndCache(key, url); + if (!asset) return Result::err(asset.error().message); + return Result::ok(std::move(asset).value()); + } return fetchAndCache(key, url); } diff --git a/core/src/services/CardSorter.cpp b/core/src/services/CardSorter.cpp index 26da5a3..4cd194d 100644 --- a/core/src/services/CardSorter.cpp +++ b/core/src/services/CardSorter.cpp @@ -293,4 +293,74 @@ void sortDigiBattle99Cards(std::vector& cards, } } +void sortJapanesePokemonCards(std::vector& cards, + JapanesePokemonSortColumn column, + bool ascending) { + switch (column) { + case JapanesePokemonSortColumn::Name: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return asciiLower(a.name) < asciiLower(b.name); + }, ascending)); + break; + case JapanesePokemonSortColumn::SetReleaseDate: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return asciiLower(a.set.releaseDate) < + asciiLower(b.set.releaseDate); + }, ascending)); + break; + case JapanesePokemonSortColumn::Language: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return asciiLower(to_string(a.language)) < + asciiLower(to_string(b.language)); + }, ascending)); + break; + case JapanesePokemonSortColumn::Condition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return asciiLower(to_string(a.condition)) < + asciiLower(to_string(b.condition)); + }, ascending)); + break; + case JapanesePokemonSortColumn::Amount: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return a.amount < b.amount; + }, ascending)); + break; + case JapanesePokemonSortColumn::Holo: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return a.holo < b.holo; + }, ascending)); + break; + case JapanesePokemonSortColumn::FirstEdition: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return a.firstEdition < b.firstEdition; + }, ascending)); + break; + case JapanesePokemonSortColumn::Signed: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return a.signed_ < b.signed_; + }, ascending)); + break; + case JapanesePokemonSortColumn::Altered: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return a.altered < b.altered; + }, ascending)); + break; + case JapanesePokemonSortColumn::Note: + std::stable_sort(cards.begin(), cards.end(), directional( + [](const JapanesePokemonCard& a, const JapanesePokemonCard& b) { + return asciiLower(a.note) < asciiLower(b.note); + }, ascending)); + break; + } +} + } // namespace ccm diff --git a/core/src/services/SetService.cpp b/core/src/services/SetService.cpp index 666bf01..209f1eb 100644 --- a/core/src/services/SetService.cpp +++ b/core/src/services/SetService.cpp @@ -21,7 +21,13 @@ Result> SetService::updateSets(Game game) { } Result> SetService::getSets(Game game) { - return repo_.load(game); + auto loaded = repo_.load(game); + if (!loaded) return loaded; + auto it = modules_.find(game); + if (it != modules_.end() && it->second != nullptr) { + it->second->setSource().augmentCachedSets(loaded.value()); + } + return loaded; } } // namespace ccm diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 6a16589..a300196 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -11,7 +11,7 @@ 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, 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`), 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!, 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. diff --git a/docs/adding-a-new-game.md b/docs/adding-a-new-game.md index a3e663d..4efe3a4 100644 --- a/docs/adding-a-new-game.md +++ b/docs/adding-a-new-game.md @@ -195,7 +195,7 @@ private: Two subtle requirements: -- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short. +- `dirName()` returns the **on-disk directory name**. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short. **Pokemon exception:** a unified Game menu entry may keep one `dirName` (`pokemon`) for collection/images and disambiguate region set caches by filename (`sets-west.json` / `sets-asia.json`) instead of a second data subdirectory. - `cardPreviewSource()` defaults to `nullptr` in `IGameModule`. Only override it if you actually have a preview source. Returning `nullptr` makes `CardPreviewService::registerModule(*module)` a silent no-op for that game; the UI gracefully falls back to "no preview available". The `.cpp` is one line of constructor body — see `core/src/games/pokemon/PokemonGameModule.cpp`. diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index 6658311..22be4df 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -18,6 +18,10 @@ Used by `MagicCardPreviewSource` to find a card printing from `name` + `setId`, ## Pokemon APIs +Unified **Pokemon** Game menu entry. Per-card `region` (`West` / `Asia`) selects the backend below. Collection: `pokemon/collection.json`. West sets: `pokemon/sets-west.json`. Asia sets: `pokemon/sets-asia.json`. Language choices: West → English/German/French/Spanish/Italian/Russian; Asia → Japanese/S-Chinese/T-Chinese/Korean. + +### West (`Game::Pokemon`, pokemontcg.io) + **Info API:** `https://api.pokemontcg.io/v2/sets` Used by `PokemonSetSource` to fetch all sets. The parser maps `id`, `name`, and `releaseDate` directly into `Set`, then sorts ascending by release date. @@ -115,6 +119,116 @@ where `{id}` is the API card number (`ST-01`, `BO-115`, `MO-06`). The CDN also s Empty search array / `{"error":"..."}` → `NotFound`; bad JSON / HTTP → `Transient`. +## Japanese Pokémon TCG APIs (TCGdex `ja`) — Asia region backend + +Asia Pokémon is routed internally as `Game::JapanesePokemon` (`dirName` `pokemon`, same data directory as West). It is **not** a separate Game menu entry: the unified **Pokemon** UI stores both West and Asia cards in `pokemon/collection.json` with a per-card `region` (`West` / `Asia`). Set caches are split by filename under that directory (`pokemon/sets-west.json` vs `pokemon/sets-asia.json`). `JsonSetRepository` migrate-on-load promotes legacy `pokemon/sets.json` → `sets-west.json` and `pokemonjp/sets.json` → `sets-asia.json` when the new files are missing. **Sets > Update Pokemon** refreshes both lists. Upstream: [TCGdex REST API](https://tcgdex.dev/). No API key. Japanese set IDs (e.g. `PMCG1`, `SV1a`) are never merged into Western pokemontcg.io ids. + +### Info API: TCGdex `GET /v2/ja/sets` (+ per-set detail) + +`https://api.tcgdex.net/v2/ja/sets` returns a slim array (`id`, `name`, `cardCount`). Release dates require `GET /v2/ja/sets/{id}` (`releaseDate` as `YYYY-MM-DD`, rewritten to `YYYY/MM/DD`). `JapanesePokemonSetSource`: + +- Excludes Chinese-region `CS*` junk rows mislabeled on the JA endpoint. +- Applies field overrides (e.g. `SV4a` Japanese name → `シャイニートレジャーex`). +- Prefers English display names and release dates from the bundled EN catalog when present; otherwise keeps the TCGdex Japanese name and fetches detail for the date. +- After parsing the TCGdex list, **injects Original-era / catalog-only products TCGdex omits** (idempotent by set id — skipped if upstream later adds them). The same injection runs when loading a cached Asia set list (`sets-asia.json`) via `ISetSource::augmentCachedSets`, so these products appear without requiring **Update Sets** first. Stable ids and English names: + +| Id | English name | +|---|---| +| `UnnumberedPromo` | Unnumbered Promotional cards (Bulbapedia catch-all; synthetic `001`… localIds; preview via catalog `image_url` preferring Japanese / Unnumbered Bulbagarden scans) | +| `ExpSheet1` / `ExpSheet2` / `ExpSheet3` | Expansion Sheet Series 1–3 | +| `NiviCG` | Nivi City Gym | +| `HanadaCG` | Hanada City Gym | +| `KuchibaCG` | Kuchiba City Gym | +| `TamamushiCG` | Tamamushi City Gym | +| `YamabukiCG` | Yamabuki City Gym | +| `GurenTG` | Guren Town Gym | +| `SouthernIslands` | Southern Islands | + +Seed data lives in `tools/pokemon_jp/classic_missing_sets.json` + `classic_missing_prints.json` (merged into the EN catalog via `merge_classic_missing.py`). LocalIds for these products are sequential `001`… within each product (cards were unnumbered in print). Refresh `UnnumberedPromo` prints from Bulbapedia with `python tools/pokemon_jp/harvest_unnumbered_promos.py`, then fill preview images with `python tools/pokemon_jp/enrich_unnumbered_promo_images.py` (prefers Unnumbered / Japanese reprint-gallery scans over English Wizards `|image=` primaries; EN-only Bulbapedia pages leave `image_url` empty), then re-run `merge_classic_missing.py`. Numbered Japanese promo eras (`SV-P`, `S-P`, …) remain out of scope — TCGdex does not expose them, and they are not part of this curated set. + +### Sets without printed collector numbers (`UnnumberedPromo`) + +Physically unnumbered Japanese promos (and the other classic catalog-only products above) have **no printed set number**. The app still stores a synthetic `setNo` / catalog `local_id` (`001`, `002`, …) so preview and collection JSON stay keyed by `(setId, localId)` — but that value must not be treated as something the user can read off the card. + +**Edit dialog (`PokemonCardEditDialog`, Asia region) for set id `UnnumberedPromo`:** + +- The **Set #** text field is hidden (row label becomes **Print**). **Auto detect** and **Next** remain. +- Auto-detect / silent Edit prefetch lists catalog prints matching the typed name (exact EN/JA, plus qualified titles such as `Mewtwo` → `Mewtwo (CoroCoro promo)`). Distinct synthetic localIds form the Next ring. +- **Next** on the edit form shows a position counter (`Next (2/5)`), not the synthetic id. For ordinary numbered JP sets, Next still shows the current collector number (`Next (42)`). +- A modeless **Print preview** popup (`VariantImagePreviewDialog`) opens ~20px to the right of the Add/Edit dialog. It loads the current print via `CardPreviewService::fetchPreviewBytes` and refreshes on each ring step. The popup has its own **`<< Prev` / `Next >>`** controls that drive the same ring as the edit dialog (buttons disabled when fewer than two variants). +- On save, the dialog writes the ring’s synthetic `setNo` into `PokemonCard::setNo` even though the text field was hidden. + +Other classic unnumbered products (City Gyms, Expansion Sheets, Southern Islands) currently keep the normal Set # field; only `UnnumberedPromo` uses the print-preview UX above. + +### Asset API: TCGdex card / set-detail images + +Preview is **local-id based**. `JapanesePokemonCardPreviewSource`: + +1. With `setId` + `setNo` (`localId`), tries `GET /v2/ja/cards/{setId}-{localId}` and reads `image`. +2. Falls back to set-detail `cards[]` (which often already carries `image` on modern sets). +3. Appends `/high.png` to the TCGdex image base URL (PNG — wxImage does not decode webp). +4. If the set-specific card still has no scan (classic sets like `PMCG1`), looks up the bundled EN catalog print for that exact `setId`+`localId` and uses optional `image_url` or a TCGPlayer product image built from `tcgplayer_id` (`https://product-images.tcgplayer.com/fit-in/437x437/{id}.jpg`). Gap-fill sources differ by era: + - **PMCG and other data-asia sets with `thirdParty.tcgplayer`**: printing-accurate `tcgplayer_id` harvested offline from [tcgdex/cards-database](https://github.com/tcgdex/cards-database) `data-asia` (the live TCGdex API does not expose them). + - **neo1–neo4**: data-asia has no `tcgplayer_id` and TCGdex JA `image` is null; the catalog may carry an ETL-written `image_url` from a **Japanese** [CardIndex](https://www.cardindex.co/) set scan (`enrich_neo_image_urls.py` scrapes the JA neo set pages and matches by English card name). **No English pokemontcg.io fallback** — if CardIndex has no JP image, `image_url` is left empty and the UI shows the card-back. Use `--overwrite` to re-resolve / clear stale EN URLs. Runtime still resolves only by exact JA `setId`+`localId` — no C++ name search across printings. + This is **printing-accurate** gap-fill — not a name search across other Charizard printings at runtime. +5. For **catalog-only products** (Unnumbered Promotional cards, City Gym theme decks, Expansion Sheets, Southern Islands), when TCGdex set/card GETs fail, Auto-detect and preview fall back to the bundled catalog prints for that `setId` (EN/JA name → `localId`; optional `tcgplayer_id` / `image_url` for preview). `UnnumberedPromo` rows typically carry Bulbagarden Archives `image_url` values written by `enrich_unnumbered_promo_images.py`, which prefers Japanese / Unnumbered Promotional reprint scans and omits English-only Wizards Black Star primaries when no JP file is available. Without a catalog image field, preview returns `NotFound` and the UI shows the card-back. Auto-detect matches exact EN/JA names and also qualified English titles (`Mewtwo` → `Mewtwo (CoroCoro promo)`). +6. City Gym deck exclusives must stay **printing-accurate**. Do **not** reuse Leaders' Stadium / PMCG donor `tcgplayer_id`s for those prints; that shows the wrong set art. Instead, bundle local scans under `assets/pokemon_jp_classic//.jpg` and point the catalog row at `image_url: "asset:pokemon_jp_classic//.jpg"`. `CardPreviewService` loads `asset:` URLs from disk next to the executable, bypassing HTTP entirely. + +It does **not** substitute another printing of the same Pokémon when both TCGdex and the catalog lack an image. Then preview returns `NotFound` and the UI shows the Japanese TCG card-back. + +Auto-detect / Next uses the same set-detail `cards[]`, matching the typed name against catalog English names or TCGdex Japanese names. Catalog EN aliases are applied only when the catalog `name_ja` agrees with the TCGdex row (stale seed mappings like Charmander→`001` are ignored). + +Pokémon English aliases in the catalog come from National Dex → species table (`dexId`) for ordinary Pokémon. When `name_ja` carries a known owner / Rocket's / Dark / Light / Shining prefix, `enrich_preview_images.py` composes the **full English product title** (e.g. `エリカのナゾノクサ` → `Erika's Oddish`, `わるいリザードン` → `Dark Charizard`, `R団のサンダー` → `Rocket's Zapdos`, neo garbled `輝くセレビ` → `Shining Celebi`). Those rows use `name_en_source: "species-table-variant"`. Trainer/Energy English aliases come from the offline JA→EN map `tools/pokemon_jp/non_pokemon_en_by_ja.json` (e.g. Switch ← `ポケモンいれかえ`). + +That trainer/energy map is maintained to cover **at least the first 15 chronological main Japanese expansions** present in TCGdex (PMCG1–PMCG6, neo1–neo4, VS1, web1, E1–E3). The same JA→EN entry also applies to later reprints that reuse the Japanese name. + +### Variant Pokémon English titles + +Auto-detect for English owner / Rocket's / Dark / Light / Shining Pokémon names requires the bundled catalog's **full** `name_en` for that print (same rule as City Gym manuals that already store `Erika's Oddish`). Typing the Japanese TCGdex name still works when `name_ja` is correct. + +To extend variant coverage: + +1. Add new JA prefix → English title prefix pairs to `VARIANT_JA_PREFIXES` in [`tools/pokemon_jp/enrich_preview_images.py`](../tools/pokemon_jp/enrich_preview_images.py) (longest prefixes first). +2. Re-run: + +```bash +python tools/pokemon_jp/enrich_preview_images.py +``` + +3. For neo1–neo4 Japanese preview images (CardIndex JP scans only; clears EN + pokemontcg.io URLs on miss), run: + +```bash +python tools/pokemon_jp/enrich_neo_image_urls.py +python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite +``` + +4. Rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated. + +Rows with `name_en_source: "manual"` (City Gym theme decks in `classic_missing_prints.json`) are never overwritten. Prefer stable English TCG product names (Bulbapedia / Limitless English titles). + +### Extending Trainer/Energy English aliases + +Auto-detect for English Trainer/Energy names only works when the bundled catalog has a `name_en` for that print. Pokémon get `name_en` automatically from `dexId` (bare species) or from variant prefix composition (full titles); Trainers and Energy do not. To add more sets or staples: + +1. Collect unique Japanese Trainer/Energy names for the sets you care about (from TCGdex set detail `cards[].name`, or from `tools/pokemon_jp/_tcgdex_cards_database/data-asia///*.ts` after running enrich once). +2. Add each missing `name_ja` → English display name to [`tools/pokemon_jp/non_pokemon_en_by_ja.json`](../tools/pokemon_jp/non_pokemon_en_by_ja.json). One entry covers **every set** that reprints that Japanese title. +3. Re-run: + +```bash +python tools/pokemon_jp/enrich_preview_images.py +``` + +4. Confirm `enrich_preview_images.py` prints `FIRST15 trainer/energy coverage OK` (or extend `FIRST15_SETS` in that script if you raise the coverage baseline). Copy/rebuild so `assets/pokemon_jp_en_catalog.json` next to the exe is updated. +5. Prefer stable English TCG product names (Bulbapedia / Limitless English titles). Do not invent per-set aliases that differ for the same `name_ja`. + +### Bundled English catalog + +`ui_wx/assets/pokemon_jp_en_catalog.json` is copied next to the exe on build (`assets/pokemon_jp_en_catalog.json`). It supplies English set/card names TCGdex JA cannot provide, plus optional classic-image gap-fill fields (`tcgplayer_id` / `image_url`). Generated offline via `tools/pokemon_jp/` (set EN merge + `enrich_preview_images.py` using species, variant, and trainer/energy tables + optional `enrich_neo_image_urls.py` for neo `image_url`). Missing catalog → Japanese-only labels still work; missing image fields → card-back for unscanned printings. Missing EN aliases for a Trainer still allow Auto-detect when the Japanese name is typed. + +Card-back fallback uses the Japanese TCG Bulbagarden scan +(`TCG_Card_Back_Japanese.jpg`), not the Western `Cardback.jpg`. + ## Runtime Flow In CCM3 The app uses the same flow for every game that registers a module: @@ -122,13 +236,13 @@ The app uses the same flow for every game that registers a module: - `SetService` asks the game's `ISetSource` (info API) for the latest set list. - `CardPreviewService` asks the game's `ICardPreviewSource` (asset API) for a preview image URL. - `CardPreviewService` performs a second HTTP GET to that URL and returns raw bytes to the UI layer. -- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`). +- If preview lookup fails (or returns empty bytes), the UI loads a **per-game card-back fallback** in `BaseSelectedCardPanel`: Magic / Pokémon / Japanese Pokémon call `CardPreviewService::fetchImageBytesByUrl(...)` against fixed HTTPS URLs. Yu-Gi-Oh! tries two Yugipedia URLs (thumbnail then full `Back-EN.png`), then reads **`assets/ygo_card_back.png`** next to the executable if both downloads fail (bundled asset; see `app/CMakeLists.txt`). ### Caching And Connection Reuse See [caching.md](caching.md) for a dedicated reference on preview cache tiers, internal keys, eviction, clearing, and HTTP session reuse. -Three mechanisms reduce preview latency for **all** games (Magic, Pokemon, Yu-Gi-Oh!, DigiBattle99). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews: +Three mechanisms reduce preview latency for **all** games (Magic, Pokemon West/Asia backends, Yu-Gi-Oh!, DigiBattle99). In addition, the shared HTTP session speeds **every** `IHttpClient::get` call (including set-list fetches), not only previews: - **In-memory preview LRU** (`CardPreviewService`). Successful `fetchPreviewBytes` results are cached keyed by `(game, name, setId, setNo)`; successful `fetchImageBytesByUrl` results are cached keyed by URL (used for the per-game card-back fallback). Re-selecting a previously viewed row is decode-only — no HTTP at all. The cache is bounded by `CardPreviewService::kCacheCapacity` (currently 128 entries) and uses a list+map LRU under a mutex (the preview pipeline is invoked from a worker thread in `BaseSelectedCardPanel`). **Source errors are split** by `PreviewLookupError::Kind`: `NotFound` (the upstream answered cleanly that the record has no image) is *negative-cached* in this tier so subsequent selections short-circuit without HTTP, while `Transient` (HTTP/network/parse failures) is **never** cached so a brief outage cannot permanently disable a card's preview. - **Persistent disk byte cache** (`LocalPreviewByteCache`, port `IPreviewByteCache`). Wraps the in-memory tier with an on-disk store under `/.cache/preview-cache/` — pinned **next to the executable**, in the same scope as `config.json`, **not** under the user-configurable `Configuration.dataStorage` path. The cache stays put when the user reconfigures or relocates their collection data, and it is not part of the user's data directory backups; it is install-scoped, not collection-scoped. Both positive previews and `NotFound` verdicts survive an app restart. Each entry is a mutually-exclusive `.bin` (positive payload) or `.neg` (negative marker) plus a `.idx` sidecar containing the original key — load-time mismatch on the sidecar treats the entry as a miss, so a hash collision degrades to a one-time HTTP refetch instead of serving the wrong card's bytes (or the wrong card's "no image" verdict). Hashing is FNV-1a 64-bit (no crypto dependency). The cache is bounded by total `.bin` payload bytes (default `kDefaultMaxBytes = 64 MiB`) and evicts oldest entries by mtime when a new write would exceed the cap; reading an entry touches its mtime so frequently-viewed cards survive eviction. Negative `.neg` markers are tiny and not counted against the cap — their count is naturally bounded by the user's actively-viewed records. Filesystem mutations route through `IFileSystem`; size and mtime queries (which the port does not expose) use `std::filesystem` directly inside the adapter. The persistent tier is **fire-and-forget on the way down** — every adapter operation swallows I/O errors so a flaky or full disk never breaks the preview path. @@ -146,6 +260,7 @@ Fallback card-back sources (`BaseSelectedCardPanel`; Magic/Pokémon URLs match C - Magic: `https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg` - 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. - 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). @@ -158,6 +273,6 @@ All source types return `Result` errors so failures cross bounda - info API failures (bad set payload, schema mismatch, endpoint/network failure), and - asset API failures (query mismatch, no matching card, missing image fields, image download failure). -When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages..imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text. +When previews fail, verify request construction first (name sanitization, number normalization, percent encoding), then verify response shape assumptions: Scryfall (`data`, `image_uris`), Pokemon (`data`, `images.large`/`images.small`; auto-detect also needs `name`, `number`, `rarity`, and `set.id` on each matching row), Yu-Gi-Oh! Yugipedia (`query.pages..imageinfo[0].url` per filename, missing files tagged `"missing": ""`), Yu-Gi-Oh! YGOPRODeck fallback (`data`, `name`, `card_images`), Digi-Battle digimoncard.io (top-level array with `name`/`id`/`set_name`; CDN `images.digimoncard.io/images/cards/{id}.jpg`), Japanese Pokémon TCGdex (`image` base + `/high.png`; set-detail `cards[]` with `localId`). If the UI fallback path succeeds (network card-back and/or bundled PNG), the panel shows the card-back image and the inline label `(image preview unavailable)`; only if every fallback fails does the preview stay empty with status text. For Yu-Gi-Oh! specifically, when a printing shows the wrong art compared with Yugipedia’s gallery, debug in this order: (1) verify the candidate list via `YuGiOhCardPreviewSource::buildCandidateFilenames(...)` against the actual file names on Yugipedia’s `Card_Gallery:` page; (2) confirm the dialog rarity name maps to the expected short code in `ygoRarityShortCode(...)` / `rarityCodeFor(...)` (extend the mapping when a new rarity surfaces); (3) confirm the `firstEdition` flag matches the printed edition stamp — the candidate ordering puts the printed edition first. diff --git a/docs/dow-doc-build-locally.md b/docs/dow-doc-build-locally.md index 0eae24e..659d850 100644 --- a/docs/dow-doc-build-locally.md +++ b/docs/dow-doc-build-locally.md @@ -113,7 +113,7 @@ Automated tests primarily cover `core/` and infrastructure adapters. UI testing `cpr` builds as shared, so `build/bin` contains runtime DLLs (for example `libcpr.dll`, `libcurl.dll`, `libzlib.dll`) next to `ccm3.exe`. -For MinGW/MSYS2 builds, UCRT runtime DLLs must be available (typically via MSYS2 UCRT64 `bin` on `PATH`). +For MinGW/MSYS2 builds, the `ccm` POST_BUILD step also copies `libstdc++-6.dll`, `libgcc_s_seh-1.dll`, and `libwinpthread-1.dll` from the compiler’s `bin/` next to `ccm3.exe`. That keeps Explorer / IDE launches on the same UCRT runtime used to build (avoids “Entry Point Not Found” / `__emutls_v._ZSt11__once_call` against `libcpr.dll` when a different `libstdc++` is on `PATH`). ## Troubleshooting diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 1f18a9f..3cc1220 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -11,7 +11,7 @@ - `image_service_tests.cpp` — `ImageService` (uses inline `RecordingImageStore` fake). - `collection_service_tests.cpp` — `CollectionService` (uses inline `InMemoryRepo` + `StubImageStore`). - `config_service_tests.cpp` — `ConfigService` against `InMemoryFileSystem`. -- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`. +- `json_collection_repository_tests.cpp`, `json_set_repository_tests.cpp` — repository round-trips against `InMemoryFileSystem`. Set-repo cases also pin Pokemon `sets-west.json` / `sets-asia.json` paths and migrate-on-load from legacy `pokemon/sets.json` / `pokemonjp/sets.json`. - `local_image_store_tests.cpp` — `LocalImageStore` against `InMemoryFileSystem` + `ConfigService`: `copyIn` (extension preserved, missing source errors), `remove` (existing file deleted; absent path is a no-op), `resolvePath` layout under `dataStorage//images/`. - `set_service_tests.cpp` — `SetService` with `FakeSetSource` + `InMemSetRepo`. - `magic_set_source_tests.cpp` — `MagicSetSource::parseResponse` (Scryfall mapping). Drives `fetchAll` via `FixedHttpClient` fake. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7e67aee..9225ab6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,9 @@ add_executable(ccm_core_tests pokemon_card_preview_source_tests.cpp digibattle99_set_source_tests.cpp digibattle99_card_preview_source_tests.cpp + japanese_pokemon_en_catalog_tests.cpp + japanese_pokemon_set_source_tests.cpp + japanese_pokemon_card_preview_source_tests.cpp icard_preview_source_tests.cpp yugioh_set_source_tests.cpp yugioh_set_lookup_tests.cpp diff --git a/tests/card_filter_tests.cpp b/tests/card_filter_tests.cpp index 4927237..d976e5b 100644 --- a/tests/card_filter_tests.cpp +++ b/tests/card_filter_tests.cpp @@ -7,6 +7,7 @@ #include "ccm/domain/Enums.hpp" #include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -186,6 +187,15 @@ TEST_SUITE("CardFilter::matchesPokemonFilter") { TEST_CASE("empty filter matches everything") { CHECK(matchesPokemonFilter(pc("Charizard", "Base Set"), "")); } + + TEST_CASE("region is searchable") { + PokemonCard c = pc("Charizard", "Base Set"); + c.region = PokemonRegion::Asia; + CHECK(matchesPokemonFilter(c, "asia")); + CHECK_FALSE(matchesPokemonFilter(c, "west")); + c.region = PokemonRegion::West; + CHECK(matchesPokemonFilter(c, "west")); + } } TEST_SUITE("CardFilter::matchesYuGiOhFilter") { @@ -276,3 +286,40 @@ TEST_SUITE("CardFilter::matchesDigiBattle99Filter") { CHECK(matchesDigiBattle99Filter(c, "agu")); } } + +TEST_SUITE("CardFilter::matchesJapanesePokemonFilter") { + TEST_CASE("matches by name and set.name") { + JapanesePokemonCard c; + c.name = "Charmander"; + c.set.name = "Expansion Pack"; + CHECK(matchesJapanesePokemonFilter(c, "char")); + CHECK(matchesJapanesePokemonFilter(c, "EXPANSION")); + CHECK_FALSE(matchesJapanesePokemonFilter(c, "pikachu")); + } + + TEST_CASE("includes setNo in searchable columns") { + JapanesePokemonCard c; + c.name = "Charmander"; + c.set.name = "Expansion Pack"; + c.setNo = "001"; + CHECK(matchesJapanesePokemonFilter(c, "001")); + CHECK(matchesJapanesePokemonFilter(c, "00")); + } + + TEST_CASE("empty filter matches everything") { + JapanesePokemonCard c; + c.name = "Charmander"; + CHECK(matchesJapanesePokemonFilter(c, "")); + } + + TEST_CASE("boolean flag columns are not matched") { + JapanesePokemonCard c; + c.name = "Charmander"; + c.holo = true; + c.firstEdition = true; + c.signed_ = true; + c.altered = true; + CHECK_FALSE(matchesJapanesePokemonFilter(c, "true")); + CHECK(matchesJapanesePokemonFilter(c, "char")); + } +} diff --git a/tests/card_preview_service_tests.cpp b/tests/card_preview_service_tests.cpp index f77882f..53ad47f 100644 --- a/tests/card_preview_service_tests.cpp +++ b/tests/card_preview_service_tests.cpp @@ -2,10 +2,12 @@ #include "ccm/games/IGameModule.hpp" #include "ccm/ports/ICardPreviewSource.hpp" +#include "ccm/ports/IFileSystem.hpp" #include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IPreviewByteCache.hpp" #include "ccm/services/CardPreviewService.hpp" +#include #include #include #include @@ -130,6 +132,50 @@ public: void storeNegative(std::string_view) override {} }; +class MemoryFileSystem final : public IFileSystem { +public: + std::unordered_map files; + + [[nodiscard]] bool exists(const std::filesystem::path& p) const override { + return files.contains(p.generic_string()); + } + [[nodiscard]] bool isDirectory(const std::filesystem::path&) const override { + return false; + } + Result ensureDirectory(const std::filesystem::path&) override { + return Result::ok(); + } + Result readText(const std::filesystem::path& p) override { + auto it = files.find(p.generic_string()); + if (it == files.end()) { + return Result::err("Unable to open " + p.generic_string()); + } + return Result::ok(it->second); + } + Result writeText(const std::filesystem::path& p, std::string_view contents) override { + files[p.generic_string()] = std::string(contents); + return Result::ok(); + } + Result copyFile(const std::filesystem::path& from, + const std::filesystem::path& to, + bool) override { + auto it = files.find(from.generic_string()); + if (it == files.end()) { + return Result::err("missing source"); + } + files[to.generic_string()] = it->second; + return Result::ok(); + } + Result remove(const std::filesystem::path& p) override { + files.erase(p.generic_string()); + return Result::ok(); + } + Result> listDirectory( + const std::filesystem::path&) override { + return Result>::ok({}); + } +}; + // Minimal IGameModule fake that exposes a configurable preview source. class FakeGameModule final : public IGameModule { public: @@ -232,6 +278,26 @@ TEST_SUITE("CardPreviewService::fetchPreviewBytes") { CHECK(out.isErr()); CHECK(out.error() == "net down"); } + + TEST_CASE("asset: preview loads bytes from configured asset root") { + FakeSource source; + source.url = "asset:pokemon_jp_classic/TamamushiCG/016.jpg"; + FakeGameModule module; + module.gameId = Game::JapanesePokemon; + module.preview = &source; + + FixedHttpClient http; + MemoryFileSystem fs; + fs.files["assets/pokemon_jp_classic/TamamushiCG/016.jpg"] = "JPEG-bytes"; + + CardPreviewService svc{http, nullptr, &fs, "assets"}; + svc.registerModule(module); + + const auto out = svc.fetchPreviewBytes(Game::JapanesePokemon, "Erika", "TamamushiCG", "016"); + REQUIRE(out.isOk()); + CHECK(out.value() == "JPEG-bytes"); + CHECK(http.calls == 0); + } } TEST_SUITE("CardPreviewService caching") { diff --git a/tests/card_sorter_tests.cpp b/tests/card_sorter_tests.cpp index e93c4af..7a63836 100644 --- a/tests/card_sorter_tests.cpp +++ b/tests/card_sorter_tests.cpp @@ -7,6 +7,7 @@ #include "ccm/domain/Enums.hpp" #include "ccm/domain/DigiBattle99Card.hpp" +#include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -119,6 +120,30 @@ DigiBattle99Card db(std::uint32_t id, std::string name, return c; } +JapanesePokemonCard jp(std::uint32_t id, std::string name, + std::string setName, std::string releaseDate, + std::uint8_t amount = 1, + bool holo = false, bool firstEdition = false, + bool sgnd = false, bool altered = false, + Language lang = Language::Japanese, + Condition cond = Condition::NearMint, + std::string note = "") { + JapanesePokemonCard c; + c.id = id; + c.name = std::move(name); + c.set.name = std::move(setName); + c.set.releaseDate = std::move(releaseDate); + c.amount = amount; + c.holo = holo; + c.firstEdition = firstEdition; + c.signed_ = sgnd; + c.altered = altered; + c.language = lang; + c.condition = cond; + c.note = std::move(note); + return c; +} + std::vector ids(const std::vector& v) { std::vector out; out.reserve(v.size()); @@ -147,6 +172,13 @@ std::vector ids(const std::vector& v) { return out; } +std::vector ids(const std::vector& v) { + std::vector out; + out.reserve(v.size()); + for (const auto& c : v) out.push_back(c.id); + return out; +} + } // namespace TEST_SUITE("CardSorter - Magic columns") { @@ -515,3 +547,35 @@ TEST_SUITE("CardSorter - DigiBattle99 columns") { CHECK(ids(v) == std::vector{2, 1}); } } + +TEST_SUITE("CardSorter - JapanesePokemon columns") { + TEST_CASE("Holo and FirstEdition sort false before true") { + std::vector v = { + jp(1, "a", "X", "2000/01/01", 1, /*holo=*/true, /*first=*/false), + jp(2, "b", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/true), + jp(3, "c", "X", "2000/01/01", 1, /*holo=*/false, /*first=*/false), + }; + sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Holo, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 3, 1}); + sortJapanesePokemonCards(v, JapanesePokemonSortColumn::FirstEdition, /*ascending=*/true); + CHECK(ids(v) == std::vector{3, 1, 2}); + } + + TEST_CASE("Set column sorts by release date") { + std::vector v = { + jp(1, "x", "Late", "2023/03/10"), + jp(2, "y", "Early", "1996/10/20"), + }; + sortJapanesePokemonCards(v, JapanesePokemonSortColumn::SetReleaseDate, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 1}); + } + + TEST_CASE("Name sorts case-insensitively") { + std::vector v = { + jp(1, "charmander", "X", "1996/10/20"), + jp(2, "Bulbasaur", "X", "1996/10/20"), + }; + sortJapanesePokemonCards(v, JapanesePokemonSortColumn::Name, /*ascending=*/true); + CHECK(ids(v) == std::vector{2, 1}); + } +} diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index fcd264f..81c8870 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -3,6 +3,7 @@ #include "ccm/domain/Configuration.hpp" #include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/Enums.hpp" +#include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/MagicCard.hpp" #include "ccm/domain/PokemonCard.hpp" #include "ccm/domain/YuGiOhCard.hpp" @@ -27,6 +28,9 @@ TEST_SUITE("domain enums round-trip JSON as strings") { nlohmann::json jDigi = "DigiBattle99"; CHECK(jDigi.get() == Game::DigiBattle99); + nlohmann::json jJp = "JapanesePokemon"; + CHECK(jJp.get() == Game::JapanesePokemon); + nlohmann::json j3 = Theme::Dark; CHECK(j3.get() == "Dark"); CHECK(j3.get() == Theme::Dark); @@ -37,11 +41,45 @@ TEST_SUITE("domain enums round-trip JSON as strings") { CHECK(l.get() == "Japanese"); CHECK(l.get() == Language::Japanese); + nlohmann::json k = Language::Korean; + CHECK(k.get() == "Korean"); + CHECK(k.get() == Language::Korean); + + nlohmann::json sc = Language::SimplifiedChinese; + CHECK(sc.get() == "S-Chinese"); + CHECK(sc.get() == Language::SimplifiedChinese); + + nlohmann::json tc = Language::TraditionalChinese; + CHECK(tc.get() == "T-Chinese"); + CHECK(tc.get() == Language::TraditionalChinese); + + nlohmann::json legacyChinese = "Chinese"; + CHECK(legacyChinese.get() == Language::SimplifiedChinese); + nlohmann::json c = Condition::LightPlayed; CHECK(c.get() == "LightPlayed"); CHECK(c.get() == Condition::LightPlayed); } + TEST_CASE("PokemonRegion") { + nlohmann::json j = PokemonRegion::Asia; + CHECK(j.get() == "Asia"); + CHECK(j.get() == PokemonRegion::Asia); + + nlohmann::json w = "West"; + CHECK(w.get() == PokemonRegion::West); + } + + TEST_CASE("allGames excludes JapanesePokemon but string mapping remains") { + for (const auto game : allGames()) { + CHECK(game != Game::JapanesePokemon); + } + CHECK(allGames().size() == 4); + CHECK(gameFromString("JapanesePokemon") == Game::JapanesePokemon); + CHECK(pokemonBackendGame(PokemonRegion::West) == Game::Pokemon); + CHECK(pokemonBackendGame(PokemonRegion::Asia) == Game::JapanesePokemon); + } + TEST_CASE("invalid enum string throws") { nlohmann::json bad = "Spanglish"; CHECK_THROWS(bad.get()); @@ -146,15 +184,37 @@ TEST_SUITE("PokemonCard JSON") { c.holo = true; c.signed_ = false; c.altered = false; + c.region = PokemonRegion::West; nlohmann::json j = c; CHECK(j.at("setNo") == "4/102"); CHECK(j.at("firstEdition") == true); CHECK(j.at("signed") == false); + CHECK(j.at("region") == "West"); const PokemonCard back = j.get(); CHECK(back == c); } + + TEST_CASE("region Asia round-trips and missing region defaults to West") { + PokemonCard c; + c.id = 1; + c.amount = 1; + c.name = "Charmander"; + c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"}; + c.setNo = "001"; + c.language = Language::Japanese; + c.condition = Condition::NearMint; + c.region = PokemonRegion::Asia; + + nlohmann::json j = c; + CHECK(j.at("region") == "Asia"); + CHECK(j.get().region == PokemonRegion::Asia); + + j.erase("region"); + const PokemonCard legacy = j.get(); + CHECK(legacy.region == PokemonRegion::West); + } } TEST_SUITE("DigiBattle99Card JSON") { @@ -185,6 +245,34 @@ TEST_SUITE("DigiBattle99Card JSON") { } } +TEST_SUITE("JapanesePokemonCard JSON") { + TEST_CASE("uses 'setNo' and 'firstEdition' aliases") { + JapanesePokemonCard c; + c.id = 9; + c.amount = 1; + c.name = "Charmander"; + c.set = Set{"PMCG1", "Expansion Pack", "1996/10/20"}; + c.setNo = "001"; + c.note = ""; + c.images = {}; + c.language = Language::Japanese; + c.condition = Condition::NearMint; + c.firstEdition = true; + c.holo = false; + c.signed_ = false; + c.altered = false; + + nlohmann::json j = c; + CHECK(j.at("setNo") == "001"); + CHECK(j.at("firstEdition") == true); + CHECK(j.at("signed") == false); + CHECK(j.at("language") == "Japanese"); + + const JapanesePokemonCard back = j.get(); + CHECK(back == c); + } +} + TEST_SUITE("Configuration JSON matches Rust serde aliases") { TEST_CASE("dataStorage / defaultGame / theme keys are present") { Configuration cfg; @@ -201,6 +289,16 @@ TEST_SUITE("Configuration JSON matches Rust serde aliases") { CHECK(back == cfg); } + TEST_CASE("legacy defaultGame JapanesePokemon coerces to Pokemon") { + nlohmann::json j = { + {"dataStorage", "/data"}, + {"defaultGame", "JapanesePokemon"}, + {"theme", "Light"}, + }; + const auto cfg = j.get(); + CHECK(cfg.defaultGame == Game::Pokemon); + } + TEST_CASE("missing theme key defaults to Light") { const nlohmann::json j = { {"dataStorage", "/portable/data"}, @@ -559,6 +657,36 @@ TEST_SUITE("Domain JSON required fields") { } } + TEST_CASE("JapanesePokemonCard missing each required key throws") { + const nlohmann::json full = { + {"id", 9}, + {"amount", 1}, + {"name", "Charmander"}, + {"set", nlohmann::json{ + {"id", "PMCG1"}, + {"name", "Expansion Pack"}, + {"releaseDate", "1996/10/20"}, + }}, + {"setNo", "001"}, + {"note", ""}, + {"images", nlohmann::json::array()}, + {"language", "Japanese"}, + {"condition", "NearMint"}, + {"firstEdition", true}, + {"holo", false}, + {"signed", false}, + {"altered", false}, + }; + + for (const char* key : + {"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition", + "firstEdition", "holo", "signed", "altered"}) { + nlohmann::json partial = full; + partial.erase(key); + CHECK_THROWS(partial.get()); + } + } + TEST_CASE("Configuration missing required key throws") { const nlohmann::json j = { {"defaultGame", "Magic"}, diff --git a/tests/game_module_tests.cpp b/tests/game_module_tests.cpp index 607fe7e..8d5ab92 100644 --- a/tests/game_module_tests.cpp +++ b/tests/game_module_tests.cpp @@ -3,6 +3,7 @@ #include "ccm/games/digibattle99/DigiBattle99GameModule.hpp" #include "ccm/games/magic/MagicGameModule.hpp" #include "ccm/games/pokemon/PokemonGameModule.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonGameModule.hpp" #include "ccm/games/yugioh/YuGiOhGameModule.hpp" #include "ccm/ports/IHttpClient.hpp" @@ -63,4 +64,15 @@ TEST_SUITE("game modules expose stable identity and wiring") { 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); + + CHECK(module.id() == Game::JapanesePokemon); + CHECK(module.dirName() == "pokemon"); + CHECK(module.displayName() == "Pokemon (Japan)"); + CHECK(module.cardPreviewSource() != nullptr); + CHECK(static_cast(&module.setSource()) != static_cast(module.cardPreviewSource())); + } } diff --git a/tests/japanese_pokemon_card_preview_source_tests.cpp b/tests/japanese_pokemon_card_preview_source_tests.cpp new file mode 100644 index 0000000..413b0de --- /dev/null +++ b/tests/japanese_pokemon_card_preview_source_tests.cpp @@ -0,0 +1,593 @@ +#include + +#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include + +using namespace ccm; + +namespace { + +class RoutingHttpClient final : public IHttpClient { +public: + std::unordered_map bodies; + std::string lastUrl; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + if (!ok) return Result::err("offline"); + const auto it = bodies.find(lastUrl); + if (it == bodies.end()) return Result::err("unknown url: " + lastUrl); + return Result::ok(it->second); + } +}; + +JapanesePokemonEnCatalog sampleCatalog() { + auto c = JapanesePokemonEnCatalog::parse(R"({ + "sets": { + "SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート"} + }, + "prints": [ + {"set_id":"SV1a","local_id":"001","name_en":"Tropius","name_ja":"トロピウス","name_en_source":"bulbapedia"} + ] + })"); + REQUIRE(c.isOk()); + return std::move(c).value(); +} + +} // namespace + +TEST_SUITE("JapanesePokemonCardPreviewSource helpers") { + TEST_CASE("normalizeLocalId strips slash and whitespace") { + CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId(" 001/102 ") == "001"); + CHECK(JapanesePokemonCardPreviewSource::normalizeLocalId("4/102") == "4"); + } + + TEST_CASE("imageUrlFromBase appends high.png") { + CHECK(JapanesePokemonCardPreviewSource::imageUrlFromBase( + "https://assets.tcgdex.net/ja/SV/SV1a/001") == + "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png"); + } + + TEST_CASE("buildCardUrl encodes set-local id") { + CHECK(JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001") == + "https://api.tcgdex.net/v2/ja/cards/SV1a-001"); + } +} + +TEST_SUITE("JapanesePokemonCardPreviewSource::parseSetCards") { + TEST_CASE("parses localId name and image") { + const std::string json = R"({ + "id":"SV1a", + "cards":[ + {"id":"SV1a-001","localId":"001","name":"トロピウス", + "image":"https://assets.tcgdex.net/ja/SV/SV1a/001","rarity":"Common"} + ] + })"; + const auto out = JapanesePokemonCardPreviewSource::parseSetCards(json); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].localId == "001"); + CHECK(out.value()[0].nameJa == "トロピウス"); + CHECK(out.value()[0].imageBase == "https://assets.tcgdex.net/ja/SV/SV1a/001"); + } + + TEST_CASE("missing cards array is Transient") { + const auto out = JapanesePokemonCardPreviewSource::parseSetCards(R"({"id":"X"})"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } +} + +TEST_SUITE("JapanesePokemonCardPreviewSource::parseCardImageUrl") { + TEST_CASE("returns high.png URL") { + const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl( + R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png"); + } + + TEST_CASE("null image is NotFound") { + const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl( + R"({"id":"PMCG1-001","image":null})"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); + } + + TEST_CASE("malformed JSON is Transient") { + const auto out = JapanesePokemonCardPreviewSource::parseCardImageUrl("{bad"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } +} + +TEST_SUITE("JapanesePokemonCardPreviewSource::parsePrintVariants") { + TEST_CASE("matches English catalog name") { + const std::string body = R"({ + "id":"SV1a", + "cards":[ + {"localId":"001","name":"トロピウス","rarity":"Common"}, + {"localId":"002","name":"other","rarity":"Common"} + ] + })"; + const auto catalog = sampleCatalog(); + const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants( + body, "SV1a", "Tropius", catalog); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].setNo == "001"); + } + + TEST_CASE("matches Japanese name without catalog") { + const std::string body = R"({ + "id":"SV1a", + "cards":[{"localId":"001","name":"トロピウス"}] + })"; + JapanesePokemonEnCatalog empty; + const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants( + body, "SV1a", "トロピウス", empty); + REQUIRE(out.isOk()); + CHECK(out.value().front().setNo == "001"); + } + + TEST_CASE("rejects stale catalog localId when name_ja disagrees with TCGdex") { + // Historical seed bug: Charmander mapped to 001 (actually Bulbasaur). + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"} + ] + })"); + REQUIRE(catalog.isOk()); + const std::string body = R"({ + "id":"PMCG1", + "cards":[ + {"localId":"001","name":"フシギダネ","rarity":"Common"}, + {"localId":"014","name":"ヒトカゲ","rarity":"Common"} + ] + })"; + const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants( + body, "PMCG1", "Charmander", catalog.value()); + REQUIRE(out.isErr()); + } + + TEST_CASE("accepts corrected catalog localId for Charmander") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"} + ] + })"); + REQUIRE(catalog.isOk()); + const std::string body = R"({ + "id":"PMCG1", + "cards":[ + {"localId":"001","name":"フシギダネ"}, + {"localId":"014","name":"ヒトカゲ","rarity":"Common"} + ] + })"; + const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants( + body, "PMCG1", "Charmander", catalog.value()); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].setNo == "014"); + } + + TEST_CASE("English Blastoise and Mewtwo resolve Expansion Pack localIds") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"032","name_en":"Blastoise","name_ja":"カメックス"}, + {"set_id":"PMCG1","local_id":"050","name_en":"Mewtwo","name_ja":"ミュウツー"} + ] + })"); + REQUIRE(catalog.isOk()); + const std::string body = R"({ + "id":"PMCG1", + "cards":[ + {"localId":"032","name":"カメックス","rarity":"Holo Rare"}, + {"localId":"050","name":"ミュウツー","rarity":"Holo Rare"} + ] + })"; + auto blast = JapanesePokemonCardPreviewSource::parsePrintVariants( + body, "PMCG1", "Blastoise", catalog.value()); + REQUIRE(blast.isOk()); + REQUIRE(blast.value().size() == 1); + CHECK(blast.value()[0].setNo == "032"); + + auto mew = JapanesePokemonCardPreviewSource::parsePrintVariants( + body, "PMCG1", "Mewtwo", catalog.value()); + REQUIRE(mew.isOk()); + REQUIRE(mew.value().size() == 1); + CHECK(mew.value()[0].setNo == "050"); + } + + TEST_CASE("English Switch resolves Expansion Pack localId 073") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"073","name_en":"Switch","name_ja":"ポケモンいれかえ"} + ] + })"); + REQUIRE(catalog.isOk()); + const std::string body = R"({ + "id":"PMCG1", + "cards":[ + {"localId":"071","name":"きずぐすり","rarity":"Common"}, + {"localId":"073","name":"ポケモンいれかえ","rarity":"Common"} + ] + })"; + const auto out = JapanesePokemonCardPreviewSource::parsePrintVariants( + body, "PMCG1", "Switch", catalog.value()); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].setNo == "073"); + } +} + +TEST_SUITE("JapanesePokemonCardPreviewSource::fetchImageUrl") { + TEST_CASE("resolves via card endpoint when localId present") { + RoutingHttpClient http; + http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] = + R"({"id":"SV1a-001","image":"https://assets.tcgdex.net/ja/SV/SV1a/001"})"; + auto catalog = sampleCatalog(); + JapanesePokemonCardPreviewSource src{http, catalog}; + const auto out = src.fetchImageUrl("Tropius", "SV1a", "001"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://assets.tcgdex.net/ja/SV/SV1a/001/high.png"); + } + + TEST_CASE("resolves via set detail when card has no image but set row does") { + RoutingHttpClient http; + http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("SV1a", "001")] = + R"({"id":"SV1a-001","image":null})"; + http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("SV1a")] = R"({ + "id":"SV1a", + "cards":[{"localId":"001","name":"トロピウス", + "image":"https://assets.tcgdex.net/ja/SV/SV1a/001"}] + })"; + auto catalog = sampleCatalog(); + JapanesePokemonCardPreviewSource src{http, catalog}; + const auto out = src.fetchImageUrl("Tropius", "SV1a", "001"); + REQUIRE(out.isOk()); + CHECK(out.value().find("/high.png") != std::string::npos); + } + + TEST_CASE("stale catalog does not bind English name to wrong localId image") { + RoutingHttpClient http; + http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({ + "id":"PMCG1", + "cards":[ + {"localId":"001","name":"フシギダネ", + "image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"}, + {"localId":"014","name":"ヒトカゲ", + "image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"} + ] + })"; + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"} + ] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + // Empty setNo forces name match; stale catalog must not pick Bulbasaur's art. + const auto out = src.fetchImageUrl("Charmander", "PMCG1", ""); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); + } + + TEST_CASE("English name resolves correct localId image via catalog") { + RoutingHttpClient http; + http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({ + "id":"PMCG1", + "cards":[ + {"localId":"001","name":"フシギダネ", + "image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/001"}, + {"localId":"014","name":"ヒトカゲ", + "image":"https://assets.tcgdex.net/ja/PMCG/PMCG1/014"} + ] + })"; + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"014","name_en":"Charmander","name_ja":"ヒトカゲ"} + ] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto out = src.fetchImageUrl("Charmander", "PMCG1", ""); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://assets.tcgdex.net/ja/PMCG/PMCG1/014/high.png"); + } + + TEST_CASE("null image on set-specific card is NotFound without other-printing fallback") { + RoutingHttpClient http; + http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] = + R"({"id":"PMCG1-021","name":"リザードン","image":null})"; + http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({ + "id":"PMCG1", + "cards":[{"localId":"021","name":"リザードン"}] + })"; + JapanesePokemonEnCatalog empty; + JapanesePokemonCardPreviewSource src{http, empty}; + const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::NotFound); + } + + TEST_CASE("catalog tcgplayer_id gap-fills when TCGdex image is null") { + RoutingHttpClient http; + http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("PMCG1", "021")] = + R"({"id":"PMCG1-021","name":"リザードン","image":null})"; + http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("PMCG1")] = R"({ + "id":"PMCG1", + "cards":[{"localId":"021","name":"リザードン"}] + })"; + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"021","name_en":"Charizard", + "name_ja":"リザードン","tcgplayer_id":"575604"} + ] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto out = src.fetchImageUrl("Charizard", "PMCG1", "021"); + REQUIRE(out.isOk()); + CHECK(out.value() == + "https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg"); + } + + TEST_CASE("HTTP failure is Transient") { + RoutingHttpClient http; + http.ok = false; + JapanesePokemonEnCatalog empty; + JapanesePokemonCardPreviewSource src{http, empty}; + const auto out = src.fetchImageUrl("Tropius", "SV1a", "001"); + REQUIRE(out.isErr()); + CHECK(out.error().kind == PreviewLookupError::Kind::Transient); + } + + TEST_CASE("catalog-only theme deck resolves preview from tcgplayer_id") { + RoutingHttpClient http; + // No TCGdex bodies: card + set detail both miss. + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}}, + "prints": [ + {"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym", + "name_ja":"タマムシシティジム","tcgplayer_id":"12345"} + ] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto out = src.fetchImageUrl("Celadon City Gym", "TamamushiCG", "021"); + REQUIRE(out.isOk()); + CHECK(out.value() == + "https://product-images.tcgplayer.com/fit-in/437x437/12345.jpg"); + } + + TEST_CASE("Tamamushi City Gym Erika uses catalog tcgplayer gap-fill") { + RoutingHttpClient http; + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}}, + "prints": [ + {"set_id":"TamamushiCG","local_id":"016","name_en":"Erika", + "name_ja":"エリカ","name_en_source":"trainer-table", + "tcgplayer_id":"576776"} + ] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto out = src.fetchImageUrl("Erika", "TamamushiCG", "016"); + REQUIRE(out.isOk()); + CHECK(out.value() == + "https://product-images.tcgplayer.com/fit-in/437x437/576776.jpg"); + } + + TEST_CASE("neo catalog image_url gap-fills when TCGdex image is null") { + RoutingHttpClient http; + http.bodies[JapanesePokemonCardPreviewSource::buildCardUrl("neo4", "106")] = + R"({"id":"neo4-106","name":"ラッキースタジアム","image":null})"; + http.bodies[JapanesePokemonCardPreviewSource::buildSetDetailUrl("neo4")] = R"({ + "id":"neo4", + "cards":[{"localId":"106","name":"ラッキースタジアム"}] + })"; + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"neo4","local_id":"106","name_en":"Shining Celebi", + "name_ja":"輝くセレビ","name_en_source":"species-table-variant", + "image_url":"https://images.pokemontcg.io/neo4/106_hires.png"} + ] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto out = src.fetchImageUrl("Shining Celebi", "neo4", "106"); + REQUIRE(out.isOk()); + CHECK(out.value() == "https://images.pokemontcg.io/neo4/106_hires.png"); + } +} + +TEST_SUITE("JapanesePokemonCardPreviewSource::detectPrintVariants catalog-only") { + TEST_CASE("English trainer name resolves when TCGdex set detail is unavailable") { + RoutingHttpClient http; + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {"TamamushiCG":{"name_en":"Tamamushi City Gym"}}, + "prints": [ + {"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym", + "name_ja":"タマムシシティジム","name_en_source":"trainer-table", + "image_url":"https://example.com/celadon.jpg"}, + {"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish", + "name_ja":"エリカのナゾノクサ","name_en_source":"manual", + "image_url":"https://example.com/oddish.jpg"} + ] + })json"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto out = src.detectPrintVariants("Celadon City Gym", "TamamushiCG"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].setNo == "021"); + } + + TEST_CASE("detectPrintVariantsFromCatalog includes UnnumberedPromo prints without image_url") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)", + "name_ja":"ミュウツー","name_en_source":"manual"}, + {"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)", + "name_ja":"ミュウツー","name_en_source":"manual"}, + {"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)", + "name_ja":"ミュウツー","name_en_source":"manual", + "image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"}, + {"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)", + "name_ja":"","name_en_source":"manual"} + ] + })json"); + REQUIRE(catalog.isOk()); + const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "UnnumberedPromo", "Mewtwo", catalog.value()); + REQUIRE(mew.isOk()); + REQUIRE(mew.value().size() == 4); + // Imaged prints first, then empty-URL identity rows. + CHECK(mew.value()[0].setNo == "030"); + CHECK(mew.value()[1].setNo == "007"); + CHECK(mew.value()[2].setNo == "008"); + CHECK(mew.value()[3].setNo == "045"); + + RoutingHttpClient http; + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto img = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030"); + REQUIRE(img.isOk()); + CHECK(img.value() == + "https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"); + } + + TEST_CASE("fetchImageUrl does not borrow sibling UnnumberedPromo image") { + RoutingHttpClient http; + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)", + "name_ja":"ミュウツー","name_en_source":"manual"}, + {"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)", + "name_ja":"ミュウツー","name_en_source":"manual", + "image_url":"https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"} + ] + })json"); + REQUIRE(catalog.isOk()); + JapanesePokemonCardPreviewSource src{http, catalog.value()}; + const auto empty = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "007"); + REQUIRE(empty.isErr()); + CHECK(empty.error().kind == PreviewLookupError::Kind::NotFound); + + const auto whf = src.fetchImageUrl("Mewtwo", "UnnumberedPromo", "030"); + REQUIRE(whf.isOk()); + CHECK(whf.value() == + "https://archives.bulbagarden.net/media/upload/w/w/MewtwoWHF.jpg"); + } + + TEST_CASE("detectPrintVariantsFromCatalog dedupes shared preview URLs") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"UnnumberedPromo","local_id":"030","name_en":"Mewtwo (WHF Special Sheet promo)", + "image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"}, + {"set_id":"UnnumberedPromo","local_id":"073","name_en":"Mewtwo (Song Best Collection promo)", + "image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"}, + {"set_id":"UnnumberedPromo","local_id":"197","name_en":"Mewtwo (Wizards Promo 12)", + "image_url":"https://archives.bulbagarden.net/media/upload/w/w/same.jpg"} + ] + })json"); + REQUIRE(catalog.isOk()); + const auto mew = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "UnnumberedPromo", "Mewtwo", catalog.value()); + REQUIRE(mew.isOk()); + REQUIRE(mew.value().size() == 1); + CHECK(mew.value()[0].setNo == "030"); + } + + TEST_CASE("detectPrintVariantsFromCatalog matches owner Pokemon English title") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"TamamushiCG","local_id":"001","name_en":"Erika's Oddish", + "name_ja":"エリカのナゾノクサ", + "image_url":"https://example.com/oddish.jpg"} + ] + })json"); + REQUIRE(catalog.isOk()); + const auto out = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "TamamushiCG", "Erika's Oddish", catalog.value()); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value()[0].setNo == "001"); + } + + TEST_CASE("detectPrintVariantsFromCatalog matches Dark Rocket and Owner PMCG titles") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"PMCG4","local_id":"017","name_en":"Dark Charizard", + "name_ja":"わるいリザードン","name_en_source":"species-table-variant", + "tcgplayer_id":"575744"}, + {"set_id":"PMCG6","local_id":"042","name_en":"Rocket's Zapdos", + "name_ja":"R団のサンダー","name_en_source":"species-table-variant", + "tcgplayer_id":"1"}, + {"set_id":"PMCG5","local_id":"002","name_en":"Erika's Oddish", + "name_ja":"エリカのナゾノクサ","name_en_source":"species-table-variant", + "tcgplayer_id":"2"} + ] + })json"); + REQUIRE(catalog.isOk()); + const auto dark = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "PMCG4", "Dark Charizard", catalog.value()); + REQUIRE(dark.isOk()); + REQUIRE(dark.value().size() == 1); + CHECK(dark.value()[0].setNo == "017"); + + const auto rocket = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "PMCG6", "Rocket's Zapdos", catalog.value()); + REQUIRE(rocket.isOk()); + REQUIRE(rocket.value().size() == 1); + CHECK(rocket.value()[0].setNo == "042"); + + const auto owner = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "PMCG5", "Erika's Oddish", catalog.value()); + REQUIRE(owner.isOk()); + REQUIRE(owner.value().size() == 1); + CHECK(owner.value()[0].setNo == "002"); + } + + TEST_CASE("detectPrintVariantsFromCatalog matches Light and Shining neo titles") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"neo4","local_id":"004","name_en":"Light Sunflora", + "name_ja":"軽いサンフロラ","name_en_source":"species-table-variant", + "image_url":"https://example.com/sunflora.png"}, + {"set_id":"neo4","local_id":"013","name_en":"Shining Celebi", + "name_ja":"輝くセレビ","name_en_source":"species-table-variant", + "image_url":"https://example.com/celebi.png"} + ] + })json"); + REQUIRE(catalog.isOk()); + const auto light = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "neo4", "Light Sunflora", catalog.value()); + REQUIRE(light.isOk()); + REQUIRE(light.value().size() == 1); + CHECK(light.value()[0].setNo == "004"); + + const auto shining = JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog( + "neo4", "Shining Celebi", catalog.value()); + REQUIRE(shining.isOk()); + REQUIRE(shining.value().size() == 1); + CHECK(shining.value()[0].setNo == "013"); + } +} diff --git a/tests/japanese_pokemon_en_catalog_tests.cpp b/tests/japanese_pokemon_en_catalog_tests.cpp new file mode 100644 index 0000000..06cd155 --- /dev/null +++ b/tests/japanese_pokemon_en_catalog_tests.cpp @@ -0,0 +1,152 @@ +#include + +#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp" + +using namespace ccm; + +TEST_SUITE("JapanesePokemonEnCatalog") { + TEST_CASE("parses sets and prints") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": { + "PMCG1": { + "name_en": "Expansion Pack", + "name_ja": "拡張パック", + "releaseDate": "1996/10/20" + } + }, + "prints": [ + { + "set_id": "PMCG1", + "local_id": "001", + "name_en": "Charmander", + "name_ja": "ヒトカゲ", + "name_en_source": "bulbapedia" + } + ] + })"); + REQUIRE(catalog.isOk()); + CHECK_FALSE(catalog.value().empty()); + + auto set = catalog.value().findSet("PMCG1"); + REQUIRE(set.has_value()); + CHECK(set->nameEn == "Expansion Pack"); + CHECK(set->releaseDate == "1996/10/20"); + + auto print = catalog.value().findPrint("PMCG1", "001"); + REQUIRE(print.has_value()); + CHECK(print->nameEn == "Charmander"); + CHECK(print->nameEnSource == "bulbapedia"); + } + + TEST_CASE("parses optional tcgplayer_id and image_url") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + { + "set_id": "PMCG1", + "local_id": "021", + "name_en": "Charizard", + "name_ja": "リザードン", + "tcgplayer_id": 575604 + }, + { + "set_id": "SV1a", + "local_id": "001", + "name_en": "Tropius", + "image_url": "https://example.com/tropius.png" + } + ] + })"); + REQUIRE(catalog.isOk()); + auto charizard = catalog.value().findPrint("PMCG1", "021"); + REQUIRE(charizard.has_value()); + CHECK(charizard->tcgplayerId == "575604"); + CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*charizard) == + "https://product-images.tcgplayer.com/fit-in/437x437/575604.jpg"); + + auto tropius = catalog.value().findPrint("SV1a", "001"); + REQUIRE(tropius.has_value()); + CHECK(JapanesePokemonEnCatalog::previewImageUrlFromPrint(*tropius) == + "https://example.com/tropius.png"); + } + + TEST_CASE("findPrintsByName is case-insensitive on English") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"PMCG1","local_id":"001","name_en":"Charmander","name_ja":"ヒトカゲ"} + ] + })"); + REQUIRE(catalog.isOk()); + const auto hits = catalog.value().findPrintsByName("PMCG1", "charmander"); + REQUIRE(hits.size() == 1); + CHECK(hits[0].localId == "001"); + } + + TEST_CASE("findPrintsByName matches qualified English titles by bare prefix") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"UnnumberedPromo","local_id":"007","name_en":"Mewtwo (CoroCoro promo)"}, + {"set_id":"UnnumberedPromo","local_id":"008","name_en":"Mewtwo (Fan Book promo)"}, + {"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (Jumbo)"}, + {"set_id":"UnnumberedPromo","local_id":"001","name_en":"Pikachu (CoroCoro promo)"} + ] + })json"); + REQUIRE(catalog.isOk()); + const auto hits = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo"); + REQUIRE(hits.size() == 3); + CHECK(hits[0].localId == "007"); + CHECK(hits[1].localId == "008"); + CHECK(hits[2].localId == "045"); + // Exact full title still works. + const auto exact = catalog.value().findPrintsByName( + "UnnumberedPromo", "Mewtwo Strikes Back (Jumbo)"); + REQUIRE(exact.size() == 1); + CHECK(exact[0].localId == "045"); + } + + TEST_CASE("findPrintsByName whole-token matches owner and GR titles") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"json({ + "sets": {}, + "prints": [ + {"set_id":"UnnumberedPromo","local_id":"227","name_en":"Team GR's Mewtwo (Pokémon Card GB2 promo)"}, + {"set_id":"UnnumberedPromo","local_id":"045","name_en":"Mewtwo Strikes Back (CoroCoro promo) (Jumbo)"}, + {"set_id":"UnnumberedPromo","local_id":"016","name_en":"Mew (CoroCoro promo)"} + ] + })json"); + REQUIRE(catalog.isOk()); + const auto mewtwo = catalog.value().findPrintsByName("UnnumberedPromo", "Mewtwo"); + REQUIRE(mewtwo.size() == 2); + CHECK(mewtwo[0].localId == "227"); + CHECK(mewtwo[1].localId == "045"); + + // "Mew" must not match "Mewtwo …" rows. + const auto mew = catalog.value().findPrintsByName("UnnumberedPromo", "Mew"); + REQUIRE(mew.size() == 1); + CHECK(mew[0].localId == "016"); + } + + TEST_CASE("hasPrintsForSet reports curated classic products") { + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": {}, + "prints": [ + {"set_id":"TamamushiCG","local_id":"021","name_en":"Celadon City Gym"} + ] + })"); + REQUIRE(catalog.isOk()); + CHECK(catalog.value().hasPrintsForSet("TamamushiCG")); + CHECK_FALSE(catalog.value().hasPrintsForSet("PMCG1")); + } + + TEST_CASE("missing set/print returns nullopt") { + JapanesePokemonEnCatalog empty; + CHECK_FALSE(empty.findSet("X").has_value()); + CHECK_FALSE(empty.findPrint("X", "1").has_value()); + CHECK(empty.empty()); + } + + TEST_CASE("malformed JSON is an error") { + CHECK(JapanesePokemonEnCatalog::parse("{not json").isErr()); + } +} diff --git a/tests/japanese_pokemon_set_source_tests.cpp b/tests/japanese_pokemon_set_source_tests.cpp new file mode 100644 index 0000000..001c13c --- /dev/null +++ b/tests/japanese_pokemon_set_source_tests.cpp @@ -0,0 +1,317 @@ +#include + +#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp" +#include "ccm/ports/IHttpClient.hpp" + +#include +#include +#include + +using namespace ccm; + +namespace { + +class RoutingHttpClient final : public IHttpClient { +public: + std::unordered_map bodies; + std::string lastUrl; + bool ok = true; + Result get(std::string_view url) override { + lastUrl = std::string(url); + if (!ok) return Result::err("offline"); + const auto it = bodies.find(lastUrl); + if (it == bodies.end()) return Result::err("unknown url"); + return Result::ok(it->second); + } +}; + +} // namespace + +TEST_SUITE("JapanesePokemonSetSource helpers") { + TEST_CASE("excludes CS* set ids") { + CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS1a")); + CHECK(JapanesePokemonSetSource::shouldExcludeSetId("CS4a")); + CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("PMCG1")); + CHECK_FALSE(JapanesePokemonSetSource::shouldExcludeSetId("SV1a")); + } + + TEST_CASE("applies SV4a name override") { + CHECK(JapanesePokemonSetSource::applySetNameOverride("SV4a", "wrong") == + "シャイニートレジャーex"); + CHECK(JapanesePokemonSetSource::applySetNameOverride("PMCG1", "拡張パック") == + "拡張パック"); + } + + TEST_CASE("rewrites release date separators") { + CHECK(JapanesePokemonSetSource::rewriteReleaseDate("1996-10-20") == "1996/10/20"); + } + + TEST_CASE("buildSetDetailUrl percent-encodes id") { + CHECK(JapanesePokemonSetSource::buildSetDetailUrl("SV1a") == + "https://api.tcgdex.net/v2/ja/sets/SV1a"); + } +} + +TEST_SUITE("JapanesePokemonSetSource::parseListResponse") { + TEST_CASE("maps id/name and drops CS* entries") { + const std::string json = R"([ + {"id":"PMCG1","name":"拡張パック","cardCount":{"total":102,"official":102}}, + {"id":"CS1a","name":"トリプレットビート","cardCount":{"total":1,"official":1}}, + {"id":"SV4a","name":"レイジングサーフ","cardCount":{"total":320,"official":190}} + ])"; + const auto out = JapanesePokemonSetSource::parseListResponse(json); + REQUIRE(out.isOk()); + // 2 from TCGdex + 11 curated products omitted by TCGdex. + REQUIRE(out.value().size() == 13); + CHECK(out.value()[0].id == "PMCG1"); + CHECK(out.value()[0].name == "拡張パック"); + CHECK(out.value()[1].id == "SV4a"); + CHECK(out.value()[1].name == "シャイニートレジャーex"); + } + + TEST_CASE("injects classic City Gym and Expansion Sheet products") { + const auto out = JapanesePokemonSetSource::parseListResponse("[]"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 11); + const auto hasId = [&](const char* id) { + return std::any_of(out.value().begin(), out.value().end(), + [&](const Set& s) { return s.id == id; }); + }; + CHECK(hasId("UnnumberedPromo")); + CHECK(hasId("TamamushiCG")); + CHECK(hasId("NiviCG")); + CHECK(hasId("HanadaCG")); + CHECK(hasId("KuchibaCG")); + CHECK(hasId("YamabukiCG")); + CHECK(hasId("GurenTG")); + CHECK(hasId("ExpSheet1")); + CHECK(hasId("ExpSheet2")); + CHECK(hasId("ExpSheet3")); + CHECK(hasId("SouthernIslands")); + const Set* unnumbered = nullptr; + const Set* tama = nullptr; + for (const auto& s : out.value()) { + if (s.id == "UnnumberedPromo") unnumbered = &s; + if (s.id == "TamamushiCG") tama = &s; + } + REQUIRE(unnumbered != nullptr); + CHECK(unnumbered->name == "Unnumbered Promotional cards"); + CHECK(unnumbered->releaseDate == "1997/03/06"); + REQUIRE(tama != nullptr); + CHECK(tama->name == "Tamamushi City Gym"); + CHECK(tama->releaseDate == "1998/07/25"); + } + + TEST_CASE("does not duplicate classic products already in the list") { + const std::string json = R"([ + {"id":"TamamushiCG","name":"already-present"} + ])"; + const auto out = JapanesePokemonSetSource::parseListResponse(json); + REQUIRE(out.isOk()); + int tamaCount = 0; + for (const auto& s : out.value()) { + if (s.id == "TamamushiCG") ++tamaCount; + } + CHECK(tamaCount == 1); + // Curated EN name / release date overwrite a stale upstream label. + CHECK(out.value().front().name == "Tamamushi City Gym"); + CHECK(out.value().front().releaseDate == "1998/07/25"); + } + + TEST_CASE("empty array still injects classic products") { + const auto out = JapanesePokemonSetSource::parseListResponse("[]"); + REQUIRE(out.isOk()); + CHECK_FALSE(out.value().empty()); + } + + TEST_CASE("non-array is an error") { + CHECK(JapanesePokemonSetSource::parseListResponse(R"({"data":[]})").isErr()); + } + + TEST_CASE("invalid JSON is an error") { + CHECK(JapanesePokemonSetSource::parseListResponse("{not json").isErr()); + } +} + +TEST_SUITE("JapanesePokemonSetSource::parseReleaseDate") { + TEST_CASE("extracts and rewrites releaseDate") { + const auto out = JapanesePokemonSetSource::parseReleaseDate( + R"({"id":"PMCG1","releaseDate":"1996-10-20"})"); + REQUIRE(out.isOk()); + CHECK(out.value() == "1996/10/20"); + } + + TEST_CASE("missing releaseDate yields empty string") { + const auto out = JapanesePokemonSetSource::parseReleaseDate(R"({"id":"X"})"); + REQUIRE(out.isOk()); + CHECK(out.value().empty()); + } +} + +TEST_SUITE("JapanesePokemonSetSource::fetchAll") { + TEST_CASE("enriches from catalog and sorts by release date") { + RoutingHttpClient http; + http.bodies[JapanesePokemonSetSource::kListEndpoint] = R"([ + {"id":"SV1a","name":"トリプレットビート"}, + {"id":"PMCG1","name":"拡張パック"}, + {"id":"PMCG2","name":"ポケモンジャングル"}, + {"id":"CS1a","name":"junk"} + ])"; + // Catalog supplies dates so detail GETs are skipped. + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": { + "PMCG1": {"name_en":"Expansion Pack","name_ja":"拡張パック","releaseDate":"1996/10/20"}, + "PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"}, + "SV1a": {"name_en":"Triplet Beat","name_ja":"トリプレットビート","releaseDate":"2023/03/10"} + }, + "prints": [] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonSetSource src{http, catalog.value()}; + const auto out = src.fetchAll(); + REQUIRE(out.isOk()); + // CS* dropped; 3 TCGdex + 11 curated injections. + REQUIRE(out.value().size() == 14); + // Expansion Pack → Jungle → UnnumberedPromo (day after Jungle). + CHECK(out.value()[0].id == "PMCG1"); + CHECK(out.value()[1].id == "PMCG2"); + CHECK(out.value()[1].name == "Pokémon Jungle"); + CHECK(out.value()[2].id == "UnnumberedPromo"); + CHECK(out.value()[2].name == "Unnumbered Promotional cards"); + CHECK(out.value()[2].releaseDate == "1997/03/06"); + const Set* pmcg1 = nullptr; + bool foundSv = false; + bool foundTama = false; + for (const auto& s : out.value()) { + if (s.id == "PMCG1") { + pmcg1 = &s; + CHECK(s.name == "Expansion Pack"); + CHECK(s.releaseDate == "1996/10/20"); + } + if (s.id == "SV1a") { + foundSv = true; + CHECK(s.name == "Triplet Beat"); + } + if (s.id == "TamamushiCG") { + foundTama = true; + CHECK(s.name == "Tamamushi City Gym"); + } + } + REQUIRE(pmcg1 != nullptr); + CHECK(foundSv); + CHECK(foundTama); + } + + TEST_CASE("fetches set detail when catalog lacks release date") { + RoutingHttpClient http; + http.bodies[JapanesePokemonSetSource::kListEndpoint] = + R"([{"id":"PMCG1","name":"拡張パック"}])"; + http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG1")] = + R"({"id":"PMCG1","releaseDate":"1996-10-20","cards":[]})"; + JapanesePokemonEnCatalog empty; + JapanesePokemonSetSource src{http, empty}; + const auto out = src.fetchAll(); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 12); // PMCG1 + 11 curated + const Set* pmcg1 = nullptr; + for (const auto& s : out.value()) { + if (s.id == "PMCG1") { + pmcg1 = &s; + break; + } + } + REQUIRE(pmcg1 != nullptr); + CHECK(pmcg1->releaseDate == "1996/10/20"); + // Without catalog EN, never keep Japanese TCGdex names in Set.name. + CHECK(pmcg1->name == "PMCG1"); + } + + TEST_CASE("CJK set names fall back to set id even without catalog") { + RoutingHttpClient http; + http.bodies[JapanesePokemonSetSource::kListEndpoint] = + R"([{"id":"PMCG3","name":"化石の秘密"}])"; + http.bodies[JapanesePokemonSetSource::buildSetDetailUrl("PMCG3")] = + R"({"id":"PMCG3","releaseDate":"1997-06-21"})"; + JapanesePokemonEnCatalog empty; + JapanesePokemonSetSource src{http, empty}; + const auto out = src.fetchAll(); + REQUIRE(out.isOk()); + const Set* pmcg3 = nullptr; + for (const auto& s : out.value()) { + if (s.id == "PMCG3") { + pmcg3 = &s; + break; + } + } + REQUIRE(pmcg3 != nullptr); + CHECK(pmcg3->name == "PMCG3"); + } + + TEST_CASE("network error on list is surfaced") { + RoutingHttpClient http; + http.ok = false; + JapanesePokemonEnCatalog empty; + JapanesePokemonSetSource src{http, empty}; + CHECK(src.fetchAll().isErr()); + } + + TEST_CASE("augmentCachedSets injects classic products into a cached list") { + RoutingHttpClient http; + JapanesePokemonEnCatalog empty; + JapanesePokemonSetSource src{http, empty}; + std::vector cached; + Set pmcg2; + pmcg2.id = "PMCG2"; + pmcg2.name = "Pokémon Jungle"; + pmcg2.releaseDate = "1997/03/05"; + cached.push_back(std::move(pmcg2)); + src.augmentCachedSets(cached); + REQUIRE(cached.size() == 12); + // Jungle stays first; UnnumberedPromo (1997/03/06) is immediately after. + CHECK(cached[0].id == "PMCG2"); + CHECK(cached[1].id == "UnnumberedPromo"); + CHECK(cached[1].releaseDate == "1997/03/06"); + bool foundTama = false; + bool foundUnnumbered = false; + for (const auto& s : cached) { + if (s.id == "TamamushiCG") { + foundTama = true; + CHECK(s.name == "Tamamushi City Gym"); + } + if (s.id == "UnnumberedPromo") { + foundUnnumbered = true; + CHECK(s.name == "Unnumbered Promotional cards"); + } + } + CHECK(foundTama); + CHECK(foundUnnumbered); + } + + TEST_CASE("augmentCachedSets restores English names from catalog") { + RoutingHttpClient http; + const auto catalog = JapanesePokemonEnCatalog::parse(R"({ + "sets": { + "PMCG2": {"name_en":"Pokémon Jungle","name_ja":"ポケモンジャングル","releaseDate":"1997/03/05"} + }, + "prints": [] + })"); + REQUIRE(catalog.isOk()); + JapanesePokemonSetSource src{http, catalog.value()}; + std::vector cached; + Set pmcg2; + pmcg2.id = "PMCG2"; + pmcg2.name = "PMCG2"; // stale cache stored the id as the display name + pmcg2.releaseDate = "1997/03/05"; + cached.push_back(std::move(pmcg2)); + src.augmentCachedSets(cached); + const Set* jungle = nullptr; + for (const auto& s : cached) { + if (s.id == "PMCG2") { + jungle = &s; + break; + } + } + REQUIRE(jungle != nullptr); + CHECK(jungle->name == "Pokémon Jungle"); + } +} diff --git a/tests/json_set_repository_tests.cpp b/tests/json_set_repository_tests.cpp index e90c40b..3f5ee7d 100644 --- a/tests/json_set_repository_tests.cpp +++ b/tests/json_set_repository_tests.cpp @@ -143,13 +143,57 @@ TEST_SUITE("JsonSetRepository") { CHECK(writeFail.error() == "write failed"); } - TEST_CASE("paths are composed from dataStorage and game dir") { + TEST_CASE("paths use region-specific filenames for Pokemon West and Asia") { InMemoryFileSystem fs; auto cfg = makeConfig(fs, "/data"); JsonSetRepository repo{fs, cfg, dirNameFn}; - const std::vector sets = {{"base1", "Base Set", "1999/01/09"}}; + const std::vector west = {{"base1", "Base Set", "1999/01/09"}}; + const std::vector asia = {{"SV1a", "Triplet Beat", "2023/01/20"}}; - REQUIRE(repo.save(Game::Pokemon, sets).isOk()); - CHECK(fs.files().count("/data/pokemon/sets.json") == 1); + REQUIRE(repo.save(Game::Pokemon, west).isOk()); + REQUIRE(repo.save(Game::JapanesePokemon, asia).isOk()); + CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1); + CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1); + CHECK(fs.files().count("/data/pokemon/sets.json") == 0); + } + + TEST_CASE("load migrates legacy pokemon/sets.json to sets-west.json") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + const std::vector sets = {{"base1", "Base Set", "1999/01/09"}}; + REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(sets).dump(2)).isOk()); + + JsonSetRepository repo{fs, cfg, dirNameFn}; + const auto loaded = repo.load(Game::Pokemon); + REQUIRE(loaded.isOk()); + CHECK(loaded.value() == sets); + CHECK(fs.files().count("/data/pokemon/sets-west.json") == 1); + } + + TEST_CASE("load migrates legacy pokemonjp/sets.json to sets-asia.json") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + const std::vector sets = {{"SV1a", "Triplet Beat", "2023/01/20"}}; + REQUIRE(fs.writeText("/data/pokemonjp/sets.json", nlohmann::json(sets).dump(2)).isOk()); + + JsonSetRepository repo{fs, cfg, dirNameFn}; + const auto loaded = repo.load(Game::JapanesePokemon); + REQUIRE(loaded.isOk()); + CHECK(loaded.value() == sets); + CHECK(fs.files().count("/data/pokemon/sets-asia.json") == 1); + } + + TEST_CASE("load prefers new path over legacy when both exist") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + const std::vector legacy = {{"old", "Old", "1999/01/01"}}; + const std::vector neu = {{"new", "New", "2024/01/01"}}; + REQUIRE(fs.writeText("/data/pokemon/sets.json", nlohmann::json(legacy).dump(2)).isOk()); + REQUIRE(fs.writeText("/data/pokemon/sets-west.json", nlohmann::json(neu).dump(2)).isOk()); + + JsonSetRepository repo{fs, cfg, dirNameFn}; + const auto loaded = repo.load(Game::Pokemon); + REQUIRE(loaded.isOk()); + CHECK(loaded.value() == neu); } } diff --git a/tests/local_image_store_tests.cpp b/tests/local_image_store_tests.cpp index 3c48fca..3135f5d 100644 --- a/tests/local_image_store_tests.cpp +++ b/tests/local_image_store_tests.cpp @@ -18,6 +18,7 @@ std::string dirNameForGame(Game g) { case Game::Pokemon: return "pokemon"; case Game::YuGiOh: return "yugioh"; case Game::DigiBattle99: return "digibattle99"; + case Game::JapanesePokemon: return "pokemon"; } return "magic"; } diff --git a/tests/set_service_tests.cpp b/tests/set_service_tests.cpp index 7be748a..91107a4 100644 --- a/tests/set_service_tests.cpp +++ b/tests/set_service_tests.cpp @@ -29,6 +29,9 @@ public: std::string dirName() const override { if (gameId == Game::Magic) return "magic"; if (gameId == Game::Pokemon) return "pokemon"; + if (gameId == Game::YuGiOh) return "yugioh"; + if (gameId == Game::DigiBattle99) return "digibattle99"; + if (gameId == Game::JapanesePokemon) return "pokemon"; return "yugioh"; } std::string displayName() const override { return dirName(); } @@ -179,6 +182,41 @@ TEST_SUITE("SetService") { CHECK(digi.source.calls == 1); } + TEST_CASE("JapanesePokemon 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 jp{Game::JapanesePokemon}; + jp.source.result = Result>::ok( + {{"PMCG1", "Expansion Pack", "1996/10/20"}}); + + svc.registerModule(&magic); + svc.registerModule(&pokemon); + svc.registerModule(&yugioh); + svc.registerModule(&digi); + svc.registerModule(&jp); + + 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::JapanesePokemon); + REQUIRE(out.isOk()); + CHECK(out.value().front().id == "PMCG1"); + CHECK(jp.source.calls == 1); + CHECK(magic.source.calls == 1); + CHECK(pokemon.source.calls == 1); + } + TEST_CASE("updateSets propagates repository save failures") { InMemSetRepo repo; repo.failSave = true; diff --git a/tools/pokemon_jp/README.md b/tools/pokemon_jp/README.md new file mode 100644 index 0000000..b650533 --- /dev/null +++ b/tools/pokemon_jp/README.md @@ -0,0 +1,147 @@ +# Japanese Pokémon EN catalog ETL + +Offline pipeline that builds `ui_wx/assets/pokemon_jp_en_catalog.json` for the +CCM3 Japanese Pokémon module. The C++ app loads this file at startup; it does +**not** scrape Bulbapedia or PokéAPI at runtime. + +## Output schema + +```json +{ + "sets": { + "PMCG1": { + "name_en": "Expansion Pack", + "name_ja": "拡張パック", + "releaseDate": "1996/10/20" + } + }, + "prints": [ + { + "set_id": "PMCG1", + "local_id": "073", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table", + "tcgplayer_id": "575596" + }, + { + "set_id": "PMCG4", + "local_id": "017", + "name_en": "Dark Charizard", + "name_ja": "わるいリザードン", + "name_en_source": "species-table-variant", + "tcgplayer_id": "575744" + } + ] +} +``` + +`name_en_source` is one of `bulbapedia` | `species-table` | `species-table-variant` | +`trainer-table` | `energy-table` | `manual` | `tcgdex-thirdparty`. + +Optional gap-fill fields (classic JA when TCGdex has no CDN scan): + +- `tcgplayer_id` — TCGPlayer product id from data-asia `thirdParty.tcgplayer` + (PMCG and other classic sets). Runtime builds + `https://product-images.tcgplayer.com/fit-in/437x437/{id}.jpg` +- `image_url` — explicit HTTPS URL (wins over `tcgplayer_id` when both set). + Sources: City Gym bundled `asset:pokemon_jp_classic/...` paths, or neo1–neo4 + Japanese CardIndex scans written by `enrich_neo_image_urls.py` (JP only; + empty `image_url` → card-back when no JP scan exists). + +## Suggested steps + +1. Snapshot TCGdex `GET /v2/ja/sets` into `_tcgdex_sets.json`. +2. Maintain curated English display names in `set_en_names.json` (set id → EN). +3. Run `merge_set_en_catalog.py` to emit set EN names into the catalog (also + refreshes classic TCGdex-missing products from `classic_missing_sets.json`). +4. For Original-era products TCGdex omits (City Gym theme decks, Expansion + Sheets, Southern Islands), maintain `classic_missing_sets.json` + + `classic_missing_prints.json` and run `merge_classic_missing.py`. Print + `local_id`s are sequential `001`… within each product. Owner Pokémon use + full English titles (e.g. `Erika's Oddish`), not bare species names. + For Bulbapedia **Unnumbered Promotional cards**, run + `harvest_unnumbered_promos.py` to refresh the `UnnumberedPromo` set + prints, + then `enrich_unnumbered_promo_images.py` to write Bulbagarden Archives + `image_url` values that prefer Japanese / Unnumbered Promotional scans + (reprint/gallery) over English Wizards primary `|image=` files. Binding is + print-identity-aware (set/page tokens, no bare-species page fallback) so + unrelated Mewtwo promos do not share one WHF scan. EN-only Bulbapedia pages + leave `image_url` empty (card-back) — never store Wizards/Base Set EN + scans for Pokemon (Japan). Also fills `name_ja` when present. Then run + `merge_classic_missing.py`. + Cardmarket labels some Expansion Sheet / Vending Pokémon as EXP/EXS; those + may still be filed under `UnnumberedPromo` here (e.g. `Mewtwo (Vending S1)` / + `Mewtwo (Vending S3)`). Auto-detect matches bare species names as whole + tokens (`Mewtwo` → `Team GR's Mewtwo`, `Mewtwo Strikes Back (…)`, not `Mew`). + Runtime UX for this set (no Set # field; Next cycles synthetic localIds; + modeless print-preview popup) is documented under + `docs/assets-and-info-apis.md` → **Sets without printed collector numbers**. + For printing-accurate City Gym deck scans, run + `fetch_classic_gym_images.py` and store deck-specific `image_url` values as + `asset:pokemon_jp_classic//.jpg`. Do **not** reuse PMCG + donor `tcgplayer_id`s for City Gym deck exclusives; that shows the wrong + Leaders' Stadium art. +5. Extend `non_pokemon_en_by_ja.json` when new Trainer/Energy English aliases + are needed (JA name → EN display name; covers reprints of the same JA name). + Baseline coverage: **first 15 chronological TCGdex JA main sets** + (`PMCG1`–`PMCG6`, `neo1`–`neo4`, `VS1`, `web1`, `E1`–`E3`). See + `docs/assets-and-info-apis.md` → **Extending Trainer/Energy English aliases**. +6. Run `enrich_preview_images.py` to merge from TCGdex cards-database + `data-asia`: + - `tcgplayer_id` for classic-image gap-fill (where data-asia exposes it) + - `name_ja` from card sources + - `name_en` via National Dex → English species table (`species_en.json`) + for ordinary Pokémon with `dexId` (e.g. Blastoise → `032`, Mewtwo → `050`) + - **Variant full titles** when `name_ja` matches a known prefix + `dexId` + (owner gym leaders, Rocket's, Dark, Light, Shining — e.g. `Erika's Oddish`, + `Dark Charizard`, `Shining Celebi`). Upgrades existing bare `species-table` + rows on re-enrich. Tagged `species-table-variant`. + - `name_en` via `non_pokemon_en_by_ja.json` for Trainer/Energy + (e.g. Switch ← `ポケモンいれかえ` → localId `073`) +7. Run `enrich_neo_image_urls.py` after enrich when neo1–neo4 previews need + gap-fill. Writes `image_url` from **Japanese** CardIndex set scans (never + English pokemontcg.io). Matching is by English card name within the JA neo + set. Misses clear `image_url` (card-back). Use `--overwrite` to replace + stale EN URLs. See `docs/assets-and-info-apis.md`. +8. Optionally refine `prints[]` name fields via Bulbapedia joins. + +At runtime, `JapanesePokemonSetSource` prefers catalog `name_en` and **never** +leaves Japanese TCGdex names in `Set.name` (falls back to the set id). It also +injects the classic missing products listed above. `JapanesePokemonCardPreviewSource` +uses catalog `tcgplayer_id` / `image_url` only for the exact `setId`+`localId` +when TCGdex has no scan, and falls back to catalog-only Auto-detect/preview +when TCGdex has no set detail for a curated classic product. + +## Commands + +```bash +# After refreshing tools/pokemon_jp/_tcgdex_sets.json and set_en_names.json: +python tools/pokemon_jp/merge_set_en_catalog.py + +# After editing classic_missing_sets.json / classic_missing_prints.json: +python tools/pokemon_jp/merge_classic_missing.py + +# Refresh UnnumberedPromo prints from Bulbapedia, enrich JP images, then merge: +python tools/pokemon_jp/harvest_unnumbered_promos.py +python tools/pokemon_jp/enrich_unnumbered_promo_images.py --force +python tools/pokemon_jp/merge_classic_missing.py + +# After updating bundled City Gym deck scans: +python tools/pokemon_jp/fetch_classic_gym_images.py + +# Harvest TCGPlayer ids + species/trainer/variant English aliases: +python tools/pokemon_jp/enrich_preview_images.py + +# Fill neo1–neo4 image_url from Japanese CardIndex scans (run after enrich): +python tools/pokemon_jp/enrich_neo_image_urls.py +# Replace / clear previously written EN pokemontcg.io neo URLs: +python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite +``` + +Seed-only catalog writer (minimal rows): + +```bash +python tools/pokemon_jp/build_catalog.py \ + --out ui_wx/assets/pokemon_jp_en_catalog.json +``` diff --git a/tools/pokemon_jp/__pycache__/enrich_neo_image_urls.cpython-314.pyc b/tools/pokemon_jp/__pycache__/enrich_neo_image_urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..beec76ecbbe79098db0defc7bc3d9e3a87d7bc20 GIT binary patch literal 15405 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
1P)N; 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 None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--out", + type=Path, + default=Path("ui_wx/assets/pokemon_jp_en_catalog.json"), + help="Output catalog path", + ) + args = parser.parse_args() + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(SEED, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"Wrote seed catalog to {args.out}") + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/build_set_en_catalog.py b/tools/pokemon_jp/build_set_en_catalog.py new file mode 100644 index 0000000..7772da9 --- /dev/null +++ b/tools/pokemon_jp/build_set_en_catalog.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Build a full Japanese Pokémon EN set catalog from TCGdex + Wikipedia. + +Strategy: +1. Pull JA set list from TCGdex (exclude CS*). +2. Pull Wikipedia 'List of Pokémon Trading Card Game sets' wikitext and + extract English / Japanese name pairs from {{lang|ja|...}} near bold titles. +3. Match by Japanese name (after TCGdex overrides). Unmatched sets get a + Latin fallback of the set id (never leave Japanese in Set.name). +4. Fetch release dates from TCGdex set detail for catalog completeness. +""" + +from __future__ import annotations + +import argparse +import json +import re +import time +import urllib.request +from pathlib import Path + +UA = "CardCollectionManager3-ETL/0.1 (local; set-catalog)" +JA_OVERRIDES = { + "SV4a": "シャイニートレジャーex", +} + + +def http_json(url: str): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=60) as resp: + return json.load(resp) + + +def http_text_params(base: str, params: dict) -> dict: + from urllib.parse import urlencode + + return http_json(base + "?" + urlencode(params)) + + +def contains_cjk(s: str) -> bool: + return any( + "\u3040" <= ch <= "\u30ff" + or "\u3400" <= ch <= "\u4dbf" + or "\u4e00" <= ch <= "\u9fff" + or "\uf900" <= ch <= "\ufaff" + for ch in s + ) + + +def wiki_en_ja_pairs() -> dict[str, str]: + """Map Japanese set name -> English display name from Wikipedia.""" + data = http_text_params( + "https://en.wikipedia.org/w/api.php", + { + "action": "parse", + "page": "List of Pokémon Trading Card Game sets", + "prop": "wikitext", + "format": "json", + "formatversion": "2", + }, + ) + wt = data["parse"]["wikitext"] + # '''English Name''' ... lang|ja|Japanese Name + pairs: dict[str, str] = {} + for m in re.finditer( + r"'''([^']+)'''(?P.{0,260}?)\{\{lang\|ja\|(?P[^}]+)\}\}", + wt, + flags=re.DOTALL, + ): + en = m.group(1).strip() + ja = m.group("ja").strip() + # Drop template noise / multi-ja (take first segment before &) + ja = re.split(r"\s*&\s*", ja)[0].strip() + ja = re.sub(r"<[^>]+>", "", ja).strip() + if not ja or not en or not contains_cjk(ja): + continue + # Prefer first English seen for a JA name + pairs.setdefault(ja, en) + return pairs + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--out", + type=Path, + default=Path("ui_wx/assets/pokemon_jp_en_catalog.json"), + ) + parser.add_argument( + "--fetch-dates", + action="store_true", + help="Hit TCGdex set detail for each set (slow, ~1 req/s).", + ) + args = parser.parse_args() + + raw_sets = http_json("https://api.tcgdex.net/v2/ja/sets") + sets = [s for s in raw_sets if not str(s.get("id", "")).startswith("CS")] + print(f"TCGdex JA sets (excl CS*): {len(sets)}") + + ja_to_en = wiki_en_ja_pairs() + print(f"Wikipedia JA->EN pairs: {len(ja_to_en)}") + + # Keep existing print rows if present + existing_prints = [] + if args.out.exists(): + try: + prev = json.loads(args.out.read_text(encoding="utf-8")) + existing_prints = prev.get("prints", []) + except Exception: + pass + + catalog_sets: dict[str, dict] = {} + matched = 0 + for entry in sets: + sid = entry["id"] + name_ja = JA_OVERRIDES.get(sid, entry.get("name", "")) + name_en = ja_to_en.get(name_ja, "") + if not name_en: + # Fuzzy: Wikipedia sometimes includes extra spaces / fullwidth + for ja, en in ja_to_en.items(): + if ja in name_ja or name_ja in ja: + name_en = en + break + if name_en: + matched += 1 + else: + # Never leave CJK in the UI set picker — fall back to set id. + name_en = sid + catalog_sets[sid] = { + "name_en": name_en, + "name_ja": name_ja, + "releaseDate": "", + } + + print(f"Matched Wikipedia EN names: {matched}/{len(sets)}") + print(f"Fallback to set id: {len(sets) - matched}") + + if args.fetch_dates: + for i, sid in enumerate(catalog_sets): + try: + detail = http_json(f"https://api.tcgdex.net/v2/ja/sets/{sid}") + rd = detail.get("releaseDate") or "" + if rd: + catalog_sets[sid]["releaseDate"] = rd.replace("-", "/") + except Exception as exc: + print(f" date fail {sid}: {exc}") + time.sleep(0.35) + if (i + 1) % 20 == 0: + print(f" dates {i+1}/{len(catalog_sets)}") + + out = {"sets": catalog_sets, "prints": existing_prints} + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(f"Wrote {args.out}") + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/classic_missing_prints.json b/tools/pokemon_jp/classic_missing_prints.json new file mode 100644 index 0000000..6fcb7dc --- /dev/null +++ b/tools/pokemon_jp/classic_missing_prints.json @@ -0,0 +1,5502 @@ +[ + { + "set_id": "TamamushiCG", + "local_id": "001", + "name_en": "Erika's Oddish", + "name_ja": "エリカのナゾノクサ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "002", + "name_en": "Erika's Oddish", + "name_ja": "エリカのナゾノクサ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "003", + "name_en": "Erika's Gloom", + "name_ja": "エリカのクサイハナ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "004", + "name_en": "Erika's Vileplume", + "name_ja": "エリカのラフレシア", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "005", + "name_en": "Erika's Bellsprout", + "name_ja": "エリカのマダツボミ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "006", + "name_en": "Erika's Bellsprout", + "name_ja": "エリカのマダツボミ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "007", + "name_en": "Erika's Weepinbell", + "name_ja": "エリカのウツドン", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "008", + "name_en": "Erika's Victreebel", + "name_ja": "エリカのウツボット", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "009", + "name_en": "Erika's Exeggcute", + "name_ja": "エリカのタマタマ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "010", + "name_en": "Erika's Exeggutor", + "name_ja": "エリカのナッシー", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "011", + "name_en": "Erika's Tangela", + "name_ja": "エリカのモンジャラ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "012", + "name_en": "Erika's Clefairy", + "name_ja": "エリカのピッピ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "013", + "name_en": "Erika's Dratini", + "name_ja": "エリカのミニリュウ", + "name_en_source": "manual" + }, + { + "set_id": "TamamushiCG", + "local_id": "014", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "015", + "name_en": "Energy Search", + "name_ja": "エネルギー検索", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "016", + "name_en": "Erika", + "name_ja": "エリカ", + "name_en_source": "trainer-table", + "image_url": "asset:pokemon_jp_classic/TamamushiCG/016.jpg" + }, + { + "set_id": "TamamushiCG", + "local_id": "017", + "name_en": "Erika's Maids", + "name_ja": "エリカのお付き", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "018", + "name_en": "Erika's Perfume", + "name_ja": "エリカの香水", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "019", + "name_en": "Charity", + "name_ja": "チャリティ", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "020", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "021", + "name_en": "Celadon City Gym", + "name_ja": "タマムシシティジム", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "022", + "name_en": "Defender", + "name_ja": "ディフェンダー", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "023", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "024", + "name_en": "PlusPower", + "name_ja": "プラスパワー", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "025", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "026", + "name_en": "Recycle", + "name_ja": "リサイクル", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "027", + "name_en": "Good Manners", + "name_ja": "グッドマナー", + "name_en_source": "trainer-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "028", + "name_en": "Grass Energy", + "name_ja": "基本草エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "029", + "name_en": "Psychic Energy", + "name_ja": "基本超エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "TamamushiCG", + "local_id": "030", + "name_en": "Double Colorless Energy", + "name_ja": "ダブル無色エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "NiviCG", + "local_id": "001", + "name_en": "Brock's Zubat", + "name_ja": "タケシのズバット", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "002", + "name_en": "Brock's Vulpix", + "name_ja": "タケシのロコン", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "003", + "name_en": "Brock's Sandshrew", + "name_ja": "タケシのサンド", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "004", + "name_en": "Brock's Sandslash", + "name_ja": "タケシのサンドパン", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "005", + "name_en": "Brock's Mankey", + "name_ja": "タケシのマンキー", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "006", + "name_en": "Brock's Geodude", + "name_ja": "タケシのイシツブテ", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "007", + "name_en": "Brock's Geodude", + "name_ja": "タケシのイシツブテ", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "008", + "name_en": "Brock's Graveler", + "name_ja": "タケシのゴローン", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "009", + "name_en": "Brock's Golem", + "name_ja": "タケシのゴローニャ", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "010", + "name_en": "Brock's Onix", + "name_ja": "タケシのイワーク", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "011", + "name_en": "Brock's Onix", + "name_ja": "タケシのイワーク", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "012", + "name_en": "Brock's Rhyhorn", + "name_ja": "タケシのサイホーン", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "013", + "name_en": "Brock's Rhydon", + "name_ja": "タケシのサイドン", + "name_en_source": "manual" + }, + { + "set_id": "NiviCG", + "local_id": "014", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "015", + "name_en": "Recall", + "name_ja": "リコール", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "016", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "017", + "name_en": "Revive", + "name_ja": "げんきのかけら", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "018", + "name_en": "Brock", + "name_ja": "タケシ", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "019", + "name_en": "Brock's Training Method", + "name_ja": "タケシの育て方", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "020", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "021", + "name_en": "Pewter City Gym", + "name_ja": "ニビシティジム", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "022", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "023", + "name_en": "Pokédex", + "name_ja": "ポケモン図鑑", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "024", + "name_en": "Pokémon Center", + "name_ja": "ポケモンセンター", + "name_en_source": "trainer-table" + }, + { + "set_id": "NiviCG", + "local_id": "025", + "name_en": "Fire Energy", + "name_ja": "基本炎エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "NiviCG", + "local_id": "026", + "name_en": "Fighting Energy", + "name_ja": "基本闘エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "NiviCG", + "local_id": "027", + "name_en": "Double Colorless Energy", + "name_ja": "ダブル無色エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "HanadaCG", + "local_id": "001", + "name_en": "Misty's Seel", + "name_ja": "カスミのパウワウ", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "002", + "name_en": "Misty's Dewgong", + "name_ja": "カスミのジュゴン", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "003", + "name_en": "Misty's Shellder", + "name_ja": "カスミのシェルダー", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "004", + "name_en": "Misty's Cloyster", + "name_ja": "カスミのパルシェン", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "005", + "name_en": "Misty's Horsea", + "name_ja": "カスミのタッツー", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "006", + "name_en": "Misty's Seadra", + "name_ja": "カスミのシードラ", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "007", + "name_en": "Misty's Goldeen", + "name_ja": "カスミのトサキント", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "008", + "name_en": "Misty's Seaking", + "name_ja": "カスミのアズマオウ", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "009", + "name_en": "Misty's Staryu", + "name_ja": "カスミのヒトデマン", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "010", + "name_en": "Misty's Starmie", + "name_ja": "カスミのスターミー", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "011", + "name_en": "Misty's Psyduck", + "name_ja": "カスミのコダック", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "012", + "name_en": "Misty's Magikarp", + "name_ja": "カスミのコイキング", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "013", + "name_en": "Misty's Goldeen", + "name_ja": "カスミのトサキント", + "name_en_source": "manual" + }, + { + "set_id": "HanadaCG", + "local_id": "014", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "015", + "name_en": "Misty", + "name_ja": "カスミ", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "016", + "name_en": "Misty's Tears", + "name_ja": "カスミのなみだ", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "017", + "name_en": "Misty's Wrath", + "name_ja": "カスミのいかり", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "018", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "019", + "name_en": "Cerulean City Gym", + "name_ja": "ハナダシティジム", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "020", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "021", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "HanadaCG", + "local_id": "022", + "name_en": "Water Energy", + "name_ja": "基本水エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "HanadaCG", + "local_id": "023", + "name_en": "Psychic Energy", + "name_ja": "基本超エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "HanadaCG", + "local_id": "024", + "name_en": "Double Colorless Energy", + "name_ja": "ダブル無色エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "001", + "name_en": "Lt. Surge's Magnemite", + "name_ja": "マチスのコイル", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "002", + "name_en": "Lt. Surge's Magneton", + "name_ja": "マチスのレアコイル", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "003", + "name_en": "Lt. Surge's Voltorb", + "name_ja": "マチスのビリリダマ", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "004", + "name_en": "Lt. Surge's Electrode", + "name_ja": "マチスのマルマイン", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "005", + "name_en": "Lt. Surge's Pikachu", + "name_ja": "マチスのピカチュウ", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "006", + "name_en": "Lt. Surge's Raichu", + "name_ja": "マチスのライチュウ", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "007", + "name_en": "Lt. Surge's Electabuzz", + "name_ja": "マチスのエレブー", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "008", + "name_en": "Lt. Surge's Eevee", + "name_ja": "マチスのイーブイ", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "009", + "name_en": "Lt. Surge's Jolteon", + "name_ja": "マチスのサンダース", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "010", + "name_en": "Lt. Surge's Spearow", + "name_ja": "マチスのオニスズメ", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "011", + "name_en": "Lt. Surge's Fearow", + "name_ja": "マチスのオニドリル", + "name_en_source": "manual" + }, + { + "set_id": "KuchibaCG", + "local_id": "012", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "013", + "name_en": "Lt. Surge", + "name_ja": "マチス", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "014", + "name_en": "Lt. Surge's Secret Plan", + "name_ja": "マチスの秘策", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "015", + "name_en": "Lt. Surge's Treaty", + "name_ja": "マチスの交渉", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "016", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "017", + "name_en": "Vermilion City Gym", + "name_ja": "クチバシティジム", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "018", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "019", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "020", + "name_en": "PlusPower", + "name_ja": "プラスパワー", + "name_en_source": "trainer-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "021", + "name_en": "Lightning Energy", + "name_ja": "基本雷エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "KuchibaCG", + "local_id": "022", + "name_en": "Double Colorless Energy", + "name_ja": "ダブル無色エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "001", + "name_en": "Sabrina's Abra", + "name_ja": "ナツメのケーシィ", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "002", + "name_en": "Sabrina's Kadabra", + "name_ja": "ナツメのユンゲラー", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "003", + "name_en": "Sabrina's Alakazam", + "name_ja": "ナツメのフーディン", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "004", + "name_en": "Sabrina's Drowzee", + "name_ja": "ナツメのスリープ", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "005", + "name_en": "Sabrina's Hypno", + "name_ja": "ナツメのスリーパー", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "006", + "name_en": "Sabrina's Mr. Mime", + "name_ja": "ナツメのバリヤード", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "007", + "name_en": "Sabrina's Jynx", + "name_ja": "ナツメのルージュラ", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "008", + "name_en": "Sabrina's Porygon", + "name_ja": "ナツメのポリゴン", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "009", + "name_en": "Sabrina's Gastly", + "name_ja": "ナツメのゴース", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "010", + "name_en": "Sabrina's Haunter", + "name_ja": "ナツメのゴースト", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "011", + "name_en": "Sabrina's Slowpoke", + "name_ja": "ナツメのヤドン", + "name_en_source": "manual" + }, + { + "set_id": "YamabukiCG", + "local_id": "012", + "name_en": "Sabrina", + "name_ja": "ナツメ", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "013", + "name_en": "Sabrina's ESP", + "name_ja": "ナツメのESP", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "014", + "name_en": "Sabrina's Gaze", + "name_ja": "ナツメの眼", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "015", + "name_en": "Sabrina's Psychic Control", + "name_ja": "ナツメのサイキックコントロール", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "016", + "name_en": "Saffron City Gym", + "name_ja": "ヤマブキシティジム", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "017", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "018", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "019", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "020", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "021", + "name_en": "Psychic Energy", + "name_ja": "基本超エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "YamabukiCG", + "local_id": "022", + "name_en": "Double Colorless Energy", + "name_ja": "ダブル無色エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "GurenTG", + "local_id": "001", + "name_en": "Blaine's Charmander", + "name_ja": "カツラのヒトカゲ", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "002", + "name_en": "Blaine's Charmeleon", + "name_ja": "カツラのリザード", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "003", + "name_en": "Blaine's Growlithe", + "name_ja": "カツラのガーディ", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "004", + "name_en": "Blaine's Arcanine", + "name_ja": "カツラのウインディ", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "005", + "name_en": "Blaine's Vulpix", + "name_ja": "カツラのロコン", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "006", + "name_en": "Blaine's Ninetales", + "name_ja": "カツラのキュウコン", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "007", + "name_en": "Blaine's Ponyta", + "name_ja": "カツラのポニータ", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "008", + "name_en": "Blaine's Rapidash", + "name_ja": "カツラのギャロップ", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "009", + "name_en": "Blaine's Magmar", + "name_ja": "カツラのブーバー", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "010", + "name_en": "Blaine's Moltres", + "name_ja": "カツラのファイヤー", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "011", + "name_en": "Blaine's Tauros", + "name_ja": "カツラのケンタロス", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "012", + "name_en": "Blaine's Kangaskhan", + "name_ja": "カツラのガルーラ", + "name_en_source": "manual" + }, + { + "set_id": "GurenTG", + "local_id": "013", + "name_en": "Blaine", + "name_ja": "カツラ", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "014", + "name_en": "Blaine's Gamble", + "name_ja": "カツラのギャンブル", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "015", + "name_en": "Blaine's Last Resort", + "name_ja": "カツラの奥の手", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "016", + "name_en": "Cinnabar Island Gym", + "name_ja": "グレンタウンジム", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "017", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "018", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "019", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "020", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "GurenTG", + "local_id": "021", + "name_en": "Fire Energy", + "name_ja": "基本炎エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "GurenTG", + "local_id": "022", + "name_en": "Double Colorless Energy", + "name_ja": "ダブル無色エネルギー", + "name_en_source": "energy-table" + }, + { + "set_id": "SouthernIslands", + "local_id": "001", + "name_en": "Mew", + "name_ja": "ミュウ", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "002", + "name_en": "Togepi", + "name_ja": "トゲピー", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "003", + "name_en": "Marill", + "name_ja": "マリル", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "004", + "name_en": "Ledyba", + "name_ja": "レディバ", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "005", + "name_en": "Spinarak", + "name_ja": "イトマル", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "006", + "name_en": "Hoothoot", + "name_ja": "ホーホー", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "007", + "name_en": "Chikorita", + "name_ja": "チコリータ", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "008", + "name_en": "Cubone", + "name_ja": "カラカラ", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "009", + "name_en": "Exeggutor", + "name_ja": "ナッシー", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "010", + "name_en": "Jigglypuff", + "name_ja": "プリン", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "011", + "name_en": "Tentacool", + "name_ja": "メノクラゲ", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "012", + "name_en": "Onix", + "name_ja": "イワーク", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "013", + "name_en": "Butterfree", + "name_ja": "バタフリー", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "014", + "name_en": "Raticate", + "name_ja": "ラッタ", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "015", + "name_en": "Ivysaur", + "name_ja": "フシギソウ", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "016", + "name_en": "Wartortle", + "name_ja": "カメール", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "017", + "name_en": "Charmeleon", + "name_ja": "リザード", + "name_en_source": "manual" + }, + { + "set_id": "SouthernIslands", + "local_id": "018", + "name_en": "Lickitung", + "name_ja": "ベロリンガ", + "name_en_source": "manual" + }, + { + "set_id": "ExpSheet1", + "local_id": "001", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "002", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "003", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "004", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "005", + "name_en": "Pokémon Center", + "name_ja": "ポケモンセンター", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "006", + "name_en": "Pokédex", + "name_ja": "ポケモン図鑑", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "007", + "name_en": "Bill", + "name_ja": "マサキ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "008", + "name_en": "Professor Oak", + "name_ja": "オーキドはかせ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "009", + "name_en": "Computer Search", + "name_ja": "パソコン通信", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet1", + "local_id": "010", + "name_en": "Item Finder", + "name_ja": "ダウジングマシーン", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "001", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "002", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "003", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "004", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "005", + "name_en": "Pokémon Center", + "name_ja": "ポケモンセンター", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "006", + "name_en": "Pokédex", + "name_ja": "ポケモン図鑑", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "007", + "name_en": "Bill", + "name_ja": "マサキ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "008", + "name_en": "Professor Oak", + "name_ja": "オーキドはかせ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "009", + "name_en": "Computer Search", + "name_ja": "パソコン通信", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet2", + "local_id": "010", + "name_en": "Item Finder", + "name_ja": "ダウジングマシーン", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "001", + "name_en": "Potion", + "name_ja": "きずぐすり", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "002", + "name_en": "Switch", + "name_ja": "ポケモンいれかえ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "003", + "name_en": "Energy Retrieval", + "name_ja": "エネルギー回収", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "004", + "name_en": "Full Heal", + "name_ja": "なんでもなおし", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "005", + "name_en": "Pokémon Center", + "name_ja": "ポケモンセンター", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "006", + "name_en": "Pokédex", + "name_ja": "ポケモン図鑑", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "007", + "name_en": "Bill", + "name_ja": "マサキ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "008", + "name_en": "Professor Oak", + "name_ja": "オーキドはかせ", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "009", + "name_en": "Computer Search", + "name_ja": "パソコン通信", + "name_en_source": "trainer-table" + }, + { + "set_id": "ExpSheet3", + "local_id": "010", + "name_en": "Item Finder", + "name_ja": "ダウジングマシーン", + "name_en_source": "trainer-table" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "001", + "name_en": "Pikachu (CoroCoro promo)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pikachu", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/15/PikachuCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "002", + "name_en": "Jigglypuff (CoroCoro promo)", + "name_ja": "プリン", + "name_en_source": "manual", + "bulbapedia_page": "Jigglypuff (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Jigglypuff", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/f3/JigglypuffCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "003", + "name_en": "Pikachu (Asobikata promo)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (Asobikata promo)", + "tcg_set": "Asobikata", + "tcg_name": "Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "004", + "name_en": "Jigglypuff (Asobikata promo)", + "name_ja": "プリン", + "name_en_source": "manual", + "bulbapedia_page": "Jigglypuff (Asobikata promo)", + "tcg_set": "Asobikata", + "tcg_name": "Jigglypuff", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "005", + "name_en": "Mew (CoroCoro promo)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Mew (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Mew", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "006", + "name_en": "Slowpoke (Playmat promo)", + "name_ja": "ヤドン", + "name_en_source": "manual", + "bulbapedia_page": "Slowpoke (Playmat promo)", + "tcg_set": "Playmat", + "tcg_name": "Slowpoke", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/23/SlowpokePlaymatPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "007", + "name_en": "Mewtwo (CoroCoro promo)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Mewtwo", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "008", + "name_en": "Mewtwo (Fan Book promo)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (Fan Book promo)", + "tcg_set": "Fan Book", + "tcg_name": "Mewtwo", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "009", + "name_en": "Super Energy Retrieval (Fan Book promo)", + "name_ja": "超エネルギー回収", + "name_en_source": "manual", + "bulbapedia_page": "Super Energy Retrieval (Fan Book promo)", + "tcg_set": "Fan Book", + "tcg_name": "Super Energy Retrieval", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "010", + "name_en": "Electabuzz (Card File promo)", + "name_ja": "エレブー", + "name_en_source": "manual", + "bulbapedia_page": "Electabuzz (Card File promo)", + "tcg_set": "Card File", + "tcg_name": "Electabuzz", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "011", + "name_en": "No.1 Trainer (Pokémon Card Game Official Tournament promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Pokémon Card Game Official Tournament promo)", + "tcg_set": "Pokémon Card Game Official Tournament", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/fb/No1Trainer2014WorldChampionshipsPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "012", + "name_en": "No.2 Trainer (Pokémon Card Game Official Tournament promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Pokémon Card Game Official Tournament promo)", + "tcg_set": "Pokémon Card Game Official Tournament", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/b/b9/No2Trainer2014WorldChampionshipsPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "013", + "name_en": "No.3 Trainer (Pokémon Card Game Official Tournament promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Pokémon Card Game Official Tournament promo)", + "tcg_set": "Pokémon Card Game Official Tournament", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/5/51/No3Trainer2014WorldChampionshipsPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "014", + "name_en": "Pikachu, Jigglypuff, and Clefairy (CoroCoro promo) (Jumbo)", + "name_ja": "ピカチュウとプリンとピッピ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu, Jigglypuff, and Clefairy (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pikachu, Jigglypuff, and Clefairy", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c2/PikachuJigglypuffClefairyCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "015", + "name_en": "Surfing Pikachu (JR promo)", + "name_ja": "なみのりピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Surfing Pikachu (JR promo)", + "tcg_set": "JR", + "tcg_name": "Surfing Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "016", + "name_en": "Mew (CoroCoro promo)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Mew (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Mew", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "017", + "name_en": "Surfing Pikachu (CoroCoro promo)", + "name_ja": "なみのりピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Surfing Pikachu (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Surfing Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "018", + "name_en": "Imakuni? (CoroCoro promo)", + "name_ja": "イマクニ?", + "name_en_source": "manual", + "bulbapedia_page": "Imakuni? (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Imakuni?", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c3/ImakuniCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "019", + "name_en": "Articuno (Phone Card promo)", + "name_ja": "フリーザー", + "name_en_source": "manual", + "bulbapedia_page": "Articuno (Phone Card promo)", + "tcg_set": "Phone Card", + "tcg_name": "Articuno", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "020", + "name_en": "Articuno, Moltres, and Zapdos (CoroCoro promo) (Jumbo)", + "name_ja": "サンダーとフリーザーとファイヤー", + "name_en_source": "manual", + "bulbapedia_page": "Articuno, Moltres, and Zapdos (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Articuno, Moltres, and Zapdos", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "021", + "name_en": "Arcanine (Toyota promo)", + "name_ja": "ウィンディ", + "name_en_source": "manual", + "bulbapedia_page": "Arcanine (Toyota promo)", + "tcg_set": "Toyota", + "tcg_name": "Arcanine", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "022", + "name_en": "Pikachu (Toyota promo)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (Toyota promo)", + "tcg_set": "Toyota", + "tcg_name": "Pikachu", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/5/5c/PikachuToyotaPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "023", + "name_en": "Flying Pikachu (CoroCoro promo)", + "name_ja": "そらをとぶピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Flying Pikachu (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Flying Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "024", + "name_en": "No.1 Trainer (Lizardon Mega Battle promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Lizardon Mega Battle promo)", + "tcg_set": "Lizardon Mega Battle", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "025", + "name_en": "No.2 Trainer (Lizardon Mega Battle promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Lizardon Mega Battle promo)", + "tcg_set": "Lizardon Mega Battle", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "026", + "name_en": "No.3 Trainer (Lizardon Mega Battle promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Lizardon Mega Battle promo)", + "tcg_set": "Lizardon Mega Battle", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "027", + "name_en": "Dark Persian (Pokémon Card Fan Club promo)", + "name_ja": "わるいペルシアン", + "name_en_source": "manual", + "bulbapedia_page": "Dark Persian (Pokémon Card Fan Club promo)", + "tcg_set": "Pokémon Card Fan Club", + "tcg_name": "Dark Persian", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/db/DarkPersianPok%C3%A9monCardFanClubPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "028", + "name_en": "Pikachu (WHF Special Sheet promo)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (WHF Special Sheet promo)", + "tcg_set": "WHF Special Sheet", + "tcg_name": "Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "029", + "name_en": "Mew (WHF Special Sheet promo)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Mew (WHF Special Sheet promo)", + "tcg_set": "WHF Special Sheet", + "tcg_name": "Mew", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/fb/MewWHFSpecialSheetpromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "030", + "name_en": "Mewtwo (WHF Special Sheet promo)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (WHF Special Sheet promo)", + "tcg_set": "WHF Special Sheet", + "tcg_name": "Mewtwo", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/3/3e/MewtwoWHFSpecialSheetPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "031", + "name_en": "Cool Porygon (Nintendo 64 promo)", + "name_ja": "かっこいいポリゴン", + "name_en_source": "manual", + "bulbapedia_page": "Cool Porygon (Nintendo 64 promo)", + "tcg_set": "Nintendo 64", + "tcg_name": "Cool Porygon", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "032", + "name_en": "Hungry Snorlax (Nintendo 64 promo)", + "name_ja": "くいしんぼカビゴン", + "name_en_source": "manual", + "bulbapedia_page": "Hungry Snorlax (Nintendo 64 promo)", + "tcg_set": "Nintendo 64", + "tcg_name": "Hungry Snorlax", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/1e/HungrySnorlaxNintendo64promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "033", + "name_en": "Meowth (CoroCoro promo)", + "name_ja": "ニャース", + "name_en_source": "manual", + "bulbapedia_page": "Meowth (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Meowth", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e4/MeowthCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "034", + "name_en": "Computer Error (CoroCoro promo)", + "name_ja": "パソコン大暴走!", + "name_en_source": "manual", + "bulbapedia_page": "Computer Error (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Computer Error", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "035", + "name_en": "Pokémon Illustrator (CoroCoro promo)", + "name_ja": "ポケモンイラストレーター", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Illustrator (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pokémon Illustrator", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/5/5f/Pok%C3%A9monIllustratorCoroCoropromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "036", + "name_en": "Venusaur (Trade Please promo)", + "name_ja": "フシギバナ", + "name_en_source": "manual", + "bulbapedia_page": "Venusaur (Trade Please promo)", + "tcg_set": "Trade Please", + "tcg_name": "Venusaur", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "037", + "name_en": "Charizard (Trade Please promo)", + "name_ja": "リザードン", + "name_en_source": "manual", + "bulbapedia_page": "Charizard (Trade Please promo)", + "tcg_set": "Trade Please", + "tcg_name": "Charizard", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "038", + "name_en": "Blastoise (Trade Please promo)", + "name_ja": "カメックス", + "name_en_source": "manual", + "bulbapedia_page": "Blastoise (Trade Please promo)", + "tcg_set": "Trade Please", + "tcg_name": "Blastoise", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "039", + "name_en": "Trade Please! (Trade Please promo)", + "name_ja": "とりかえっこプリーズ!", + "name_en_source": "manual", + "bulbapedia_page": "Trade Please! (Trade Please promo)", + "tcg_set": "Trade Please", + "tcg_name": "Trade Please!", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/de/TradePleasePromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "040", + "name_en": "Brock's Onix (CoroCoro promo)", + "name_ja": "タケシのイワーク", + "name_en_source": "manual", + "bulbapedia_page": "Brock's Onix (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Brock's Onix", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/73/BrockOnixCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "041", + "name_en": "Misty's Staryu (CoroCoro promo)", + "name_ja": "カスミのヒトデマン", + "name_en_source": "manual", + "bulbapedia_page": "Misty's Staryu (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Misty's Staryu", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/b/bf/MistyStaryuCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "042", + "name_en": "Jynx (CoroCoro promo)", + "name_ja": "ルージュラ", + "name_en_source": "manual", + "bulbapedia_page": "Jynx (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Jynx", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/4/4c/JynxCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "043", + "name_en": "Cubone (CoroCoro promo)", + "name_ja": "カラカラ", + "name_en_source": "manual", + "bulbapedia_page": "Cubone (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Cubone", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/1d/CuboneCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "044", + "name_en": "Farfetch'd (CoroCoro promo)", + "name_ja": "カモネギ", + "name_en_source": "manual", + "bulbapedia_page": "Farfetch'd (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Farfetch'd", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/90/Farfetch%27dCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "045", + "name_en": "Mewtwo Strikes Back (CoroCoro promo) (Jumbo)", + "name_ja": "ミュウツーの逆襲", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo Strikes Back (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Mewtwo Strikes Back", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/f7/MewtwoStrikesBackCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "046", + "name_en": "Touch Change! (Garura Parent/Child promo)", + "name_ja": "タッチ交代!", + "name_en_source": "manual", + "bulbapedia_page": "Touch Change! (Garura Parent/Child promo)", + "tcg_set": "Garura Parent/Child", + "tcg_name": "Touch Change!", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/19/TouchChangeGaruraParentChildPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "047", + "name_en": "Kangaskhan (Garura Parent/Child promo)", + "name_ja": "ガルーラ", + "name_en_source": "manual", + "bulbapedia_page": "Kangaskhan (Garura Parent/Child promo)", + "tcg_set": "Garura Parent/Child", + "tcg_name": "Kangaskhan", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/f9/KangaskhanParentChildTournamentpromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "048", + "name_en": "Pikachu's Summer Vacation (CoroCoro promo) (Jumbo)", + "name_ja": "ピカチュウのなつやすみ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu's Summer Vacation (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pikachu's Summer Vacation", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/1e/PikachuSummerVacationCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "049", + "name_en": "Touch Change! (Pokémon Card Fan Club promo)", + "name_ja": "タッチ交代!", + "name_en_source": "manual", + "bulbapedia_page": "Touch Change! (Pokémon Card Fan Club promo)", + "tcg_set": "Pokémon Card Fan Club", + "tcg_name": "Touch Change!", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "050", + "name_en": "Computer Error (Kamex Mega Battle promo)", + "name_ja": "パソコン大暴走!", + "name_en_source": "manual", + "bulbapedia_page": "Computer Error (Kamex Mega Battle promo)", + "tcg_set": "Kamex Mega Battle", + "tcg_name": "Computer Error", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "051", + "name_en": "No.1 Trainer (Kamex Mega Battle promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Kamex Mega Battle promo)", + "tcg_set": "Kamex Mega Battle", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "052", + "name_en": "No.2 Trainer (Kamex Mega Battle promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Kamex Mega Battle promo)", + "tcg_set": "Kamex Mega Battle", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "053", + "name_en": "No.3 Trainer (Kamex Mega Battle promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Kamex Mega Battle promo)", + "tcg_set": "Kamex Mega Battle", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "054", + "name_en": "Lt. Surge's Electabuzz (CoroCoro promo)", + "name_ja": "マチスのエレブー", + "name_en_source": "manual", + "bulbapedia_page": "Lt. Surge's Electabuzz (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Lt. Surge's Electabuzz", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c6/LtSurgeElectabuzzCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "055", + "name_en": "Erika's Dratini (CoroCoro promo)", + "name_ja": "エリカのミニリュウ", + "name_en_source": "manual", + "bulbapedia_page": "Erika's Dratini (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Erika's Dratini", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/88/ErikaDratiniCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "056", + "name_en": "Pokémon Plaza (CoroCoro promo) (Jumbo)", + "name_ja": "ポケモン広場", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Plaza (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pokémon Plaza", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/de/Pok%C3%A9monPlazaCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "057", + "name_en": "Pikachu (CoroCoro promo)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pikachu", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/15/PikachuCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "058", + "name_en": "Flying Pikachu (ANA promo 98)", + "name_ja": "そらをとぶピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Flying Pikachu (ANA promo 98)", + "tcg_set": "ANA promo", + "tcg_name": "Flying Pikachu", + "tcg_num": "98", + "image_url": "https://archives.bulbagarden.net/media/upload/6/6f/FlyingPikachuANAPromo98.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "059", + "name_en": "Dragonite (ANA promo)", + "name_ja": "カイリュー", + "name_en_source": "manual", + "bulbapedia_page": "Dragonite (ANA promo)", + "tcg_set": "ANA", + "tcg_name": "Dragonite", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "060", + "name_en": "_____'s Pikachu (All Card Calendar promo)", + "name_ja": "{{tt", + "name_en_source": "manual", + "bulbapedia_page": "_____'s Pikachu (All Card Calendar promo)", + "tcg_set": "All Card Calendar", + "tcg_name": "_____'s Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "061", + "name_en": "Diglett (Asobikata promo)", + "name_ja": "ディグダ", + "name_en_source": "manual", + "bulbapedia_page": "Diglett (Asobikata promo)", + "tcg_set": "Asobikata", + "tcg_name": "Diglett", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/ef/DiglettAsobikataPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "062", + "name_en": "Dugtrio (Asobikata promo)", + "name_ja": "ダグトリオ", + "name_en_source": "manual", + "bulbapedia_page": "Dugtrio (Asobikata promo)", + "tcg_set": "Asobikata", + "tcg_name": "Dugtrio", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/7a/DugtrioAsobikataPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "063", + "name_en": "Brock's Mankey (CoroCoro promo)", + "name_ja": "タケシのマンキー", + "name_en_source": "manual", + "bulbapedia_page": "Brock's Mankey (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Brock's Mankey", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/0/06/BrockMankeyCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "064", + "name_en": "Erika's Bulbasaur (CoroCoro promo)", + "name_ja": "エリカのフシギダネ", + "name_en_source": "manual", + "bulbapedia_page": "Erika's Bulbasaur (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Erika's Bulbasaur", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/87/ErikaBulbasaurCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "065", + "name_en": "Misty's Tentacool (CoroCoro promo)", + "name_ja": "カスミのメノクラゲ", + "name_en_source": "manual", + "bulbapedia_page": "Misty's Tentacool (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Misty's Tentacool", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/b/b2/MistyTentacoolCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "066", + "name_en": "Lt. Surge's Jolteon (CoroCoro promo)", + "name_ja": "マチスのサンダース", + "name_en_source": "manual", + "bulbapedia_page": "Lt. Surge's Jolteon (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Lt. Surge's Jolteon", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/2f/LtSurgeJolteonCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "067", + "name_en": "Dragonite (Pokémon Card GB promo)", + "name_ja": "カイリュー", + "name_en_source": "manual", + "bulbapedia_page": "Dragonite (Pokémon Card GB promo)", + "tcg_set": "Pokémon Card GB", + "tcg_name": "Dragonite", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/b/b1/DragoniteCardGBPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "068", + "name_en": "Venusaur (Pokémon Song Best Collection promo)", + "name_ja": "フシギバナ", + "name_en_source": "manual", + "bulbapedia_page": "Venusaur (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Venusaur", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "069", + "name_en": "Arcanine (Pokémon Song Best Collection promo)", + "name_ja": "ウィンディ", + "name_en_source": "manual", + "bulbapedia_page": "Arcanine (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Arcanine", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "070", + "name_en": "Charizard (Pokémon Song Best Collection promo)", + "name_ja": "リザードン", + "name_en_source": "manual", + "bulbapedia_page": "Charizard (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Charizard", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/ef/CharizardBestCDPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "071", + "name_en": "Blastoise (Pokémon Song Best Collection promo)", + "name_ja": "カメックス", + "name_en_source": "manual", + "bulbapedia_page": "Blastoise (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Blastoise", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "072", + "name_en": "Mew (Pokémon Song Best Collection promo)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Mew (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Mew", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "073", + "name_en": "Mewtwo (Pokémon Song Best Collection promo)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Mewtwo", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "074", + "name_en": "Cool Porygon (Pokémon Song Best Collection promo)", + "name_ja": "かっこいいポリゴン", + "name_en_source": "manual", + "bulbapedia_page": "Cool Porygon (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Cool Porygon", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "075", + "name_en": "Hungry Snorlax (Pokémon Song Best Collection promo)", + "name_ja": "くいしんぼカビゴン", + "name_en_source": "manual", + "bulbapedia_page": "Hungry Snorlax (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Hungry Snorlax", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "076", + "name_en": "Computer Error (Pokémon Song Best Collection promo)", + "name_ja": "パソコン大暴走!", + "name_en_source": "manual", + "bulbapedia_page": "Computer Error (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Computer Error", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "077", + "name_en": "Super Energy Retrieval (Pokémon Song Best Collection promo)", + "name_ja": "超エネルギー回収", + "name_en_source": "manual", + "bulbapedia_page": "Super Energy Retrieval (Pokémon Song Best Collection promo)", + "tcg_set": "Pokémon Song Best Collection", + "tcg_name": "Super Energy Retrieval", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "078", + "name_en": "Pikachu (Base Set 58)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (Base Set 58)", + "tcg_set": "Base Set", + "tcg_name": "Pikachu", + "tcg_num": "58", + "image_url": "https://archives.bulbagarden.net/media/upload/f/f7/PikachuSnapBestPhotoContest.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "079", + "name_en": "Tropical Present (Fan Club promo)", + "name_ja": "トロピカルプレゼント", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Present (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "Tropical Present", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e0/TropicalPresentEarly1999Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "080", + "name_en": "Magikarp (University promo)", + "name_ja": "コイキング", + "name_en_source": "manual", + "bulbapedia_page": "Magikarp (University promo)", + "tcg_set": "University", + "tcg_name": "Magikarp", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/4/4f/MagikarpUniversityPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "081", + "name_en": "Meowth (CoroCoro promo 99)", + "name_ja": "ニャース", + "name_en_source": "manual", + "bulbapedia_page": "Meowth (CoroCoro promo 99)", + "tcg_set": "CoroCoro promo", + "tcg_name": "Meowth", + "tcg_num": "99" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "082", + "name_en": "Venusaur (Pokémon Card GB promo)", + "name_ja": "フシギバナ", + "name_en_source": "manual", + "bulbapedia_page": "Venusaur (Pokémon Card GB promo)", + "tcg_set": "Pokémon Card GB", + "tcg_name": "Venusaur", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "083", + "name_en": "Mewtwo (Wizards Promo 3)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (Wizards Promo 3)", + "tcg_set": "Wizards Promo", + "tcg_name": "Mewtwo", + "tcg_num": "3" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "084", + "name_en": "Blaine's Growlithe (CoroCoro promo)", + "name_ja": "カツラのガーディ", + "name_en_source": "manual", + "bulbapedia_page": "Blaine's Growlithe (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Blaine's Growlithe", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/7b/BlaineGrowlitheCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "085", + "name_en": "Sabrina's Abra (CoroCoro promo)", + "name_ja": "ナツメのケーシィ", + "name_en_source": "manual", + "bulbapedia_page": "Sabrina's Abra (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Sabrina's Abra", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "086", + "name_en": "Pikachu (Wizards Promo 1)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (Wizards Promo 1)", + "tcg_set": "Wizards Promo", + "tcg_name": "Pikachu", + "tcg_num": "1", + "image_url": "https://archives.bulbagarden.net/media/upload/5/5c/PikachuToyotaPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "087", + "name_en": "Jigglypuff (Wizards Promo 7)", + "name_ja": "プリン", + "name_en_source": "manual", + "bulbapedia_page": "Jigglypuff (Wizards Promo 7)", + "tcg_set": "Wizards Promo", + "tcg_name": "Jigglypuff", + "tcg_num": "7" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "088", + "name_en": "Omastar (Communication Evolution Campaign promo)", + "name_ja": "オムスター", + "name_en_source": "manual", + "bulbapedia_page": "Omastar (Communication Evolution Campaign promo)", + "tcg_set": "Communication Evolution Campaign", + "tcg_name": "Omastar", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/72/OmastarCommunicationEvolutionPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "089", + "name_en": "Alakazam (Communication Evolution Campaign promo)", + "name_ja": "フーディン", + "name_en_source": "manual", + "bulbapedia_page": "Alakazam (Communication Evolution Campaign promo)", + "tcg_set": "Communication Evolution Campaign", + "tcg_name": "Alakazam", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/eb/AlakazamCommunicationEvolutionPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "090", + "name_en": "Gengar (Communication Evolution Campaign promo)", + "name_ja": "ゲンガー", + "name_en_source": "manual", + "bulbapedia_page": "Gengar (Communication Evolution Campaign promo)", + "tcg_set": "Communication Evolution Campaign", + "tcg_name": "Gengar", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c8/GengarCommunicationEvolutionPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "091", + "name_en": "Golem (Communication Evolution Campaign promo)", + "name_ja": "ゴローニャ", + "name_en_source": "manual", + "bulbapedia_page": "Golem (Communication Evolution Campaign promo)", + "tcg_set": "Communication Evolution Campaign", + "tcg_name": "Golem", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/18/GolemCommunicationEvolutionPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "092", + "name_en": "Machamp (Communication Evolution Campaign promo)", + "name_ja": "カイリキー", + "name_en_source": "manual", + "bulbapedia_page": "Machamp (Communication Evolution Campaign promo)", + "tcg_set": "Communication Evolution Campaign", + "tcg_name": "Machamp", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "093", + "name_en": "Togepi (CoroCoro promo)", + "name_ja": "トゲピー", + "name_en_source": "manual", + "bulbapedia_page": "Togepi (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Togepi", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "094", + "name_en": "Pikachu (Pokémon Card Trainers promo)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "095", + "name_en": "Mankey (How I Became a Pokémon Card promo)", + "name_ja": "マンキー", + "name_en_source": "manual", + "bulbapedia_page": "Mankey (How I Became a Pokémon Card promo)", + "tcg_set": "How I Became a Pokémon Card", + "tcg_name": "Mankey", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "096", + "name_en": "Bulbasaur (Base Set 44)", + "name_ja": "フシギダネ", + "name_en_source": "manual", + "bulbapedia_page": "Bulbasaur (Base Set 44)", + "tcg_set": "Base Set", + "tcg_name": "Bulbasaur", + "tcg_num": "44" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "097", + "name_en": "Gyarados (Base Set 6)", + "name_ja": "ギャラドス", + "name_en_source": "manual", + "bulbapedia_page": "Gyarados (Base Set 6)", + "tcg_set": "Base Set", + "tcg_name": "Gyarados", + "tcg_num": "6" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "098", + "name_en": "Magikarp (Base Set 35)", + "name_ja": "コイキング", + "name_en_source": "manual", + "bulbapedia_page": "Magikarp (Base Set 35)", + "tcg_set": "Base Set", + "tcg_name": "Magikarp", + "tcg_num": "35" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "099", + "name_en": "Poliwag (Base Set 59)", + "name_ja": "ニョロモ", + "name_en_source": "manual", + "bulbapedia_page": "Poliwag (Base Set 59)", + "tcg_set": "Base Set", + "tcg_name": "Poliwag", + "tcg_num": "59" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "100", + "name_en": "Pikachu (Base Set 58)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (Base Set 58)", + "tcg_set": "Base Set", + "tcg_name": "Pikachu", + "tcg_num": "58", + "image_url": "https://archives.bulbagarden.net/media/upload/f/f7/PikachuSnapBestPhotoContest.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "101", + "name_en": "Koffing (Base Set 51)", + "name_ja": "ドガース", + "name_en_source": "manual", + "bulbapedia_page": "Koffing (Base Set 51)", + "tcg_set": "Base Set", + "tcg_name": "Koffing", + "tcg_num": "51" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "102", + "name_en": "Charmander (Base Set 46)", + "name_ja": "ヒトカゲ", + "name_en_source": "manual", + "bulbapedia_page": "Charmander (Base Set 46)", + "tcg_set": "Base Set", + "tcg_name": "Charmander", + "tcg_num": "46" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "103", + "name_en": "Articuno (Fossil 2)", + "name_ja": "フリーザー", + "name_en_source": "manual", + "bulbapedia_page": "Articuno (Fossil 2)", + "tcg_set": "Fossil", + "tcg_name": "Articuno", + "tcg_num": "2" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "104", + "name_en": "Squirtle (Base Set 63)", + "name_ja": "ゼニガメ", + "name_en_source": "manual", + "bulbapedia_page": "Squirtle (Base Set 63)", + "tcg_set": "Base Set", + "tcg_name": "Squirtle", + "tcg_num": "63" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "105", + "name_en": "Chansey (Base Set 3)", + "name_ja": "ラッキー", + "name_en_source": "manual", + "bulbapedia_page": "Chansey (Base Set 3)", + "tcg_set": "Base Set", + "tcg_name": "Chansey", + "tcg_num": "3" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "106", + "name_en": "Articuno (ANA promo)", + "name_ja": "フリーザー", + "name_en_source": "manual", + "bulbapedia_page": "Articuno (ANA promo)", + "tcg_set": "ANA", + "tcg_name": "Articuno", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c0/ArticunoANAPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "107", + "name_en": "Flying Pikachu (ANA promo 99)", + "name_ja": "そらをとぶピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Flying Pikachu (ANA promo 99)", + "tcg_set": "ANA promo", + "tcg_name": "Flying Pikachu", + "tcg_num": "99", + "image_url": "https://archives.bulbagarden.net/media/upload/8/85/FlyingPikachuANAPromo99.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "108", + "name_en": "Marill (CoroCoro promo)", + "name_ja": "マリル", + "name_en_source": "manual", + "bulbapedia_page": "Marill (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Marill", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "109", + "name_en": "Pokémon Valley (CoroCoro promo) (Jumbo)", + "name_ja": "ポケモン谷", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Valley (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pokémon Valley", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/eb/PokemonValleyCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "110", + "name_en": "No.1 Trainer (Tropical Mega Battle promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Tropical Mega Battle promo)", + "tcg_set": "Tropical Mega Battle", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "111", + "name_en": "No.2 Trainer (Tropical Mega Battle promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Tropical Mega Battle promo)", + "tcg_set": "Tropical Mega Battle", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "112", + "name_en": "No.3 Trainer (Tropical Mega Battle promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Tropical Mega Battle promo)", + "tcg_set": "Tropical Mega Battle", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "113", + "name_en": "No.1 Trainer (Secret Super Battle promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Secret Super Battle promo)", + "tcg_set": "Secret Super Battle", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "114", + "name_en": "No.2 Trainer (Secret Super Battle promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Secret Super Battle promo)", + "tcg_set": "Secret Super Battle", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "115", + "name_en": "No.3 Trainer (Secret Super Battle promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Secret Super Battle promo)", + "tcg_set": "Secret Super Battle", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "116", + "name_en": "Ancient Mew (The Power of One promo)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Ancient Mew (The Power of One promo)", + "tcg_set": "The Power of One", + "tcg_name": "Ancient Mew", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/dd/AncientMewNintedoError.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "117", + "name_en": "Tropical Present (Fan Club promo)", + "name_ja": "トロピカルプレゼント", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Present (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "Tropical Present", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e0/TropicalPresentEarly1999Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "118", + "name_en": "Giovanni's Nidoking (CoroCoro promo)", + "name_ja": "サカキのニドキング", + "name_en_source": "manual", + "bulbapedia_page": "Giovanni's Nidoking (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Giovanni's Nidoking", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/5/57/GiovanniNidokingCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "119", + "name_en": "Koga's Ninja Gym (CoroCoro promo)", + "name_ja": "キョウの忍者ジム", + "name_en_source": "manual", + "bulbapedia_page": "Koga's Ninja Gym (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Koga's Ninja Gym", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/4/41/KogaNinjaGymCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "120", + "name_en": "Lawrence III (CoroCoro promo) (Jumbo)", + "name_ja": "ジラルダン", + "name_en_source": "manual", + "bulbapedia_page": "Lawrence III (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Lawrence III", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/15/LawrenceIIICoroCoropromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "121", + "name_en": "Moltres (ANA promo)", + "name_ja": "ファイヤー", + "name_en_source": "manual", + "bulbapedia_page": "Moltres (ANA promo)", + "tcg_set": "ANA", + "tcg_name": "Moltres", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/10/MoltresANAPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "122", + "name_en": "Zapdos (ANA promo)", + "name_ja": "サンダー", + "name_en_source": "manual", + "bulbapedia_page": "Zapdos (ANA promo)", + "tcg_set": "ANA", + "tcg_name": "Zapdos", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/dd/ZapdosANAPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "123", + "name_en": "Moltres (Phone Card promo)", + "name_ja": "ファイヤー", + "name_en_source": "manual", + "bulbapedia_page": "Moltres (Phone Card promo)", + "tcg_set": "Phone Card", + "tcg_name": "Moltres", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "124", + "name_en": "Articuno (Phone Card promo)", + "name_ja": "フリーザー", + "name_en_source": "manual", + "bulbapedia_page": "Articuno (Phone Card promo)", + "tcg_set": "Phone Card", + "tcg_name": "Articuno", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "125", + "name_en": "Zapdos (Phone Card promo)", + "name_ja": "サンダー", + "name_en_source": "manual", + "bulbapedia_page": "Zapdos (Phone Card promo)", + "tcg_set": "Phone Card", + "tcg_name": "Zapdos", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "126", + "name_en": "Exeggutor (Tropical Mega Battle promo)", + "name_ja": "ナッシー", + "name_en_source": "manual", + "bulbapedia_page": "Exeggutor (Tropical Mega Battle promo)", + "tcg_set": "Tropical Mega Battle", + "tcg_name": "Exeggutor", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "127", + "name_en": "Tropical Wind (Tropical Mega Battle promo)", + "name_ja": "トロピカルウインド", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Wind (Tropical Mega Battle promo)", + "tcg_set": "Tropical Mega Battle", + "tcg_name": "Tropical Wind", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/TropicalWindTropicalMegaBattle1999Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "128", + "name_en": "Hama-chan's Slowking (CoroCoro promo)", + "name_ja": "ハマちゃんのヤドキング", + "name_en_source": "manual", + "bulbapedia_page": "Hama-chan's Slowking (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Hama-chan's Slowking", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/5/5a/HamachanSlowkingCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "129", + "name_en": "Misty's Treatment (CD promo)", + "name_ja": "カスミのてあて", + "name_en_source": "manual", + "bulbapedia_page": "Misty's Treatment (CD promo)", + "tcg_set": "CD", + "tcg_name": "Misty's Treatment", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/4/42/MistyTreatmentRidingOnLaprasPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "130", + "name_en": "Imakuni? (CoroCoro promo)", + "name_ja": "イマクニ?", + "name_en_source": "manual", + "bulbapedia_page": "Imakuni? (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Imakuni?", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c3/ImakuniCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "131", + "name_en": "Psyduck (How I Became a Pokémon Card promo)", + "name_ja": "コダック", + "name_en_source": "manual", + "bulbapedia_page": "Psyduck (How I Became a Pokémon Card promo)", + "tcg_set": "How I Became a Pokémon Card", + "tcg_name": "Psyduck", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "132", + "name_en": "Exeggutor (Pokémon Card Trainers promo)", + "name_ja": "ナッシー", + "name_en_source": "manual", + "bulbapedia_page": "Exeggutor (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Exeggutor", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "133", + "name_en": "Team Rocket's Meowth (CD promo)", + "name_ja": "ロケット団のニャース", + "name_en_source": "manual", + "bulbapedia_page": "Team Rocket's Meowth (CD promo)", + "tcg_set": "CD", + "tcg_name": "Team Rocket's Meowth", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "134", + "name_en": "Trainer Certification Card (Fan Club promo)", + "name_ja": "カードトレーナー認定証", + "name_en_source": "manual", + "bulbapedia_page": "Trainer Certification Card (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "Trainer Certification Card", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/79/TrainerCertificationCardPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "135", + "name_en": "Murkrow (Pokémon Card Trainers promo)", + "name_ja": "ヤミカラス", + "name_en_source": "manual", + "bulbapedia_page": "Murkrow (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Murkrow", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/19/MurkrowTrainerpromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "136", + "name_en": "Darkness Energy (Pokémon Card Trainers promo)", + "name_ja": "特殊悪エネルギー", + "name_en_source": "manual", + "bulbapedia_page": "Darkness Energy (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Darkness Energy", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "137", + "name_en": "Chikorita (Premium File promo)", + "name_ja": "チコリータ", + "name_en_source": "manual", + "bulbapedia_page": "Chikorita (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Chikorita", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "138", + "name_en": "Bayleef (Premium File promo)", + "name_ja": "ベイリーフ", + "name_en_source": "manual", + "bulbapedia_page": "Bayleef (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Bayleef", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "139", + "name_en": "Meganium (Premium File promo)", + "name_ja": "メガニウム", + "name_en_source": "manual", + "bulbapedia_page": "Meganium (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Meganium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "140", + "name_en": "Cyndaquil (Premium File promo)", + "name_ja": "ヒノアラシ", + "name_en_source": "manual", + "bulbapedia_page": "Cyndaquil (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Cyndaquil", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "141", + "name_en": "Quilava (Premium File promo)", + "name_ja": "マグマラシ", + "name_en_source": "manual", + "bulbapedia_page": "Quilava (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Quilava", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "142", + "name_en": "Typhlosion (Premium File promo)", + "name_ja": "バクフーン", + "name_en_source": "manual", + "bulbapedia_page": "Typhlosion (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Typhlosion", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "143", + "name_en": "Totodile (Premium File promo)", + "name_ja": "ワニノコ", + "name_en_source": "manual", + "bulbapedia_page": "Totodile (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Totodile", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "144", + "name_en": "Croconaw (Premium File promo)", + "name_ja": "アリゲイツ", + "name_en_source": "manual", + "bulbapedia_page": "Croconaw (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Croconaw", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "145", + "name_en": "Feraligatr (Premium File promo)", + "name_ja": "オーダイル", + "name_en_source": "manual", + "bulbapedia_page": "Feraligatr (Premium File promo)", + "tcg_set": "Premium File", + "tcg_name": "Feraligatr", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "146", + "name_en": "Wooper (CoroCoro promo)", + "name_ja": "ウパー", + "name_en_source": "manual", + "bulbapedia_page": "Wooper (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Wooper", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/20/WooperCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "147", + "name_en": "New Year Present (Fan Club promo)", + "name_ja": "ニューイヤープレゼント", + "name_en_source": "manual", + "bulbapedia_page": "New Year Present (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "New Year Present", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/4/45/NewYearPresentFanClubPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "148", + "name_en": "Chikorita (WHF promo)", + "name_ja": "チコリータ", + "name_en_source": "manual", + "bulbapedia_page": "Chikorita (WHF promo)", + "tcg_set": "WHF", + "tcg_name": "Chikorita", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "149", + "name_en": "Cyndaquil (WHF promo)", + "name_ja": "ヒノアラシ", + "name_en_source": "manual", + "bulbapedia_page": "Cyndaquil (WHF promo)", + "tcg_set": "WHF", + "tcg_name": "Cyndaquil", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "150", + "name_en": "Totodile (WHF promo)", + "name_ja": "ワニノコ", + "name_en_source": "manual", + "bulbapedia_page": "Totodile (WHF promo)", + "tcg_set": "WHF", + "tcg_name": "Totodile", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "151", + "name_en": "Chikorita (Champion Road promo)", + "name_ja": "チコリータ", + "name_en_source": "manual", + "bulbapedia_page": "Chikorita (Champion Road promo)", + "tcg_set": "Champion Road", + "tcg_name": "Chikorita", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "152", + "name_en": "Cyndaquil (Champion Road promo)", + "name_ja": "ヒノアラシ", + "name_en_source": "manual", + "bulbapedia_page": "Cyndaquil (Champion Road promo)", + "tcg_set": "Champion Road", + "tcg_name": "Cyndaquil", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "153", + "name_en": "Totodile (Champion Road promo)", + "name_ja": "ワニノコ", + "name_en_source": "manual", + "bulbapedia_page": "Totodile (Champion Road promo)", + "tcg_set": "Champion Road", + "tcg_name": "Totodile", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "154", + "name_en": "Berry (Champion Road promo)", + "name_ja": "きのみ", + "name_en_source": "manual", + "bulbapedia_page": "Berry (Champion Road promo)", + "tcg_set": "Champion Road", + "tcg_name": "Berry", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "155", + "name_en": "Miracle Berry (Champion Road promo)", + "name_ja": "きせきのみ", + "name_en_source": "manual", + "bulbapedia_page": "Miracle Berry (Champion Road promo)", + "tcg_set": "Champion Road", + "tcg_name": "Miracle Berry", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "156", + "name_en": "Cleffa (CoroCoro promo)", + "name_ja": "ピィ", + "name_en_source": "manual", + "bulbapedia_page": "Cleffa (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Cleffa", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "157", + "name_en": "Jynx (How I Became a Pokémon Card promo)", + "name_ja": "ルージュラ", + "name_en_source": "manual", + "bulbapedia_page": "Jynx (How I Became a Pokémon Card promo)", + "tcg_set": "How I Became a Pokémon Card", + "tcg_name": "Jynx", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "158", + "name_en": "Steelix (Pokémon Card Trainers promo)", + "name_ja": "ハガネール", + "name_en_source": "manual", + "bulbapedia_page": "Steelix (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Steelix", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/d4/SteelixTrainersPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "159", + "name_en": "Charizard (Base Set 4) (Jumbo)", + "name_ja": "リザードン", + "name_en_source": "manual", + "bulbapedia_page": "Charizard (Base Set 4)", + "tcg_set": "Base Set", + "tcg_name": "Charizard", + "tcg_num": "4", + "image_url": "https://archives.bulbagarden.net/media/upload/e/ef/CharizardBestCDPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "160", + "name_en": "Eevee (Fan Club promo)", + "name_ja": "イーブイ", + "name_en_source": "manual", + "bulbapedia_page": "Eevee (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "Eevee", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/11/EeveePok%C3%A9monCardFanClubPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "161", + "name_en": "Porygon (Fan Club promo)", + "name_ja": "ポリゴン", + "name_en_source": "manual", + "bulbapedia_page": "Porygon (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "Porygon", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/82/PorygonPok%C3%A9monCardFanClubPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "162", + "name_en": "Mew (Wizards Promo 8)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Mew (Wizards Promo 8)", + "tcg_set": "Wizards Promo", + "tcg_name": "Mew", + "tcg_num": "8" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "163", + "name_en": "Scizor (CoroCoro promo)", + "name_ja": "ハッサム", + "name_en_source": "manual", + "bulbapedia_page": "Scizor (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Scizor", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "164", + "name_en": "Dance! Neo Imakuni? (CD promo)", + "name_ja": "おどれ!neo★イマクニ?", + "name_en_source": "manual", + "bulbapedia_page": "Dance! Neo Imakuni? (CD promo)", + "tcg_set": "CD", + "tcg_name": "Dance! Neo Imakuni?", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/83/DanceNeoImakuniCDPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "165", + "name_en": "Pikachu (Wizards Promo 4)", + "name_ja": "ピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu (Wizards Promo 4)", + "tcg_set": "Wizards Promo", + "tcg_name": "Pikachu", + "tcg_num": "4" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "166", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Onix)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "167", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Lugia)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "168", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Ho-Oh)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "169", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Gyarados)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "170", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Zapdos)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "171", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Diglett)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "172", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Mew)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "173", + "name_en": "Lucky Stadium (World Challenge Summer promo) (Pikachu)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (World Challenge Summer promo)", + "tcg_set": "World Challenge Summer", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "174", + "name_en": "No.1 Trainer (World Challenge Tropical Mega Battle promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (World Challenge Tropical Mega Battle promo)", + "tcg_set": "World Challenge Tropical Mega Battle", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "175", + "name_en": "No.2 Trainer (World Challenge Tropical Mega Battle promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (World Challenge Tropical Mega Battle promo)", + "tcg_set": "World Challenge Tropical Mega Battle", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "176", + "name_en": "No.3 Trainer (World Challenge Tropical Mega Battle promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (World Challenge Tropical Mega Battle promo)", + "tcg_set": "World Challenge Tropical Mega Battle", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "177", + "name_en": "No.1 Trainer (World Challenge Secret Super Battle promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (World Challenge Secret Super Battle promo)", + "tcg_set": "World Challenge Secret Super Battle", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "178", + "name_en": "No.2 Trainer (World Challenge Secret Super Battle promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (World Challenge Secret Super Battle promo)", + "tcg_set": "World Challenge Secret Super Battle", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "179", + "name_en": "No.3 Trainer (World Challenge Secret Super Battle promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (World Challenge Secret Super Battle promo)", + "tcg_set": "World Challenge Secret Super Battle", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "180", + "name_en": "Smeargle (Pokémon Card Trainers promo)", + "name_ja": "ドーブル", + "name_en_source": "manual", + "bulbapedia_page": "Smeargle (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Smeargle", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "181", + "name_en": "Pikachu & Pichu (CoroCoro promo) (Jumbo)", + "name_ja": "ピチューとピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu & Pichu (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Pikachu & Pichu", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e0/PichuPikachuCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "182", + "name_en": "Hitmontop (Emperor of the Crystal Tower: Entei promo)", + "name_ja": "カポエラー", + "name_en_source": "manual", + "bulbapedia_page": "Hitmontop (Emperor of the Crystal Tower: Entei promo)", + "tcg_set": "Emperor of the Crystal Tower: Entei", + "tcg_name": "Hitmontop", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "183", + "name_en": "Igglybuff (Emperor of the Crystal Tower: Entei promo)", + "name_ja": "ププリン", + "name_en_source": "manual", + "bulbapedia_page": "Igglybuff (Emperor of the Crystal Tower: Entei promo)", + "tcg_set": "Emperor of the Crystal Tower: Entei", + "tcg_name": "Igglybuff", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "184", + "name_en": "Charizard (Premium File 2 promo)", + "name_ja": "リザードン", + "name_en_source": "manual", + "bulbapedia_page": "Charizard (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Charizard", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/4/4c/CharizardPremiumFile2Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "185", + "name_en": "Entei (Premium File 2 promo)", + "name_ja": "エンテイ", + "name_en_source": "manual", + "bulbapedia_page": "Entei (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Entei", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "186", + "name_en": "Pichu (Premium File 2 promo)", + "name_ja": "ピチュー", + "name_en_source": "manual", + "bulbapedia_page": "Pichu (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Pichu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "187", + "name_en": "Espeon (Premium File 2 promo)", + "name_ja": "エーフィ", + "name_en_source": "manual", + "bulbapedia_page": "Espeon (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Espeon", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "188", + "name_en": "Unown E (Premium File 2 promo)", + "name_ja": "アンノーン [E]", + "name_en_source": "manual", + "bulbapedia_page": "Unown E (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Unown E", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "189", + "name_en": "Unown N (Premium File 2 promo)", + "name_ja": "アンノーン [N]", + "name_en_source": "manual", + "bulbapedia_page": "Unown N (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Unown N", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "190", + "name_en": "Unown O (Premium File 2 promo)", + "name_ja": "アンノーン [O]", + "name_en_source": "manual", + "bulbapedia_page": "Unown O (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Unown O", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "191", + "name_en": "Umbreon (Premium File 2 promo)", + "name_ja": "ブラッキー", + "name_en_source": "manual", + "bulbapedia_page": "Umbreon (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Umbreon", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "192", + "name_en": "Eevee (Premium File 2 promo)", + "name_ja": "イーブイ", + "name_en_source": "manual", + "bulbapedia_page": "Eevee (Premium File 2 promo)", + "tcg_set": "Premium File 2", + "tcg_name": "Eevee", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e9/EeveePremiumFile2Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "193", + "name_en": "Crystal Tower's Entei (CoroCoro promo) (Jumbo)", + "name_ja": "結晶塔のエンテイ", + "name_en_source": "manual", + "bulbapedia_page": "Crystal Tower's Entei (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Crystal Tower's Entei", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/ec/CrystalTowerEnteiCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "194", + "name_en": "New Century Present (Fan Club promo)", + "name_ja": "ニューセンチュリープレゼント", + "name_en_source": "manual", + "bulbapedia_page": "New Century Present (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "New Century Present", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/91/NewCenturyPresentFanClubPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "195", + "name_en": "Marill (ANA promo)", + "name_ja": "マリル", + "name_en_source": "manual", + "bulbapedia_page": "Marill (ANA promo)", + "tcg_set": "ANA", + "tcg_name": "Marill", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/a/a3/MarillANAPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "196", + "name_en": "Meowth (Wizards Promo 10)", + "name_ja": "ニャース", + "name_en_source": "manual", + "bulbapedia_page": "Meowth (Wizards Promo 10)", + "tcg_set": "Wizards Promo", + "tcg_name": "Meowth", + "tcg_num": "10" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "197", + "name_en": "Mewtwo (Wizards Promo 12)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (Wizards Promo 12)", + "tcg_set": "Wizards Promo", + "tcg_name": "Mewtwo", + "tcg_num": "12" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "198", + "name_en": "Eevee (Wizards Promo 11)", + "name_ja": "イーブイ", + "name_en_source": "manual", + "bulbapedia_page": "Eevee (Wizards Promo 11)", + "tcg_set": "Wizards Promo", + "tcg_name": "Eevee", + "tcg_num": "11", + "image_url": "https://archives.bulbagarden.net/media/upload/1/11/EeveePok%C3%A9monCardFanClubPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "199", + "name_en": "No.1 Trainer (World Challenge Secret Super Battle finals promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (World Challenge Secret Super Battle finals promo)", + "tcg_set": "World Challenge Secret Super Battle finals", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "200", + "name_en": "No.2 Trainer (World Challenge Secret Super Battle finals promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (World Challenge Secret Super Battle finals promo)", + "tcg_set": "World Challenge Secret Super Battle finals", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "201", + "name_en": "No.3 Trainer (World Challenge Secret Super Battle finals promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (World Challenge Secret Super Battle finals promo)", + "tcg_set": "World Challenge Secret Super Battle finals", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "202", + "name_en": "Lucky Stadium (Tropical Mega Battle promo)", + "name_ja": "ラッキースタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Lucky Stadium (Tropical Mega Battle promo)", + "tcg_set": "Tropical Mega Battle", + "tcg_name": "Lucky Stadium", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e7/LuckyStadiumTropicalMegaBattlePromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "203", + "name_en": "No.1 Trainer (World Challenge Tropical Mega Battle finals promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (World Challenge Tropical Mega Battle finals promo)", + "tcg_set": "World Challenge Tropical Mega Battle finals", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "204", + "name_en": "No.2 Trainer (World Challenge Tropical Mega Battle finals promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (World Challenge Tropical Mega Battle finals promo)", + "tcg_set": "World Challenge Tropical Mega Battle finals", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "205", + "name_en": "No.3 Trainer (World Challenge Tropical Mega Battle finals promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (World Challenge Tropical Mega Battle finals promo)", + "tcg_set": "World Challenge Tropical Mega Battle finals", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "206", + "name_en": "Sunkern (How I Became a Pokémon Card promo)", + "name_ja": "ヒマナッツ", + "name_en_source": "manual", + "bulbapedia_page": "Sunkern (How I Became a Pokémon Card promo)", + "tcg_set": "How I Became a Pokémon Card", + "tcg_name": "Sunkern", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "207", + "name_en": "Togepi (ANA promo)", + "name_ja": "トゲピー", + "name_en_source": "manual", + "bulbapedia_page": "Togepi (ANA promo)", + "tcg_set": "ANA", + "tcg_name": "Togepi", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/4/4a/TogepiANAPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "208", + "name_en": "Unown J (Pokémon Card Trainers promo)", + "name_ja": "アンノーン [J]", + "name_en_source": "manual", + "bulbapedia_page": "Unown J (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Unown J", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "209", + "name_en": "Unown R (CoroCoro promo)", + "name_ja": "アンノーン [R]", + "name_en_source": "manual", + "bulbapedia_page": "Unown R (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Unown R", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/dd/UnownRCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "210", + "name_en": "Celebi (Premium File 3 promo)", + "name_ja": "セレビィ", + "name_en_source": "manual", + "bulbapedia_page": "Celebi (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Celebi", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "211", + "name_en": "Entei (Premium File 3 promo)", + "name_ja": "エンテイ", + "name_en_source": "manual", + "bulbapedia_page": "Entei (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Entei", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "212", + "name_en": "Suicune (Premium File 3 promo)", + "name_ja": "スイクン", + "name_en_source": "manual", + "bulbapedia_page": "Suicune (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Suicune", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "213", + "name_en": "Raikou (Premium File 3 promo)", + "name_ja": "ライコウ", + "name_en_source": "manual", + "bulbapedia_page": "Raikou (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Raikou", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "214", + "name_en": "Lugia (Premium File 3 promo)", + "name_ja": "ルギア", + "name_en_source": "manual", + "bulbapedia_page": "Lugia (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Lugia", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "215", + "name_en": "Sudowoodo (Premium File 3 promo)", + "name_ja": "ウソッキー", + "name_en_source": "manual", + "bulbapedia_page": "Sudowoodo (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Sudowoodo", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "216", + "name_en": "Skarmory (Premium File 3 promo)", + "name_ja": "エアームド", + "name_en_source": "manual", + "bulbapedia_page": "Skarmory (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Skarmory", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "217", + "name_en": "Sneasel (Premium File 3 promo)", + "name_ja": "ニューラ", + "name_en_source": "manual", + "bulbapedia_page": "Sneasel (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Sneasel", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "218", + "name_en": "Ho-Oh (Premium File 3 promo)", + "name_ja": "ホウオウ", + "name_en_source": "manual", + "bulbapedia_page": "Ho-Oh (Premium File 3 promo)", + "tcg_set": "Premium File 3", + "tcg_name": "Ho-Oh", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "219", + "name_en": "Misdreavus (Pokémon Card Trainers promo)", + "name_ja": "ムウマ", + "name_en_source": "manual", + "bulbapedia_page": "Misdreavus (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Misdreavus", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "220", + "name_en": "Smoochum (CoroCoro promo)", + "name_ja": "ムチュール", + "name_en_source": "manual", + "bulbapedia_page": "Smoochum (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Smoochum", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/b/b1/SmoochumCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "221", + "name_en": "Dunsparce (Information Pack promo)", + "name_ja": "ノコッチ", + "name_en_source": "manual", + "bulbapedia_page": "Dunsparce (Information Pack promo)", + "tcg_set": "Information Pack", + "tcg_name": "Dunsparce", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/6a/DunsparceInformationPackPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "222", + "name_en": "No.1 Trainer (Neo Spring Road promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Neo Spring Road promo)", + "tcg_set": "Neo Spring Road", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "223", + "name_en": "No.2 Trainer (Neo Spring Road promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Neo Spring Road promo)", + "tcg_set": "Neo Spring Road", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "224", + "name_en": "No.3 Trainer (Neo Spring Road promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Neo Spring Road promo)", + "tcg_set": "Neo Spring Road", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "225", + "name_en": "Dark Ivysaur (Pokémon Card Trainers promo)", + "name_ja": "わるいフシギソウ", + "name_en_source": "manual", + "bulbapedia_page": "Dark Ivysaur (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Dark Ivysaur", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/de/DarkIvysaurPok%C3%A9monCardTrainersPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "226", + "name_en": "Dark Venusaur (Pokémon Card Trainers promo)", + "name_ja": "わるいフシギバナ", + "name_en_source": "manual", + "bulbapedia_page": "Dark Venusaur (Pokémon Card Trainers promo)", + "tcg_set": "Pokémon Card Trainers", + "tcg_name": "Dark Venusaur", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e0/DarkVenusaurPok%C3%A9monCardTrainersPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "227", + "name_en": "Team GR's Mewtwo (Pokémon Card GB2 promo)", + "name_ja": "GR団のミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Team GR's Mewtwo (Pokémon Card GB2 promo)", + "tcg_set": "Pokémon Card GB2", + "tcg_name": "Team GR's Mewtwo", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/9f/TeamGRMewtwoPok%C3%A9monCardGB2Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "228", + "name_en": "Lugia (Pokémon Card GB2 promo)", + "name_ja": "ルギア", + "name_en_source": "manual", + "bulbapedia_page": "Lugia (Pokémon Card GB2 promo)", + "tcg_set": "Pokémon Card GB2", + "tcg_name": "Lugia", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/a/ae/LugiaPok%C3%A9monCardGB2Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "229", + "name_en": "Dark Fearow (Pokémon Card GB2 promo)", + "name_ja": "わるいオニドリル", + "name_en_source": "manual", + "bulbapedia_page": "Dark Fearow (Pokémon Card GB2 promo)", + "tcg_set": "Pokémon Card GB2", + "tcg_name": "Dark Fearow", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/3/32/DarkFearowPok%C3%A9monCardGB2Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "230", + "name_en": "No.1 Trainer (Neo Spring Road finals promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Neo Spring Road finals promo)", + "tcg_set": "Neo Spring Road finals", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "231", + "name_en": "No.2 Trainer (Neo Spring Road finals promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Neo Spring Road finals promo)", + "tcg_set": "Neo Spring Road finals", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "232", + "name_en": "No.3 Trainer (Neo Spring Road finals promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Neo Spring Road finals promo)", + "tcg_set": "Neo Spring Road finals", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "233", + "name_en": "Bellossom (Information Pack promo)", + "name_ja": "キレイハナ", + "name_en_source": "manual", + "bulbapedia_page": "Bellossom (Information Pack promo)", + "tcg_set": "Information Pack", + "tcg_name": "Bellossom", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/b/b6/BellossomInformationPackPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "234", + "name_en": "Shining Mew (CoroCoro promo)", + "name_ja": "ひかるミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Shining Mew (CoroCoro promo)", + "tcg_set": "CoroCoro", + "tcg_name": "Shining Mew", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/fe/ShiningMewCoroCoroPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "235", + "name_en": "Shining Magikarp (Fan Club promo)", + "name_ja": "ひかるコイキング", + "name_en_source": "manual", + "bulbapedia_page": "Shining Magikarp (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "Shining Magikarp", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "236", + "name_en": "Tropical Present (Fan Club promo)", + "name_ja": "トロピカルプレゼント", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Present (Fan Club promo)", + "tcg_set": "Fan Club", + "tcg_name": "Tropical Present", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/e/e0/TropicalPresentEarly1999Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "237", + "name_en": "Pryce's Lapras (Information Pack promo)", + "name_ja": "ヤナギのラプラス", + "name_en_source": "manual", + "bulbapedia_page": "Pryce's Lapras (Information Pack promo)", + "tcg_set": "Information Pack", + "tcg_name": "Pryce's Lapras", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "238", + "name_en": "No.1 Trainer (Neo Summer Road promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Neo Summer Road promo)", + "tcg_set": "Neo Summer Road", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "239", + "name_en": "No.2 Trainer (Neo Summer Road promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Neo Summer Road promo)", + "tcg_set": "Neo Summer Road", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "240", + "name_en": "No.3 Trainer (Neo Summer Road promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Neo Summer Road promo)", + "tcg_set": "Neo Summer Road", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "241", + "name_en": "Hoppip (How I Became a Pokémon Card promo)", + "name_ja": "ハネッコ", + "name_en_source": "manual", + "bulbapedia_page": "Hoppip (How I Became a Pokémon Card promo)", + "tcg_set": "How I Became a Pokémon Card", + "tcg_name": "Hoppip", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "242", + "name_en": "No.1 Trainer (Neo Summer Road finals promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Neo Summer Road finals promo)", + "tcg_set": "Neo Summer Road finals", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "243", + "name_en": "No.2 Trainer (Neo Summer Road finals promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Neo Summer Road finals promo)", + "tcg_set": "Neo Summer Road finals", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "244", + "name_en": "No.3 Trainer (Neo Summer Road finals promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Neo Summer Road finals promo)", + "tcg_set": "Neo Summer Road finals", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "245", + "name_en": "_____'s Pikachu (How I Became a Pokémon Card promo)", + "name_ja": "{{tt", + "name_en_source": "manual", + "bulbapedia_page": "_____'s Pikachu (How I Became a Pokémon Card promo)", + "tcg_set": "How I Became a Pokémon Card", + "tcg_name": "_____'s Pikachu", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "246", + "name_en": "Touch Generation Change! (New Garura Parent/Child promo)", + "name_ja": "タッチ世代交代!", + "name_en_source": "manual", + "bulbapedia_page": "Touch Generation Change! (New Garura Parent/Child promo)", + "tcg_set": "New Garura Parent/Child", + "tcg_name": "Touch Generation Change!", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/a/a6/TouchGenerationChangeParentChildPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "247", + "name_en": "No.1 Trainer (Battle Road Spring 2002 promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Battle Road Spring 2002 promo)", + "tcg_set": "Battle Road Spring 2002", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "248", + "name_en": "No.2 Trainer (Battle Road Spring 2002 promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Battle Road Spring 2002 promo)", + "tcg_set": "Battle Road Spring 2002", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "249", + "name_en": "No.3 Trainer (Battle Road Spring 2002 promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Battle Road Spring 2002 promo)", + "tcg_set": "Battle Road Spring 2002", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "250", + "name_en": "No.1 Trainer (Battle Road Summer 2002 promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Battle Road Summer 2002 promo)", + "tcg_set": "Battle Road Summer 2002", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "251", + "name_en": "No.2 Trainer (Battle Road Summer 2002 promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Battle Road Summer 2002 promo)", + "tcg_set": "Battle Road Summer 2002", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "252", + "name_en": "No.3 Trainer (Battle Road Summer 2002 promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Battle Road Summer 2002 promo)", + "tcg_set": "Battle Road Summer 2002", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "253", + "name_en": "No.1 Trainer (Battle Road Summer 2002 finals promo)", + "name_ja": "No.1トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.1 Trainer (Battle Road Summer 2002 finals promo)", + "tcg_set": "Battle Road Summer 2002 finals", + "tcg_name": "No.1 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "254", + "name_en": "No.2 Trainer (Battle Road Summer 2002 finals promo)", + "name_ja": "No.2トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.2 Trainer (Battle Road Summer 2002 finals promo)", + "tcg_set": "Battle Road Summer 2002 finals", + "tcg_name": "No.2 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "255", + "name_en": "No.3 Trainer (Battle Road Summer 2002 finals promo)", + "name_ja": "No.3トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "No.3 Trainer (Battle Road Summer 2002 finals promo)", + "tcg_set": "Battle Road Summer 2002 finals", + "tcg_name": "No.3 Trainer", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "256", + "name_en": "Elite Four Trainer (Battle Road Summer 2002 finals promo)", + "name_ja": "四天王トレーナー", + "name_en_source": "manual", + "bulbapedia_page": "Elite Four Trainer (Battle Road Summer 2002 finals promo)", + "tcg_set": "Battle Road Summer 2002 finals", + "tcg_name": "Elite Four Trainer", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/1/1d/EliteFourTrainerBattleRoadSummer2002finalspromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "257", + "name_en": "Victory Ring (Battle Road promo)", + "name_ja": "勝利のリング", + "name_en_source": "manual", + "bulbapedia_page": "Victory Ring (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Ring", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/VictoryRingBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "258", + "name_en": "Victory Orb (Battle Road promo)", + "name_ja": "勝利のオーブ", + "name_en_source": "manual", + "bulbapedia_page": "Victory Orb (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Orb", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/2f/VictoryOrbBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "259", + "name_en": "Victory Ring (Battle Road promo)", + "name_ja": "勝利のリング", + "name_en_source": "manual", + "bulbapedia_page": "Victory Ring (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Ring", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/VictoryRingBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "260", + "name_en": "Victory Ring (Battle Road promo)", + "name_ja": "勝利のリング", + "name_en_source": "manual", + "bulbapedia_page": "Victory Ring (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Ring", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/VictoryRingBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "261", + "name_en": "Victory Orb (Battle Road promo)", + "name_ja": "勝利のオーブ", + "name_en_source": "manual", + "bulbapedia_page": "Victory Orb (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Orb", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/2f/VictoryOrbBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "262", + "name_en": "Sky-Splitting Deoxys (Movie VS Pack promo)", + "name_ja": "裂空のデオキシス", + "name_en_source": "manual", + "bulbapedia_page": "Sky-Splitting Deoxys (Movie VS Pack promo)", + "tcg_set": "Movie VS Pack", + "tcg_name": "Sky-Splitting Deoxys", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/6e/SkySplittingDeoxysMovieVSPack1.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "263", + "name_en": "Poké Dude (Pokémon Daisuki Club promo)", + "name_ja": "おしえテレビのおにいさん", + "name_en_source": "manual", + "bulbapedia_page": "Poké Dude (Pokémon Daisuki Club promo)", + "tcg_set": "Pokémon Daisuki Club", + "tcg_name": "Poké Dude", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/3/35/Pok%C3%A9DudePok%C3%A9monDaisukiClubPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "264", + "name_en": "Tropical Wind (Nintendo Promo 26)", + "name_ja": "トロピカルウインド", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Wind (Nintendo Promo 26)", + "tcg_set": "Nintendo Promo", + "tcg_name": "Tropical Wind", + "tcg_num": "26", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/TropicalWindTropicalMegaBattle1999Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "265", + "name_en": "Victory Ring (Battle Road promo)", + "name_ja": "勝利のリング", + "name_en_source": "manual", + "bulbapedia_page": "Victory Ring (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Ring", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/VictoryRingBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "266", + "name_en": "Victory Medal (Battle Road promo) (Silver)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "267", + "name_en": "Victory Ring (Battle Road promo)", + "name_ja": "勝利のリング", + "name_en_source": "manual", + "bulbapedia_page": "Victory Ring (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Ring", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/VictoryRingBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "268", + "name_en": "Victory Medal (Battle Road promo) (Gold)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "269", + "name_en": "Victory Orb (Battle Road promo)", + "name_ja": "勝利のオーブ", + "name_en_source": "manual", + "bulbapedia_page": "Victory Orb (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Orb", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/2f/VictoryOrbBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "270", + "name_en": "Tropical Tidal Wave (Nintendo Promo 27)", + "name_ja": "トロピカルタイダルウェーブ", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Tidal Wave (Nintendo Promo 27)", + "tcg_set": "Nintendo Promo", + "tcg_name": "Tropical Tidal Wave", + "tcg_num": "27", + "image_url": "https://archives.bulbagarden.net/media/upload/d/d1/TropicalTidalWaveHGSSPromo18.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "271", + "name_en": "Champion's League (Champion's League promo)", + "name_ja": "チャンピオンズリーグ", + "name_en_source": "manual", + "bulbapedia_page": "Champion's League (Champion's League promo)", + "tcg_set": "Champion's League", + "tcg_name": "Champion's League", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/9f/ChampionLeague2005Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "272", + "name_en": "Shadow Lugia (Jumbo)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Shadow Lugia (Jumbo)" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "273", + "name_en": "Ancient Mew (The Power of One promo)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Ancient Mew (The Power of One promo)", + "tcg_set": "The Power of One", + "tcg_name": "Ancient Mew", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/dd/AncientMewNintedoError.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "274", + "name_en": "Articuno, Moltres, and Zapdos (Warner Bros. promo) (Jumbo)", + "name_ja": "サンダーとフリーザーとファイヤー", + "name_en_source": "manual", + "bulbapedia_page": "Articuno, Moltres, and Zapdos (Warner Bros. promo)", + "tcg_set": "Warner Bros.", + "tcg_name": "Articuno, Moltres, and Zapdos", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8f/ArticunoMoltresZapdosWarnerBrosPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "275", + "name_en": "Shadow Lugia (Nintendo World promo) (Game stamp; Jumbo)", + "name_ja": "ダーク・ルギア", + "name_en_source": "manual", + "bulbapedia_page": "Shadow Lugia (Nintendo World promo)", + "tcg_set": "Nintendo World", + "tcg_name": "Shadow Lugia", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/86/ShadowLugiaNintendoWorldPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "276", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "277", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "278", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "279", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "280", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "281", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "282", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "283", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "284", + "name_en": "Zorua and Celebi (Black and White Tour promo) (Jumbo)", + "name_ja": "ゾロアとセレビィ", + "name_en_source": "manual", + "bulbapedia_page": "Zorua and Celebi (Black and White Tour promo)", + "tcg_set": "Black and White Tour", + "tcg_name": "Zorua and Celebi", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/9e/ZoruaCelebiBlackWhiteTourPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "285", + "name_en": "Zoroark and Legendary Pokémon (World of Illusions promo) (Jumbo)", + "name_ja": "ゾロアークと伝説のポケモン", + "name_en_source": "manual", + "bulbapedia_page": "Zoroark and Legendary Pokémon (World of Illusions promo)", + "tcg_set": "World of Illusions", + "tcg_name": "Zoroark and Legendary Pokémon", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/7d/ZoroarkLegendaryPok%C3%A9monWorldofIllusionsPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "286", + "name_en": "Victory Medal (Battle Road promo)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "287", + "name_en": "_____'s Snivy (Super Snivy promo) (Jumbo)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "_____'s Snivy (Super Snivy promo)", + "tcg_set": "Super Snivy", + "tcg_name": "_____'s Snivy", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "288", + "name_en": "_____'s Tepig (Terrific Tepig promo) (Jumbo)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "_____'s Tepig (Terrific Tepig promo)", + "tcg_set": "Terrific Tepig", + "tcg_name": "_____'s Tepig", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "289", + "name_en": "_____'s Oshawott (Outstanding Oshawott promo) (Jumbo)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "_____'s Oshawott (Outstanding Oshawott promo)", + "tcg_set": "Outstanding Oshawott", + "tcg_name": "_____'s Oshawott", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "290", + "name_en": "Raikou, Entei & Suicune (Legends of Johto promo) (Legendary Pokémon; Jumbo)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Raikou, Entei & Suicune (Legends of Johto promo)", + "tcg_set": "Legends of Johto", + "tcg_name": "Raikou, Entei & Suicune", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "291", + "name_en": "Tornadus, Thundurus & Landorus (Forces of Nature promo) (Legendary Pokémon; Jumbo)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Tornadus, Thundurus & Landorus (Forces of Nature promo)", + "tcg_set": "Forces of Nature", + "tcg_name": "Tornadus, Thundurus & Landorus", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "292", + "name_en": "Reshiram, Zekrom & Kyurem (Legends of Unova promo) (Legendary Pokémon; Jumbo)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Reshiram, Zekrom & Kyurem (Legends of Unova promo)", + "tcg_set": "Legends of Unova", + "tcg_name": "Reshiram, Zekrom & Kyurem", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "293", + "name_en": "Victory Medal (Battle Road promo) (Silver)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "294", + "name_en": "Victory Ring (Battle Road promo)", + "name_ja": "勝利のリング", + "name_en_source": "manual", + "bulbapedia_page": "Victory Ring (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Ring", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/VictoryRingBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "295", + "name_en": "Victory Medal (Battle Road promo) (Gold)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "296", + "name_en": "Victory Orb (Battle Road promo)", + "name_ja": "勝利のオーブ", + "name_en_source": "manual", + "bulbapedia_page": "Victory Orb (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Orb", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/2f/VictoryOrbBattleRoad2003Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "297", + "name_en": "Tropical Tidal Wave (Nintendo Promo 36)", + "name_ja": "トロピカルタイダルウェーブ", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Tidal Wave (Nintendo Promo 36)", + "tcg_set": "Nintendo Promo", + "tcg_name": "Tropical Tidal Wave", + "tcg_num": "36", + "image_url": "https://archives.bulbagarden.net/media/upload/d/d1/TropicalTidalWaveHGSSPromo18.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "298", + "name_en": "Gardevoir ex δ (EX Dragon Frontiers 93) (Jumbo)", + "name_ja": "サーナイト ex δ", + "name_en_source": "manual", + "bulbapedia_page": "Gardevoir ex δ (EX Dragon Frontiers 93)", + "tcg_set": "EX Dragon Frontiers", + "tcg_name": "Gardevoir ex δ", + "tcg_num": "93" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "299", + "name_en": "Tyranitar ex δ (EX Dragon Frontiers 99) (Jumbo)", + "name_ja": "バンギラス ex δ", + "name_en_source": "manual", + "bulbapedia_page": "Tyranitar ex δ (EX Dragon Frontiers 99)", + "tcg_set": "EX Dragon Frontiers", + "tcg_name": "Tyranitar ex δ", + "tcg_num": "99" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "300", + "name_en": "Championship Arena (Battle Road promo)", + "name_ja": "バトルロードスタジアム", + "name_en_source": "manual", + "bulbapedia_page": "Championship Arena (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Championship Arena", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/62/ChampionshipArenaBattleRoadStadiumDPPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "301", + "name_en": "Grass Energy", + "name_ja": "基本草エネルギー", + "name_en_source": "manual", + "bulbapedia_page": "Grass Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "302", + "name_en": "Fire Energy", + "name_ja": "基本炎エネルギー", + "name_en_source": "manual", + "bulbapedia_page": "Fire Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "303", + "name_en": "Lightning Energy", + "name_ja": "基本雷エネルギー", + "name_en_source": "manual", + "bulbapedia_page": "Lightning Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "304", + "name_en": "Psychic Energy", + "name_ja": "基本超エネルギー", + "name_en_source": "manual", + "bulbapedia_page": "Psychic Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "305", + "name_en": "Water Energy", + "name_ja": "基本水エネルギー", + "name_en_source": "manual", + "bulbapedia_page": "Water Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "306", + "name_en": "Fighting Energy", + "name_ja": "基本闘エネルギー", + "name_en_source": "manual", + "bulbapedia_page": "Fighting Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "307", + "name_en": "Darkness Energy", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Darkness Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "308", + "name_en": "Metal Energy", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Metal Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "309", + "name_en": "Victory Medal (Battle Road promo) (Silver w/Stamp)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "310", + "name_en": "Darkness Energy", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Darkness Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "311", + "name_en": "Metal Energy", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Metal Energy" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "312", + "name_en": "Victory Medal (Battle Road promo) (Silver)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "313", + "name_en": "Armor Fossil (Mysterious Treasures 116)", + "name_ja": "たての化石", + "name_en_source": "manual", + "bulbapedia_page": "Armor Fossil (Mysterious Treasures 116)", + "tcg_set": "Mysterious Treasures", + "tcg_name": "Armor Fossil", + "tcg_num": "116" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "314", + "name_en": "Skull Fossil (Mysterious Treasures 117)", + "name_ja": "ずがいの化石", + "name_en_source": "manual", + "bulbapedia_page": "Skull Fossil (Mysterious Treasures 117)", + "tcg_set": "Mysterious Treasures", + "tcg_name": "Skull Fossil", + "tcg_num": "117" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "315", + "name_en": "Bebe's Search (Mysterious Treasures 109)", + "name_ja": "ミズキの検索", + "name_en_source": "manual", + "bulbapedia_page": "Bebe's Search (Mysterious Treasures 109)", + "tcg_set": "Mysterious Treasures", + "tcg_name": "Bebe's Search", + "tcg_num": "109" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "316", + "name_en": "Time-Space Distortion (Mysterious Treasures 124)", + "name_ja": "時空のゆがみ", + "name_en_source": "manual", + "bulbapedia_page": "Time-Space Distortion (Mysterious Treasures 124)", + "tcg_set": "Mysterious Treasures", + "tcg_name": "Time-Space Distortion", + "tcg_num": "124" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "317", + "name_en": "Mysterious Pearl (Battle Road promo)", + "name_ja": "ミステリアス・パール", + "name_en_source": "manual", + "bulbapedia_page": "Mysterious Pearl (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Mysterious Pearl", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/f2/MysteriousPearlBattleRoadPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "318", + "name_en": "Turtwig (Diamond & Pearl 103) (Space-Time Creation; Mini)", + "name_ja": "ナエトル", + "name_en_source": "manual", + "bulbapedia_page": "Turtwig (Diamond & Pearl 103)", + "tcg_set": "Diamond & Pearl", + "tcg_name": "Turtwig", + "tcg_num": "103" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "319", + "name_en": "Chimchar (Diamond & Pearl 76) (Space-Time Creation; Mini)", + "name_ja": "ヒコザル", + "name_en_source": "manual", + "bulbapedia_page": "Chimchar (Diamond & Pearl 76)", + "tcg_set": "Diamond & Pearl", + "tcg_name": "Chimchar", + "tcg_num": "76" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "320", + "name_en": "Piplup (Diamond & Pearl 93) (Space-Time Creation; Mini)", + "name_ja": "ポッチャマ", + "name_en_source": "manual", + "bulbapedia_page": "Piplup (Diamond & Pearl 93)", + "tcg_set": "Diamond & Pearl", + "tcg_name": "Piplup", + "tcg_num": "93" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "321", + "name_en": "Champion's League (Champion's League promo)", + "name_ja": "チャンピオンズリーグ", + "name_en_source": "manual", + "bulbapedia_page": "Champion's League (Champion's League promo)", + "tcg_set": "Champion's League", + "tcg_name": "Champion's League", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/9f/ChampionLeague2005Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "322", + "name_en": "Victory Medal (Battle Road promo) (Gold)", + "name_ja": "勝利のメダル", + "name_en_source": "manual", + "bulbapedia_page": "Victory Medal (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Victory Medal", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/2/26/VictoryMedalLPromo33.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "323", + "name_en": "Darkrai (Great Encounters 4) (Shining Darkness; Jumbo)", + "name_ja": "ダークライ", + "name_en_source": "manual", + "bulbapedia_page": "Darkrai (Great Encounters 4)", + "tcg_set": "Great Encounters", + "tcg_name": "Darkrai", + "tcg_num": "4" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "324", + "name_en": "Pokémon Pal City (Battle Road promo) (Numel & Magby)", + "name_ja": "ポケモンパルシティ", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Pal City (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Pokémon Pal City", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/64/Pok%C3%A9monPalCityPromoChubu.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "325", + "name_en": "Pokémon Pal City (Battle Road promo) (Jirachi & Cherubi)", + "name_ja": "ポケモンパルシティ", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Pal City (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Pokémon Pal City", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/64/Pok%C3%A9monPalCityPromoChubu.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "326", + "name_en": "Pokémon Pal City (Battle Road promo) (Teddiursa & Snover)", + "name_ja": "ポケモンパルシティ", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Pal City (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Pokémon Pal City", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/64/Pok%C3%A9monPalCityPromoChubu.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "327", + "name_en": "Pokémon Pal City (Battle Road promo) (Pikachu & Pichu)", + "name_ja": "ポケモンパルシティ", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Pal City (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Pokémon Pal City", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/64/Pok%C3%A9monPalCityPromoChubu.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "328", + "name_en": "Pokémon Pal City (Battle Road promo) (Magikarp & Hitmonchan)", + "name_ja": "ポケモンパルシティ", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Pal City (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Pokémon Pal City", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/64/Pok%C3%A9monPalCityPromoChubu.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "329", + "name_en": "Pokémon Pal City (Battle Road promo) (Qwilfish & Clamperl)", + "name_ja": "ポケモンパルシティ", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Pal City (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Pokémon Pal City", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/64/Pok%C3%A9monPalCityPromoChubu.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "330", + "name_en": "Pokémon Pal City (Battle Road promo) (Meowth & Stantler)", + "name_ja": "ポケモンパルシティ", + "name_en_source": "manual", + "bulbapedia_page": "Pokémon Pal City (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Pokémon Pal City", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/6/64/Pok%C3%A9monPalCityPromoChubu.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "331", + "name_en": "Touch Exchange! (Battle Road promo)", + "name_ja": "タッチ交換!", + "name_en_source": "manual", + "bulbapedia_page": "Touch Exchange! (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Touch Exchange!", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "332", + "name_en": "Roseanne's Research (Secret Wonders 125)", + "name_ja": "ハマナのリサーチ", + "name_en_source": "manual", + "bulbapedia_page": "Roseanne's Research (Secret Wonders 125)", + "tcg_set": "Secret Wonders", + "tcg_name": "Roseanne's Research", + "tcg_num": "125" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "333", + "name_en": "Professor Oak's Visit (Secret Wonders 122)", + "name_ja": "オーキドはかせの訪問", + "name_en_source": "manual", + "bulbapedia_page": "Professor Oak's Visit (Secret Wonders 122)", + "tcg_set": "Secret Wonders", + "tcg_name": "Professor Oak's Visit", + "tcg_num": "122" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "334", + "name_en": "Time-Space Distortion (Mysterious Treasures 124)", + "name_ja": "時空のゆがみ", + "name_en_source": "manual", + "bulbapedia_page": "Time-Space Distortion (Mysterious Treasures 124)", + "tcg_set": "Mysterious Treasures", + "tcg_name": "Time-Space Distortion", + "tcg_num": "124" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "335", + "name_en": "Miracle Diamond (Battle Road promo)", + "name_ja": "ミラクル・ダイヤモンド", + "name_en_source": "manual", + "bulbapedia_page": "Miracle Diamond (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Miracle Diamond", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c9/MiracleDiamondBattleRoadPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "336", + "name_en": "Tropical Wind (DP Promo 5)", + "name_ja": "トロピカルウインド", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Wind (DP Promo 5)", + "tcg_set": "DP Promo", + "tcg_name": "Tropical Wind", + "tcg_num": "5" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "337", + "name_en": "Champion's League (Champion's League promo)", + "name_ja": "チャンピオンズリーグ", + "name_en_source": "manual", + "bulbapedia_page": "Champion's League (Champion's League promo)", + "tcg_set": "Champion's League", + "tcg_name": "Champion's League", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/9f/ChampionLeague2005Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "338", + "name_en": "Time-Space Distortion (Mysterious Treasures 124)", + "name_ja": "時空のゆがみ", + "name_en_source": "manual", + "bulbapedia_page": "Time-Space Distortion (Mysterious Treasures 124)", + "tcg_set": "Mysterious Treasures", + "tcg_name": "Time-Space Distortion", + "tcg_num": "124" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "339", + "name_en": "Mysterious Pearl (Battle Road promo)", + "name_ja": "ミステリアス・パール", + "name_en_source": "manual", + "bulbapedia_page": "Mysterious Pearl (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Mysterious Pearl", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/f/f2/MysteriousPearlBattleRoadPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "340", + "name_en": "Buck's Training (Battle Road promo)", + "name_ja": "バクのトレーニング", + "name_en_source": "manual", + "bulbapedia_page": "Buck's Training (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Buck's Training", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "341", + "name_en": "Cynthia's Feelings (Battle Road promo)", + "name_ja": "シロナの想い", + "name_en_source": "manual", + "bulbapedia_page": "Cynthia's Feelings (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Cynthia's Feelings", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "342", + "name_en": "Miracle Diamond (Battle Road promo)", + "name_ja": "ミラクル・ダイヤモンド", + "name_en_source": "manual", + "bulbapedia_page": "Miracle Diamond (Battle Road promo)", + "tcg_set": "Battle Road", + "tcg_name": "Miracle Diamond", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/c/c9/MiracleDiamondBattleRoadPromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "343", + "name_en": "Luxury Ball (Gym Challenge promo)", + "name_ja": "ゴージャスボール", + "name_en_source": "manual", + "bulbapedia_page": "Luxury Ball (Gym Challenge promo)", + "tcg_set": "Gym Challenge", + "tcg_name": "Luxury Ball", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "344", + "name_en": "Tropical Wind (Nintendo Promo 26)", + "name_ja": "トロピカルウインド", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Wind (Nintendo Promo 26)", + "tcg_set": "Nintendo Promo", + "tcg_name": "Tropical Wind", + "tcg_num": "26", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/TropicalWindTropicalMegaBattle1999Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "345", + "name_en": "Captain Pikachu (Pokémon Center promo) (Jumbo)", + "name_ja": "キャプテンピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "Captain Pikachu (Pokémon Center promo)", + "tcg_set": "Pokémon Center", + "tcg_name": "Captain Pikachu", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/7c/CaptainPikachuPok%C3%A9monCenterpromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "346", + "name_en": "_____'s Pikachu (Pokémon Center promo) (Jumbo)", + "name_ja": "_____のピカチュウ", + "name_en_source": "manual", + "bulbapedia_page": "_____'s Pikachu (Pokémon Center promo)", + "tcg_set": "Pokémon Center", + "tcg_name": "_____'s Pikachu", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/7/7f/PikachuPok%C3%A9monCenterpromo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "347", + "name_en": "[[Spiky-eared Pichu M (Movie Random Pack 9)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "[[Spiky-eared Pichu M (Movie Random Pack 9)" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "348", + "name_en": "[[Spiky-eared Pichu M (Movie Random Pack 9)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "[[Spiky-eared Pichu M (Movie Random Pack 9)" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "349", + "name_en": "[[Spiky-eared Pichu M (Movie Random Pack 9)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "[[Spiky-eared Pichu M (Movie Random Pack 9)" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "350", + "name_en": "Arceus (DPt-P Promo 41)", + "name_ja": "アルセウス", + "name_en_source": "manual", + "bulbapedia_page": "Arceus (DPt-P Promo 41)", + "tcg_set": "DPt-P Promo", + "tcg_name": "Arceus", + "tcg_num": "41" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "351", + "name_en": "Arceus (DPt-P Promo 41)", + "name_ja": "アルセウス", + "name_en_source": "manual", + "bulbapedia_page": "Arceus (DPt-P Promo 41)", + "tcg_set": "DPt-P Promo", + "tcg_name": "Arceus", + "tcg_num": "41" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "352", + "name_en": "Arceus (DPt-P Promo 41)", + "name_ja": "アルセウス", + "name_en_source": "manual", + "bulbapedia_page": "Arceus (DPt-P Promo 41)", + "tcg_set": "DPt-P Promo", + "tcg_name": "Arceus", + "tcg_num": "41" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "353", + "name_en": "Arceus (DPt-P Promo 41)", + "name_ja": "アルセウス", + "name_en_source": "manual", + "bulbapedia_page": "Arceus (DPt-P Promo 41)", + "tcg_set": "DPt-P Promo", + "tcg_name": "Arceus", + "tcg_num": "41" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "354", + "name_en": "[[Spiky-eared Pichu M (Movie Random Pack 9)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "[[Spiky-eared Pichu M (Movie Random Pack 9)" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "355", + "name_en": "Arceus (DPt-P Promo 41)", + "name_ja": "アルセウス", + "name_en_source": "manual", + "bulbapedia_page": "Arceus (DPt-P Promo 41)", + "tcg_set": "DPt-P Promo", + "tcg_name": "Arceus", + "tcg_num": "41" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "356", + "name_en": "[[Spiky-eared Pichu M (Movie Random Pack 9)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "[[Spiky-eared Pichu M (Movie Random Pack 9)" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "357", + "name_en": "[[Spiky-eared Pichu M (Movie Random Pack 9)", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "[[Spiky-eared Pichu M (Movie Random Pack 9)" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "358", + "name_en": "Arceus (DPt-P Promo 41)", + "name_ja": "アルセウス", + "name_en_source": "manual", + "bulbapedia_page": "Arceus (DPt-P Promo 41)", + "tcg_set": "DPt-P Promo", + "tcg_name": "Arceus", + "tcg_num": "41" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "359", + "name_en": "Arceus (DPt-P Promo 41)", + "name_ja": "アルセウス", + "name_en_source": "manual", + "bulbapedia_page": "Arceus (DPt-P Promo 41)", + "tcg_set": "DPt-P Promo", + "tcg_name": "Arceus", + "tcg_num": "41" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "360", + "name_en": "Tropical Wind (Nintendo Promo 26)", + "name_ja": "トロピカルウインド", + "name_en_source": "manual", + "bulbapedia_page": "Tropical Wind (Nintendo Promo 26)", + "tcg_set": "Nintendo Promo", + "tcg_name": "Tropical Wind", + "tcg_num": "26", + "image_url": "https://archives.bulbagarden.net/media/upload/8/8b/TropicalWindTropicalMegaBattle1999Promo.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "361", + "name_en": "Zorua and Celebi (Clash at the Summit promo) (Jumbo)", + "name_ja": "ゾロアとセレビィ", + "name_en_source": "manual", + "bulbapedia_page": "Zorua and Celebi (Clash at the Summit promo)", + "tcg_set": "Clash at the Summit", + "tcg_name": "Zorua and Celebi", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "362", + "name_en": "Zoroark and the Legendary Pokémon (Clash at the Summit promo) (Jumbo)", + "name_ja": "ゾロアークと伝説のポケモン", + "name_en_source": "manual", + "bulbapedia_page": "Zoroark and the Legendary Pokémon (Clash at the Summit promo)", + "tcg_set": "Clash at the Summit", + "tcg_name": "Zoroark and the Legendary Pokémon", + "tcg_num": "promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "363", + "name_en": "Pikachu & Ishihara", + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": "Pikachu & Ishihara" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "364", + "name_en": "Gardevoir (Illustration Grand Prix promo)", + "name_ja": "サーナイト", + "name_en_source": "manual", + "bulbapedia_page": "Gardevoir (Illustration Grand Prix promo)", + "tcg_set": "Illustration Grand Prix", + "tcg_name": "Gardevoir", + "tcg_num": "Promo" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "365", + "name_en": "Ancient Mew (The Power of One promo)", + "name_ja": "ミュウ", + "name_en_source": "manual", + "bulbapedia_page": "Ancient Mew (The Power of One promo)", + "tcg_set": "The Power of One", + "tcg_name": "Ancient Mew", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/d/dd/AncientMewNintedoError.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "366", + "name_en": "Mewtwo (Vending S1)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (Vending S1)", + "tcg_set": "Expansion Sheet 1", + "tcg_name": "Mewtwo", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/0/0c/MewtwoVendingS1.jpg" + }, + { + "set_id": "UnnumberedPromo", + "local_id": "367", + "name_en": "Mewtwo (Vending S3)", + "name_ja": "ミュウツー", + "name_en_source": "manual", + "bulbapedia_page": "Mewtwo (Vending S3)", + "tcg_set": "Expansion Sheet 3", + "tcg_name": "Mewtwo", + "tcg_num": "promo", + "image_url": "https://archives.bulbagarden.net/media/upload/9/90/MewtwoVendingS3.jpg" + } +] diff --git a/tools/pokemon_jp/classic_missing_sets.json b/tools/pokemon_jp/classic_missing_sets.json new file mode 100644 index 0000000..7abbf6e --- /dev/null +++ b/tools/pokemon_jp/classic_missing_sets.json @@ -0,0 +1,57 @@ +{ + "ExpSheet1": { + "name_en": "Expansion Sheet Series 1", + "name_ja": "拡張シート 第1弾", + "releaseDate": "1998/03/23" + }, + "NiviCG": { + "name_en": "Nivi City Gym", + "name_ja": "ニビシティジム タケシ", + "releaseDate": "1998/04/26" + }, + "HanadaCG": { + "name_en": "Hanada City Gym", + "name_ja": "ハナダシティジム カスミ", + "releaseDate": "1998/04/26" + }, + "ExpSheet2": { + "name_en": "Expansion Sheet Series 2", + "name_ja": "拡張シート 第2弾", + "releaseDate": "1998/06/17" + }, + "KuchibaCG": { + "name_en": "Kuchiba City Gym", + "name_ja": "クチバシティジム マチス", + "releaseDate": "1998/07/25" + }, + "TamamushiCG": { + "name_en": "Tamamushi City Gym", + "name_ja": "タマムシシティジム エリカ", + "releaseDate": "1998/07/25" + }, + "ExpSheet3": { + "name_en": "Expansion Sheet Series 3", + "name_ja": "拡張シート 第3弾", + "releaseDate": "1998/11/24" + }, + "YamabukiCG": { + "name_en": "Yamabuki City Gym", + "name_ja": "ヤマブキシティジム ナツメ", + "releaseDate": "1999/02/26" + }, + "GurenTG": { + "name_en": "Guren Town Gym", + "name_ja": "グレンタウンジム カツラ", + "releaseDate": "1999/02/26" + }, + "SouthernIslands": { + "name_en": "Southern Islands", + "name_ja": "サザンアイランド", + "releaseDate": "1999/07/17" + }, + "UnnumberedPromo": { + "name_en": "Unnumbered Promotional cards", + "name_ja": "番号なしプロモーションカード", + "releaseDate": "1997/03/06" + } +} diff --git a/tools/pokemon_jp/enrich_neo_image_urls.py b/tools/pokemon_jp/enrich_neo_image_urls.py new file mode 100644 index 0000000..937d2aa --- /dev/null +++ b/tools/pokemon_jp/enrich_neo_image_urls.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Fill neo1–neo4 catalog image_url from Japanese CardIndex scans only. + +TCGdex JA neo sets have image:null. This ETL scrapes CardIndex Japanese set +pages (Awakening Legends, etc.) and writes HTTPS image_url values for exact +JA setId+localId catalog rows. + +Policy (UnnumberedPromo parity): Japanese scans only. If CardIndex has no JP +image for a print, image_url is cleared — never store English pokemontcg.io +art as a fallback. + +Usage: + python tools/pokemon_jp/enrich_neo_image_urls.py + python tools/pokemon_jp/enrich_neo_image_urls.py --dry-run + python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite + python tools/pokemon_jp/enrich_neo_image_urls.py --overwrite --limit 20 +""" + +from __future__ import annotations + +import argparse +import json +import re +import time +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json" + +NEO_SETS = frozenset({"neo1", "neo2", "neo3", "neo4"}) +UA = { + "User-Agent": ( + "CCM3-pokemon-jp-etl/1.0 " + "(local; +https://github.com/sebastiandine/Card-Collection-Manager-3)" + ) +} + +# JA neo set id -> (CardIndex set page slug, image CDN folder) +SET_META: dict[str, tuple[str, str]] = { + "neo1": ("gold-silver-to-a-new-world", "neo-jp-gold-silver"), + "neo2": ("crossing-the-ruins", "neo-jp-crossing-ruins"), + "neo3": ("awakening-legends", "neo-jp-awakening-legends"), + "neo4": ("darkness-and-to-light", "neo-jp-darkness-light"), +} + +IMG_RE = re.compile( + r"https://images\.cardindex\.co/cardindex-images/cards/" + r"(?P[^/\"']+)/(?P[^\"'\s>]+)\.(?Pjpe?g|png|webp)", + re.IGNORECASE, +) + + +def log(msg: str) -> None: + try: + print(msg, flush=True) + except UnicodeEncodeError: + print(msg.encode("ascii", errors="replace").decode("ascii"), flush=True) + + +def http_get(url: str, timeout: float = 60.0) -> str: + req = urllib.request.Request(url, headers=UA) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", "replace") + + +def normalize_name(s: str) -> str: + """Lowercase alnum tokens for matching catalog names to CardIndex slugs.""" + s = s.lower().replace("\u2019", "'").replace("'", "") + s = re.sub(r"[^a-z0-9]+", " ", s) + return " ".join(s.split()) + + +def slug_base_name(slug: str) -> str: + """shining-magikarp-129 / balloon-berry-promo -> shining magikarp / balloon berry.""" + s = slug.strip().lower() + s = re.sub(r"-promo$", "", s) + s = re.sub(r"-\d+$", "", s) + return normalize_name(s.replace("-", " ")) + + +def pick_set_image(html: str, image_folder: str, card_slug: str) -> str | None: + """Prefer full-size JP scan in this set's CDN folder for this card slug.""" + folder_l = image_folder.lower() + slug_l = card_slug.lower() + full: list[str] = [] + small: list[str] = [] + for m in IMG_RE.finditer(html): + if m.group("folder").lower() != folder_l: + continue + file_stem = m.group("file").lower() + # Require the card's own slug (with optional -small). + if not (file_stem == slug_l or file_stem == f"{slug_l}-small"): + # Also accept promo variant files named "{base}-promo". + base = re.sub(r"-\d+$", "", slug_l) + if not ( + file_stem == f"{base}-promo" + or file_stem == f"{base}-promo-small" + or file_stem.startswith(f"{slug_l}") + ): + continue + url = m.group(0) + if file_stem.endswith("-small"): + small.append(url) + else: + full.append(url) + if full: + # Prefer exact slug match over promo/other. + for u in full: + if f"/{slug_l}." in u.lower(): + return u + for u in full: + if f"/{slug_l}-" not in u.lower() or "-promo." in u.lower(): + return u + return full[0] + if small: + # Upgrade -small to full-size URL when possible. + u = small[0] + return re.sub(r"-small\.(jpe?g|png|webp)$", r".\1", u, flags=re.I) + return None + + +def scrape_set_index( + set_id: str, *, sleep_s: float +) -> dict[str, list[tuple[str, str]]]: + """Return normalize_name -> [(card_slug, image_url), ...] for one neo set.""" + page_slug, image_folder = SET_META[set_id] + set_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}" + log(f"scraping set index {set_id}: {set_url}") + html = http_get(set_url) + card_slugs = sorted( + set( + re.findall( + rf"/pokemon-cards/{re.escape(page_slug)}/([a-z0-9\-]+)", + html, + ) + ) + ) + log(f" {len(card_slugs)} card pages") + + by_name: dict[str, list[tuple[str, str]]] = {} + for i, slug in enumerate(card_slugs, start=1): + card_url = f"https://www.cardindex.co/pokemon-cards/{page_slug}/{slug}" + try: + time.sleep(sleep_s) + card_html = http_get(card_url) + except (urllib.error.URLError, TimeoutError) as exc: + log(f" [{i}/{len(card_slugs)}] FAIL {slug}: {exc}") + continue + img = pick_set_image(card_html, image_folder, slug) + if not img: + log(f" [{i}/{len(card_slugs)}] no JP image {slug}") + continue + name = slug_base_name(slug) + by_name.setdefault(name, []).append((slug, img)) + log(f" [{i}/{len(card_slugs)}] {name!r} <- {img}") + return by_name + + +def resolve_url_for_print( + name_en: str, index: dict[str, list[tuple[str, str]]] +) -> str | None: + key = normalize_name(name_en) + if not key: + return None + hits = index.get(key) or [] + if not hits: + return None + # Unique image only — ambiguous Unown / multi-print names stay empty. + urls = sorted({u for _slug, u in hits}) + if len(urls) == 1: + return urls[0] + return None + + +def enrich_neo_images( + catalog: dict, + *, + dry_run: bool, + overwrite: bool, + sleep_s: float, + limit: int, + out_path: Path, +) -> tuple[int, int, int, int]: + prints = catalog.get("prints", []) + candidates = [ + p + for p in prints + if p.get("set_id") in NEO_SETS and (p.get("name_en") or "").strip() + ] + if not overwrite: + candidates = [ + p for p in candidates if not (p.get("image_url") or "").strip() + ] + if limit > 0: + candidates = candidates[:limit] + + # Scrape only the sets we need. + needed_sets = sorted({str(p["set_id"]) for p in candidates}) + indexes: dict[str, dict[str, list[tuple[str, str]]]] = {} + for sid in needed_sets: + indexes[sid] = scrape_set_index(sid, sleep_s=sleep_s) + + filled = 0 + changed = 0 + missed = 0 + updates = 0 + + def persist() -> None: + if dry_run: + return + out_path.write_text( + json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + for i, p in enumerate(candidates, start=1): + sid = str(p["set_id"]) + lid = str(p["local_id"]) + name_en = str(p["name_en"]).strip() + prev = (p.get("image_url") or "").strip() + log(f"[{i}/{len(candidates)}] {sid}-{lid} {name_en!r} …") + url = resolve_url_for_print(name_en, indexes.get(sid, {})) + if url: + if not dry_run: + p["image_url"] = url + filled += 1 + if url != prev: + changed += 1 + updates += 1 + log(f" -> {url}" + (f" (was {prev})" if prev else "")) + else: + log(f" -> {url} (unchanged)") + else: + missed += 1 + if prev: + if not dry_run: + p.pop("image_url", None) + changed += 1 + updates += 1 + log(f" -> (miss, cleared {prev})") + else: + log(" -> (miss)") + if updates >= 25: + persist() + updates = 0 + log(f" checkpoint wrote {out_path}") + + persist() + return len(candidates), filled, changed, missed + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=OUT) + ap.add_argument("--dry-run", action="store_true") + ap.add_argument( + "--overwrite", + action="store_true", + help="Re-resolve neo prints that already have image_url (clears EN URLs)", + ) + ap.add_argument("--limit", type=int, default=0, help="Max neo prints to process") + ap.add_argument("--sleep", type=float, default=0.35, help="Seconds between HTTP calls") + args = ap.parse_args() + + if not args.out.is_file(): + raise SystemExit(f"catalog not found: {args.out}") + + catalog = json.loads(args.out.read_text(encoding="utf-8")) + total, filled, changed, missed = enrich_neo_images( + catalog, + dry_run=args.dry_run, + overwrite=args.overwrite, + sleep_s=args.sleep, + limit=args.limit, + out_path=args.out, + ) + + print( + f"neo image_url: candidates={total} filled={filled} changed={changed} " + f"missed={missed}" + + (" (dry-run)" if args.dry_run else f" wrote {args.out}"), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/enrich_preview_images.py b/tools/pokemon_jp/enrich_preview_images.py new file mode 100644 index 0000000..e040fd9 --- /dev/null +++ b/tools/pokemon_jp/enrich_preview_images.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""Enrich pokemon_jp_en_catalog.json prints from TCGdex data-asia. + +Harvests per-card: + - tcgplayer_id (thirdParty.tcgplayer) for classic-image gap-fill + - name_ja from the card source + - name_en via National Dex id → English species name (when dexId present) + - name_en for owner / Rocket's / Dark / Light / Shining variants (full titles) + - name_en for Trainer/Energy via tools/pokemon_jp/non_pokemon_en_by_ja.json + +English names are required for Auto-detect when the user types "Mewtwo" / +"Switch" / "Erika's Oddish" / "Dark Charizard" etc. — TCGdex set résumés only +expose Japanese names. + +Usage: + python tools/pokemon_jp/enrich_preview_images.py + python tools/pokemon_jp/enrich_preview_images.py --data-asia path/to/data-asia +""" + +from __future__ import annotations + +import argparse +import io +import json +import re +import shutil +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json" +CACHE_DIR = Path(__file__).resolve().parent / "_tcgdex_cards_database" +SPECIES_CACHE = Path(__file__).resolve().parent / "species_en.json" +NON_POKEMON_EN = Path(__file__).resolve().parent / "non_pokemon_en_by_ja.json" +ZIP_URL = "https://github.com/tcgdex/cards-database/archive/refs/heads/master.zip" +# National-dex-ordered English names (index 0 = Bulbasaur / dex 1). +SPECIES_URL = ( + "https://raw.githubusercontent.com/sindresorhus/pokemon/main/data/en.json" +) + +TCGPLAYER_RE = re.compile(r"tcgplayer\s*:\s*(\d+)") +NAME_JA_RE = re.compile(r"name\s*:\s*\{\s*ja\s*:\s*\"([^\"]+)\"", re.DOTALL) +DEX_RE = re.compile(r"dexId\s*:\s*\[\s*(\d+)") +CATEGORY_RE = re.compile(r'category\s*:\s*"([^"]+)"') +LOCAL_ID_RE = re.compile(r"^[0-9A-Za-z]+$") + +# Chronological first 15 main Japanese expansions in TCGdex (for coverage checks). +# Longest JA prefixes first. Maps to English product-title prefix + National Dex species. +VARIANT_JA_PREFIXES: list[tuple[str, str]] = [ + ("R団の", "Rocket's "), + ("エリカの", "Erika's "), + ("タケシの", "Brock's "), + ("カスミの", "Misty's "), + ("マチスの", "Lt. Surge's "), + ("ナツメの", "Sabrina's "), + ("カツラの", "Blaine's "), + ("キョウの", "Koga's "), + ("サカキの", "Giovanni's "), + ("ヤナギの", "Pryce's "), + ("カンナの", "Lorelei's "), + ("シバの", "Bruno's "), + ("キクコの", "Agatha's "), + ("やさしい", "Light "), + ("ひかる", "Shining "), + ("輝く", "Shining "), # neo Destiny upstream garble + ("軽い", "Light "), # neo Destiny upstream garble + ("わるい", "Dark "), + ("暗い", "Dark "), # neo Destiny upstream garble + ("ダーク", "Dark "), # neo Destiny upstream garble (e.g. ダークアリアドス) +] + +PROTECTED_NAME_EN_SOURCES = frozenset( + {"manual", "trainer-table", "energy-table", "bulbapedia", "tcgdex-thirdparty"} +) + +FIRST15_SETS = [ + "PMCG1", + "PMCG2", + "PMCG3", + "PMCG4", + "PMCG5", + "PMCG6", + "neo1", + "neo2", + "neo3", + "neo4", + "VS1", + "web1", + "E1", + "E2", + "E3", +] + + +def download_data_asia(dest: Path) -> Path: + dest.mkdir(parents=True, exist_ok=True) + marker = dest / "data-asia" + if marker.is_dir() and any(marker.rglob("*.ts")): + return marker + + print(f"Downloading {ZIP_URL} …") + req = urllib.request.Request(ZIP_URL, headers={"User-Agent": "ccm-pokemonjp-etl"}) + with urllib.request.urlopen(req, timeout=180) as resp: + blob = resp.read() + + with zipfile.ZipFile(io.BytesIO(blob)) as zf: + members = [n for n in zf.namelist() if "/data-asia/" in n.replace("\\", "/")] + for name in members: + parts = Path(name).parts + if "data-asia" not in parts: + continue + idx = parts.index("data-asia") + rel = Path(*parts[idx:]) + target = dest / rel + if name.endswith("/"): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(name) as src, open(target, "wb") as out: + shutil.copyfileobj(src, out) + + if not marker.is_dir(): + raise SystemExit("data-asia missing after zip extract") + return marker + + +def load_species_en() -> dict[int, str]: + """Map National Dex id -> English species name.""" + if SPECIES_CACHE.is_file(): + raw = json.loads(SPECIES_CACHE.read_text(encoding="utf-8")) + else: + print(f"Downloading {SPECIES_URL} …") + req = urllib.request.Request( + SPECIES_URL, headers={"User-Agent": "ccm-pokemonjp-etl"} + ) + with urllib.request.urlopen(req, timeout=60) as resp: + raw = json.loads(resp.read().decode("utf-8")) + SPECIES_CACHE.write_text( + json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + # File is a list: ["Bulbasaur", "Ivysaur", ...] + if isinstance(raw, list): + return {i + 1: name for i, name in enumerate(raw) if isinstance(name, str)} + if isinstance(raw, dict): + return {int(k): str(v) for k, v in raw.items()} + raise SystemExit("unexpected species_en.json shape") + + +def load_non_pokemon_en() -> dict[str, str]: + """Map Japanese Trainer/Energy (etc.) names → English display names.""" + if not NON_POKEMON_EN.is_file(): + return {} + raw = json.loads(NON_POKEMON_EN.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise SystemExit("non_pokemon_en_by_ja.json must be a JSON object") + return {str(k): str(v) for k, v in raw.items() if str(k).strip() and str(v).strip()} + + +def extract_cards(data_asia: Path) -> dict[tuple[str, str], dict]: + """Map (setId, localId) -> {tcgplayer_id, name_ja, dex_id}.""" + out: dict[tuple[str, str], dict] = {} + for path in data_asia.rglob("*.ts"): + try: + rel = path.relative_to(data_asia) + except ValueError: + continue + parts = rel.parts + if len(parts) != 3: + continue + set_id = parts[1] + local_id = path.stem + if not LOCAL_ID_RE.match(local_id): + continue + text = path.read_text(encoding="utf-8", errors="replace") + entry: dict = {} + m = TCGPLAYER_RE.search(text) + if m: + entry["tcgplayer_id"] = m.group(1) + m = NAME_JA_RE.search(text) + if m: + entry["name_ja"] = m.group(1) + m = DEX_RE.search(text) + if m: + entry["dex_id"] = int(m.group(1)) + m = CATEGORY_RE.search(text) + category = m.group(1) if m else "" + # TCGdex PMCG1-102 Fighting Energy has an empty Japanese name in source. + if ( + set_id == "PMCG1" + and local_id == "102" + and category == "Energy" + and not entry.get("name_ja") + ): + entry["name_ja"] = "基本闘エネルギー" + if not entry: + continue + out[(set_id, local_id)] = entry + return out + + +def variant_en_prefix(name_ja: str) -> str | None: + """Return English title prefix for a known JA variant pattern, or None.""" + for ja_prefix, en_prefix in VARIANT_JA_PREFIXES: + if name_ja.startswith(ja_prefix): + return en_prefix + return None + + +def compose_species_name_en( + name_ja: str, dex_id: int | None, species_en: dict[int, str] +) -> tuple[str, str] | None: + """Return (name_en, name_en_source) from dex + optional variant prefix.""" + if dex_id is None or dex_id not in species_en: + return None + species = species_en[dex_id] + prefix = variant_en_prefix(name_ja) + if prefix: + return prefix + species, "species-table-variant" + return species, "species-table" + + +def upgrade_variant_titles( + prints: list[dict], + cards: dict[tuple[str, str], dict], + species_en: dict[int, str], +) -> int: + """Upgrade bare species-table rows to full variant English titles.""" + upgraded = 0 + for p in prints: + if (p.get("name_en_source") or "") in PROTECTED_NAME_EN_SOURCES: + continue + ja = (p.get("name_ja") or "").strip() + if not ja or variant_en_prefix(ja) is None: + continue + sid = str(p.get("set_id", "")) + lid = str(p.get("local_id", "")) + dex = cards.get((sid, lid), {}).get("dex_id") + composed = compose_species_name_en(ja, dex, species_en) + if composed is None: + continue + full_en, src = composed + if p.get("name_en") == full_en and p.get("name_en_source") == src: + continue + p["name_en"] = full_en + p["name_en_source"] = src + upgraded += 1 + return upgraded + + +def verify_first15_trainer_coverage( + data_asia: Path, non_pokemon_en: dict[str, str] +) -> list[str]: + """Return unique Trainer/Energy JA names in FIRST15 still missing from the map.""" + missing: set[str] = set() + for path in data_asia.rglob("*.ts"): + try: + rel = path.relative_to(data_asia) + except ValueError: + continue + parts = rel.parts + if len(parts) != 3 or parts[1] not in FIRST15_SETS: + continue + text = path.read_text(encoding="utf-8", errors="replace") + catm = CATEGORY_RE.search(text) + if not catm or catm.group(1) == "Pokemon": + continue + jam = NAME_JA_RE.search(text) + ja = jam.group(1) if jam else "" + if path.stem == "102" and parts[1] == "PMCG1" and not ja: + ja = "基本闘エネルギー" + if not ja: + missing.add(f"{parts[1]}/{path.stem} ") + continue + if ja not in non_pokemon_en: + missing.add(ja) + return sorted(missing) + + +def merge_catalog( + catalog: dict, + cards: dict[tuple[str, str], dict], + species_en: dict[int, str], + non_pokemon_en: dict[str, str], +) -> tuple[int, int, int, int]: + prints = catalog.setdefault("prints", []) + by_key: dict[tuple[str, str], dict] = {} + for p in prints: + sid = str(p.get("set_id", "")) + lid = str(p.get("local_id", "")) + if sid and lid: + by_key[(sid, lid)] = p + + updated = 0 + added = 0 + species_named = 0 + table_named = 0 + for (sid, lid), meta in sorted(cards.items()): + existing = by_key.get((sid, lid)) + if existing is None: + existing = { + "set_id": sid, + "local_id": lid, + "name_en": "", + "name_ja": "", + "name_en_source": "", + } + prints.append(existing) + by_key[(sid, lid)] = existing + added += 1 + + changed = False + pid = meta.get("tcgplayer_id") + if pid and existing.get("tcgplayer_id") != pid: + existing["tcgplayer_id"] = pid + changed = True + + name_ja = meta.get("name_ja", "") + if name_ja and not (existing.get("name_ja") or "").strip(): + existing["name_ja"] = name_ja + changed = True + + if not (existing.get("name_en") or "").strip(): + dex = meta.get("dex_id") + ja_key = (existing.get("name_ja") or name_ja or "").strip() + composed = compose_species_name_en(ja_key, dex, species_en) + if composed is not None: + existing["name_en"], existing["name_en_source"] = composed + species_named += 1 + changed = True + else: + ja_key = (existing.get("name_ja") or name_ja or "").strip() + if ja_key and ja_key in non_pokemon_en: + existing["name_en"] = non_pokemon_en[ja_key] + # Energies vs trainers: basic energy names share a pattern. + if "エネルギー" in ja_key and ja_key.startswith("基本"): + existing["name_en_source"] = "energy-table" + elif "エネルギー" in ja_key: + existing["name_en_source"] = "energy-table" + else: + existing["name_en_source"] = "trainer-table" + table_named += 1 + changed = True + + if changed: + updated += 1 + + # Also apply the JA→EN table to existing prints that were never in data-asia + # walk (or already present with name_ja but empty name_en). + for p in prints: + if (p.get("name_en") or "").strip(): + continue + ja_key = (p.get("name_ja") or "").strip() + if not ja_key or ja_key not in non_pokemon_en: + continue + p["name_en"] = non_pokemon_en[ja_key] + if "エネルギー" in ja_key: + p["name_en_source"] = "energy-table" + else: + p["name_en_source"] = "trainer-table" + table_named += 1 + updated += 1 + + catalog["prints"] = prints + return updated, added, species_named, table_named + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--data-asia", type=Path, default=None) + ap.add_argument("--out", type=Path, default=OUT) + args = ap.parse_args() + + if args.data_asia: + data_asia = args.data_asia + if not data_asia.is_dir(): + raise SystemExit(f"data-asia not found: {data_asia}") + else: + data_asia = download_data_asia(CACHE_DIR) + + species_en = load_species_en() + non_pokemon_en = load_non_pokemon_en() + cards = extract_cards(data_asia) + print(f"found {len(cards)} card files under {data_asia}") + print(f"non-pokemon EN map entries: {len(non_pokemon_en)}") + + if args.out.exists(): + catalog = json.loads(args.out.read_text(encoding="utf-8")) + else: + catalog = {"sets": {}, "prints": []} + + updated, added, species_named, table_named = merge_catalog( + catalog, cards, species_en, non_pokemon_en + ) + variant_upgraded = upgrade_variant_titles( + catalog["prints"], cards, species_en + ) + + for lid, expect_en in ( + ("021", "Charizard"), + ("032", "Blastoise"), + ("050", "Mewtwo"), + ("073", "Switch"), + ): + hit = next( + ( + p + for p in catalog["prints"] + if p.get("set_id") == "PMCG1" and p.get("local_id") == lid + ), + None, + ) + if hit: + print( + f"PMCG1/{lid}: name_en={hit.get('name_en')!r} " + f"name_ja={hit.get('name_ja')!r} tp={hit.get('tcgplayer_id')}" + ) + if hit.get("name_en") != expect_en: + print(f" WARNING: expected name_en {expect_en!r}") + else: + print(f"WARNING: missing PMCG1/{lid}") + + gaps = verify_first15_trainer_coverage(data_asia, non_pokemon_en) + if gaps: + print(f"WARNING: {len(gaps)} FIRST15 trainer/energy JA names still unmapped:") + for ja in gaps[:30]: + print(f" - {ja}") + if len(gaps) > 30: + print(f" ... and {len(gaps) - 30} more") + else: + print(f"FIRST15 trainer/energy coverage OK ({len(FIRST15_SETS)} sets)") + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + # Spot-check variant titles on classic sets. + for sid, lid, expect_en in ( + ("PMCG5", "002", "Erika's Oddish"), + ("PMCG4", "017", "Dark Charizard"), + ("PMCG6", "042", "Rocket's Zapdos"), + ): + hit = next( + ( + p + for p in catalog["prints"] + if p.get("set_id") == sid and p.get("local_id") == lid + ), + None, + ) + if hit: + print( + f"{sid}/{lid}: name_en={hit.get('name_en')!r} " + f"source={hit.get('name_en_source')!r}" + ) + if hit.get("name_en") != expect_en: + print(f" WARNING: expected name_en {expect_en!r}") + else: + print(f"WARNING: missing {sid}/{lid}") + + print( + f"wrote {args.out}: touched={updated} added={added} " + f"species_named={species_named} table_named={table_named} " + f"variant_upgraded={variant_upgraded} " + f"prints={len(catalog['prints'])}" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/enrich_unnumbered_promo_images.py b/tools/pokemon_jp/enrich_unnumbered_promo_images.py new file mode 100644 index 0000000..606c830 --- /dev/null +++ b/tools/pokemon_jp/enrich_unnumbered_promo_images.py @@ -0,0 +1,567 @@ +#!/usr/bin/env python3 +"""Fill UnnumberedPromo image_url / name_ja from Bulbapedia card pages. + +Reads tools/pokemon_jp/classic_missing_prints.json rows with set_id=UnnumberedPromo, +resolves each `bulbapedia_page` (with redirects), and prefers Japanese / +Unnumbered Promotional scans from reprint/gallery fields over the English +primary `|image=` (often a Wizards Black Star print). + +If Bulbapedia only hosts an English scan, image_url is left empty (card-back) +rather than storing a misleading EN preview. + +Usage: + python tools/pokemon_jp/enrich_unnumbered_promo_images.py + python tools/pokemon_jp/enrich_unnumbered_promo_images.py --force + python tools/pokemon_jp/enrich_unnumbered_promo_images.py --dry-run + python tools/pokemon_jp/enrich_unnumbered_promo_images.py --limit 20 + +Then: + python tools/pokemon_jp/merge_classic_missing.py +""" + +from __future__ import annotations + +import argparse +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +PRINTS = HERE / "classic_missing_prints.json" +SET_ID = "UnnumberedPromo" +UA = "CCM3-pokemon-jp-etl/1.0 (local; +https://github.com/sebastiandine/Card-Collection-Manager-3)" +API = "https://bulbapedia.bulbagarden.net/w/api.php" + +JNAME_RE = re.compile(r"\|\s*jname\s*=\s*([^\n|]+)", re.IGNORECASE) +# |image= / |image1= / |reprint1= / |caption= / |caption2= / |recaption1= +FIELD_RE = re.compile( + r"\|\s*(image|reprint|caption|recaption)(\d*)\s*=\s*([^\n]+)", + re.IGNORECASE, +) + +SKIP_IMAGE_SUBSTR = ( + "attack.png", + "card_back", + "cardback", + "project_tcg", + "setsymbol", + "rare_", + "energy.png", + "tcg1_", + "tcg2_", + "misprint", +) + +# Filename / caption hints that the scan is the Japanese unnumbered print. +JP_FILENAME_MARKERS = ( + "corocoro", + "whf", + "fanbook", + "unnumbered", + "japanese", + "gb2", + "illustrator", + "battleroad", + "movie", + "parentchild", + "vending", + "asobikata", + "jogress", + "pokedude", + "daisuki", + "specialsheet", + "informationpack", + "howibecame", + "newgarura", + "touchgeneration", + "championleague", + "worldofillusions", + "clashatthesummit", + "blackwhitetour", + "warnerbros", + "nintendo64", + "teamgr", + "imakuni", + "tradeplease", + "hungrysnorlax", + "coolporygon", # often still EN — scored only with caption +) + +# Captions that mark the Unnumbered / JP print on shared EN+JP articles. +JP_CAPTION_MARKERS = ( + "unnumbered promotional", + "unnumbered promo", + "japanese", + "jpexpansion", +) + +# English primary prints we must not prefer when a JP candidate exists. +EN_FILENAME_MARKERS = ( + "wizardspromo", + "baseset", + "neogenesis", + "fossil", + "teamrocket", + "jungle", + "legendarycollection", + "dppromo", + "mysterious treasures", + "mysterioustreasures", + "diamondpearl", + "exdragon", + "exholon", + "exdelta", + "neodiscovery", + "neorevelations", + "neodestiny", + "gymheroes", + "gymchallenge", + "nintendopromo", # often EN Black Star; allow if also JP-captioned +) + + +def api(**params: object) -> dict: + qs = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + req = urllib.request.Request(f"{API}?{qs}", headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=90) as resp: + return json.load(resp) + + +def fetch_wikitext(page: str) -> tuple[str, str] | None: + """Return (resolved_title, wikitext) or None if missing.""" + try: + data = api( + action="parse", + page=page, + prop="wikitext", + format="json", + redirects=1, + ) + except urllib.error.HTTPError: + return None + except urllib.error.URLError: + return None + if "error" in data: + return None + parsed = data.get("parse") or {} + wt = (parsed.get("wikitext") or {}).get("*") + title = parsed.get("title") or page + if not wt: + return None + return title, wt + + +def file_url(filename: str) -> str | None: + fname = filename.strip().replace(" ", "_") + if not fname: + return None + data = api( + action="query", + titles=f"File:{fname}", + prop="imageinfo", + iiprop="url", + format="json", + ) + pages = (data.get("query") or {}).get("pages") or {} + for page in pages.values(): + infos = page.get("imageinfo") or [] + if infos and infos[0].get("url"): + return str(infos[0]["url"]) + return None + + +def normalize_filename(raw: str) -> str | None: + s = raw.strip().split("|", 1)[0].strip() + s = re.sub(r"\[\[(?:File:)?([^\]|]+).*", r"\1", s, flags=re.IGNORECASE) + s = s.strip() + if not s: + return None + low = s.lower().replace(" ", "_") + if any(tok in low for tok in SKIP_IMAGE_SUBSTR): + return None + if not re.search(r"\.(jpe?g|png|gif|webp)$", low): + return None + return s + + +def score_candidate(filename: str, caption: str) -> int: + """Higher is better. Score <= 0 means EN-only / reject for UnnumberedPromo.""" + fl = filename.lower().replace(" ", "").replace("_", "") + cl = caption.lower() + score = 0 + + if any(m in cl for m in JP_CAPTION_MARKERS): + score += 100 + if any(m.replace(" ", "") in fl for m in JP_FILENAME_MARKERS): + score += 50 + if "promo" in fl and not any(m in fl for m in ("wizardspromo", "nintendopromo", "dppromo")): + score += 10 + + en_hit = any(m.replace(" ", "") in fl for m in EN_FILENAME_MARKERS) + if en_hit: + # EN primary unless caption explicitly marks Unnumbered/JP. + if score < 100: + return -100 + score -= 20 + + return score + + +def collect_image_candidates(wikitext: str) -> list[tuple[int, str]]: + """Return (score, filename) for JP-eligible images, best first.""" + # Map field key -> value for pairing imageN with captionN / reprintN with recaptionN. + fields: dict[str, str] = {} + for m in FIELD_RE.finditer(wikitext): + kind = m.group(1).lower() + num = m.group(2) or "" + val = m.group(3).strip() + fields[f"{kind}{num}"] = val + + candidates: list[tuple[int, str]] = [] + seen: set[str] = set() + + def add(fname_raw: str, caption: str) -> None: + fname = normalize_filename(fname_raw) + if not fname: + return + key = fname.lower().replace(" ", "_") + if key in seen: + return + score = score_candidate(fname, caption) + if score <= 0: + return + seen.add(key) + candidates.append((score, fname)) + + # Primary image + caption (usually EN — only kept if JP-scored). + if "image" in fields: + add(fields["image"], fields.get("caption", "")) + + # reprintN + recaptionN (common home of Unnumbered JP scans). + for key, val in list(fields.items()): + m = re.fullmatch(r"reprint(\d+)", key) + if not m: + continue + n = m.group(1) + add(val, fields.get(f"recaption{n}", "") or fields.get(f"caption{n}", "")) + + # Gallery imageN + captionN. + for key, val in list(fields.items()): + m = re.fullmatch(r"image(\d+)", key) + if not m: + continue + n = m.group(1) + add(val, fields.get(f"caption{n}", "") or fields.get(f"recaption{n}", "")) + + candidates.sort(key=lambda t: (-t[0], t[1].lower())) + return candidates + + +def normalize_token_blob(s: str) -> str: + """Lowercase alnum-only blob for substring affinity checks.""" + return re.sub(r"[^a-z0-9]+", "", s.lower()) + + +def identity_tokens(print_row: dict) -> list[str]: + """Significant tokens from this print's promo identity (set / page).""" + raw_bits: list[str] = [] + for key in ("tcg_set", "bulbapedia_page", "name_en"): + val = str(print_row.get(key) or "").strip() + if val: + raw_bits.append(val) + # Prefer longer set-like phrases first. + tokens: list[str] = [] + for bit in raw_bits: + # Drop trailing extras like "(Jumbo)". + bit = re.sub(r"\s*\([^)]*(?:Jumbo|Mini|Silver|Gold)[^)]*\)\s*", " ", bit) + # Pull parenthetical set qualifier: "Mewtwo (WHF Special Sheet promo)". + m = re.search(r"\(([^)]+)\)", bit) + if m: + inner = m.group(1) + inner = re.sub(r"\bpromo\b", "", inner, flags=re.I).strip() + if inner: + tokens.append(inner) + tokens.append(bit) + # Significant wordy tokens (>=3 chars after normalize), longest first. + out: list[str] = [] + seen: set[str] = set() + for t in tokens: + norm = normalize_token_blob(t) + if len(norm) < 4: + continue + if norm in seen: + continue + # Skip generic card-name-only blobs when we have set context. + seen.add(norm) + out.append(norm) + out.sort(key=len, reverse=True) + return out + + +def has_print_affinity( + print_row: dict, + requested_page: str, + resolved_title: str, + filename: str, + caption: str = "", +) -> bool: + """True if this JP candidate belongs to this print, not a borrowed promo.""" + tokens = identity_tokens(print_row) + token_set = set(tokens) + hay = normalize_token_blob(filename + " " + caption + " " + resolved_title) + fl = normalize_token_blob(filename) + req = normalize_token_blob(requested_page) + resolved = normalize_token_blob(resolved_title) + + # Filename names a specific JP promo family this print is not part of → reject. + foreign_markers = ( + "whf", + "corocoro", + "fanbook", + "gb2", + "specialsheet", + "songbest", + "battleroad", + "teamgr", + "illustrator", + "asobikata", + "vending", + "movie", + ) + for marker in foreign_markers: + if marker in fl and not any(marker in tok for tok in token_set): + # e.g. WHF file on a Wizards Promo / Song Best Collection row. + return False + + # Resolved title still matches what we asked for (allow mild redirect rename). + if req and (req in resolved or resolved in req): + # Still require filename not foreign (handled above); OK. + if any(m in fl for m in foreign_markers) or "unnumbered" in hay or any( + len(tok) >= 5 and tok in fl for tok in token_set + ): + return True + # Requested page matched but image is generic EN — leave to score_candidate. + if any(len(tok) >= 5 and tok in hay for tok in token_set): + return True + + tcg_set = str(print_row.get("tcg_set") or "").strip() + tcg_set_norm = normalize_token_blob(tcg_set) + # Wizards Promo rows may use the Wizards article, but only with a + # non-foreign JP file (foreign_markers already rejected WHF/etc.). + if tcg_set_norm.startswith("wizardspromo") and "wizardspromo" in resolved: + if "wizardspromo" in fl or ( + any(m in caption.lower() for m in JP_CAPTION_MARKERS) + and not any(m in fl for m in foreign_markers) + ): + return True + return False + + # Reject borrowing from a generic Wizards Promo dump unless this print is that set. + if "wizardspromo" in resolved and not tcg_set_norm.startswith("wizardspromo"): + for tok in tokens: + if len(tok) >= 5 and tok in hay and "wizardspromo" not in tok: + species = normalize_token_blob(str(print_row.get("tcg_name") or "")) + if species and tok == species: + continue + return True + return False + + for tok in tokens: + if len(tok) >= 5 and tok in hay: + species = normalize_token_blob(str(print_row.get("tcg_name") or "")) + if species and tok == species: + continue + return True + if len(tok) >= 3 and tok in ("whf", "gb2") and tok in hay: + return True + + for tok in tokens: + if len(tok) >= 5 and tok in fl: + species = normalize_token_blob(str(print_row.get("tcg_name") or "")) + if species and tok == species: + continue + return True + + return False + + +def pick_image_filename_for_print( + print_row: dict, + requested_page: str, + resolved_title: str, + wikitext: str, +) -> str | None: + """Best JP scan that also has affinity with this print's promo identity.""" + fields: dict[str, str] = {} + for m in FIELD_RE.finditer(wikitext): + fields[f"{m.group(1).lower()}{m.group(2) or ''}"] = m.group(3).strip() + + def caption_for(fname: str) -> str: + target = fname.lower().replace(" ", "_") + for key, val in fields.items(): + nf = normalize_filename(val) + if not nf or nf.lower().replace(" ", "_") != target: + continue + if key == "image": + return fields.get("caption", "") + m = re.fullmatch(r"(reprint|image)(\d+)", key) + if not m: + continue + n = m.group(2) + if m.group(1) == "reprint": + return fields.get(f"recaption{n}", "") or fields.get(f"caption{n}", "") + return fields.get(f"caption{n}", "") or fields.get(f"recaption{n}", "") + return "" + + for _score, fname in collect_image_candidates(wikitext): + if has_print_affinity( + print_row, requested_page, resolved_title, fname, caption_for(fname) + ): + return fname + return None + + +def pick_jname(wikitext: str) -> str: + m = JNAME_RE.search(wikitext) + if not m: + return "" + return m.group(1).strip() + + +def candidate_pages(print_row: dict) -> list[str]: + """Qualified Bulbapedia titles only — never bare species (avoids shared dumps).""" + out: list[str] = [] + page = str(print_row.get("bulbapedia_page") or "").strip() + if page: + out.append(page) + tcg_set = str(print_row.get("tcg_set") or "").strip() + tcg_name = str(print_row.get("tcg_name") or "").strip() + tcg_num = str(print_row.get("tcg_num") or "").strip() + if tcg_name and tcg_set: + if not tcg_num or tcg_num.lower() == "promo": + out.append(f"{tcg_name} ({tcg_set} promo)") + else: + out.append(f"{tcg_name} ({tcg_set} {tcg_num})") + out.append(f"{tcg_name} ({tcg_set} promo)") + # Full qualified English title from harvest (may include Jumbo markers). + name_en = str(print_row.get("name_en") or "").strip() + if name_en and "(" in name_en: + # Strip only trailing variant markers, keep set qualifier. + cleaned = re.sub( + r"\s*\((?:Jumbo|Mini|Silver|Gold|Silver w/Stamp)[^)]*\)\s*$", + "", + name_en, + flags=re.I, + ).strip() + if cleaned: + out.append(cleaned) + out.append(name_en) + seen: set[str] = set() + uniq: list[str] = [] + for p in out: + if p and p not in seen: + seen.add(p) + uniq.append(p) + return uniq + + +def enrich_print(print_row: dict, sleep_s: float) -> bool: + """Mutate print_row with JP image_url / name_ja. Return True if image filled.""" + already = str(print_row.get("image_url") or "").strip() + if already: + return False + + for page in candidate_pages(print_row): + time.sleep(sleep_s) + resolved = fetch_wikitext(page) + if not resolved: + continue + title, wt = resolved + if not str(print_row.get("name_ja") or "").strip(): + jname = pick_jname(wt) + if jname: + print_row["name_ja"] = jname + fname = pick_image_filename_for_print(print_row, page, title, wt) + if not fname: + continue + time.sleep(sleep_s) + url = file_url(fname) + if url: + print_row["image_url"] = url + return True + return False + + +def log(msg: str) -> None: + try: + print(msg) + except UnicodeEncodeError: + print(msg.encode("ascii", errors="replace").decode("ascii")) + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--limit", type=int, default=0, help="Max UnnumberedPromo rows") + ap.add_argument("--sleep", type=float, default=0.35, help="Seconds between API calls") + ap.add_argument("--force", action="store_true", help="Overwrite existing image_url") + ap.add_argument( + "--save-every", + type=int, + default=25, + help="Persist classic_missing_prints.json every N updates", + ) + args = ap.parse_args() + + all_prints: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8")) + targets = [p for p in all_prints if str(p.get("set_id")) == SET_ID] + if args.limit > 0: + targets = targets[: args.limit] + + def persist() -> None: + if args.dry_run: + return + PRINTS.write_text( + json.dumps(all_prints, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + filled = 0 + missed = 0 + updates_since_save = 0 + for i, p in enumerate(targets, start=1): + lid = p.get("local_id") + name = p.get("name_en") + if args.force: + p.pop("image_url", None) + if str(p.get("image_url") or "").strip(): + log(f"[{i}/{len(targets)}] skip {lid} {name} (already has image)") + continue + ok = enrich_print(p, sleep_s=args.sleep) + if ok: + filled += 1 + updates_since_save += 1 + log(f"[{i}/{len(targets)}] OK {lid} {name} -> {p.get('image_url')}") + else: + missed += 1 + # Ensure stale EN URLs do not linger after --force. + p.pop("image_url", None) + log(f"[{i}/{len(targets)}] MISS {lid} {name}") + if updates_since_save >= args.save_every: + persist() + updates_since_save = 0 + log(f" checkpoint wrote {PRINTS}") + + log(f"filled={filled} missed={missed} total={len(targets)}") + if args.dry_run: + log(f"dry-run: not writing {PRINTS}") + return + persist() + log(f"wrote {PRINTS}") + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/fetch_classic_gym_images.py b/tools/pokemon_jp/fetch_classic_gym_images.py new file mode 100644 index 0000000..23d26ca --- /dev/null +++ b/tools/pokemon_jp/fetch_classic_gym_images.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Download and convert bundled classic Japanese gym-deck scans. + +Writes JPEGs under ui_wx/assets/pokemon_jp_classic//.jpg. +Uses dwebp + cjpeg from the local MSYS2 toolchain so we do not need Pillow. +""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT_DIR = ROOT / "ui_wx" / "assets" / "pokemon_jp_classic" + +# Curated direct CDN/file URLs for printing-accurate deck scans. +SOURCES: dict[tuple[str, str], str] = { + # TCGCollector static CDN URL for Erika (City Gym Decks No. 061). + ("TamamushiCG", "016"): ( + "https://static.tcgcollector.com/content/images/9d/33/c6/" + "9d33c6ffe701da03266dd5a65c6ee9537c7043b63b880cf3889f644e1c66aa6f.webp" + ), +} + + +def tool(name: str) -> str: + path = shutil.which(name) + if path is None: + raise SystemExit(f"required tool not found on PATH: {name}") + return path + + +def download(url: str, dest: Path) -> None: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=60) as resp: + dest.write_bytes(resp.read()) + + +def convert_to_jpeg(src: Path, dest: Path) -> None: + suffix = src.suffix.lower() + if suffix in {".jpg", ".jpeg"}: + shutil.copyfile(src, dest) + return + if suffix == ".webp": + dwebp = tool("dwebp") + cjpeg = tool("cjpeg") + with tempfile.NamedTemporaryFile(suffix=".ppm", delete=False) as tmp: + ppm = Path(tmp.name) + try: + subprocess.run([dwebp, str(src), "-ppm", "-o", str(ppm)], check=True) + with open(dest, "wb") as out: + subprocess.run([cjpeg, "-quality", "92", str(ppm)], check=True, stdout=out) + finally: + ppm.unlink(missing_ok=True) + return + raise SystemExit(f"unsupported source format: {src}") + + +def main() -> None: + for (set_id, local_id), url in SOURCES.items(): + out_dir = OUT_DIR / set_id + out_dir.mkdir(parents=True, exist_ok=True) + target = out_dir / f"{local_id}.jpg" + with tempfile.TemporaryDirectory() as td: + src = Path(td) / Path(url).name + print(f"download {set_id}/{local_id} <- {url}") + download(url, src) + convert_to_jpeg(src, target) + print(f"wrote {target}") + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/fetch_serebii_set_names.py b/tools/pokemon_jp/fetch_serebii_set_names.py new file mode 100644 index 0000000..0a7f646 --- /dev/null +++ b/tools/pokemon_jp/fetch_serebii_set_names.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Build English set names for Japanese Pokémon catalog from Serebii + TCGdex.""" + +from __future__ import annotations + +import json +import re +import urllib.request +from html import unescape +from pathlib import Path + +UA = "CardCollectionManager3-ETL/0.1" +OUT = Path("ui_wx/assets/pokemon_jp_en_catalog.json") +SEREBII = "https://www.serebii.net/card/japanese.shtml" +JA_OVERRIDES = {"SV4a": "シャイニートレジャーex"} + + +def get_bytes(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=60) as resp: + return resp.read() + + +def contains_cjk(s: str) -> bool: + return any(ord(c) >= 0x80 for c in s) + + +def parse_serebii_ja_en(html: str) -> dict[str, str]: + """Extract Japanese -> English set name pairs from Serebii japanese.shtml.""" + ja_en: dict[str, str] = {} + + # Common patterns on the page: + # English Name
Japanese + # English Name (Japanese) + # English ... Japanese in nearby cell + for m in re.finditer( + r">([A-Za-z0-9][^<]{1,70}?)\s*\s*" + r"([^<]{2,50}?)<", + html, + flags=re.IGNORECASE, + ): + en = unescape(re.sub(r"\s+", " ", m.group(1))).strip() + ja = unescape(re.sub(r"\s+", " ", m.group(2))).strip() + if contains_cjk(ja) and not contains_cjk(en) and len(en) > 1: + ja_en.setdefault(ja, en) + + for m in re.finditer( + r">([A-Za-z0-9][^<]{1,70}?)\s*\(([^)]{2,50})\)<", + html, + ): + en = unescape(re.sub(r"\s+", " ", m.group(1))).strip() + ja = unescape(re.sub(r"\s+", " ", m.group(2))).strip() + if contains_cjk(ja) and not contains_cjk(en) and len(en) > 1: + ja_en.setdefault(ja, en) + + return ja_en + + +def main() -> None: + sets = [ + s + for s in json.loads(get_bytes("https://api.tcgdex.net/v2/ja/sets")) + if not str(s.get("id", "")).startswith("CS") + ] + print("tcgdex sets", len(sets)) + + html = get_bytes(SEREBII).decode("utf-8", "replace") + Path("tools/pokemon_jp/_serebii_japanese.html").write_text(html, encoding="utf-8") + print("serebii bytes", len(html)) + + ja_en = parse_serebii_ja_en(html) + print("ja->en pairs", len(ja_en)) + for ja, en in list(ja_en.items())[:12]: + print(f" {en!r} <- {ja!r}") + + prints = [] + if OUT.exists(): + try: + prints = json.loads(OUT.read_text(encoding="utf-8")).get("prints", []) + except Exception: + pass + + catalog: dict[str, dict] = {} + matched = 0 + for entry in sets: + sid = entry["id"] + name_ja = JA_OVERRIDES.get(sid, entry.get("name", "")) + name_en = ja_en.get(name_ja, "") + if not name_en: + for ja, en in ja_en.items(): + if ja == name_ja or ja in name_ja or name_ja in ja: + name_en = en + break + if name_en: + matched += 1 + else: + name_en = sid + catalog[sid] = { + "name_en": name_en, + "name_ja": name_ja, + "releaseDate": "", + } + + print(f"matched {matched}/{len(sets)}; id fallback {len(sets) - matched}") + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text( + json.dumps({"sets": catalog, "prints": prints}, ensure_ascii=False, indent=2) + + "\n", + encoding="utf-8", + ) + print("wrote", OUT) + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/harvest_unnumbered_promos.py b/tools/pokemon_jp/harvest_unnumbered_promos.py new file mode 100644 index 0000000..d38e635 --- /dev/null +++ b/tools/pokemon_jp/harvest_unnumbered_promos.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Harvest Bulbapedia Unnumbered Promotional cards into classic_missing seed JSON. + +Fetches: + - Unnumbered_Promotional_cards_(TCG)/1996-2005 + - Yearly sections on Unnumbered_Promotional_cards_(TCG) (2006+) + +Writes/updates: + - tools/pokemon_jp/classic_missing_sets.json (adds UnnumberedPromo) + - tools/pokemon_jp/classic_missing_prints.json (replaces UnnumberedPromo prints) + +Synthetic local_ids are sequential 001… (cards are unnumbered in print). +Each print carries a qualified English title when the setlist row has a +{{TCG ID|Set|Name|num}} (e.g. "Mewtwo (CoroCoro promo)") plus a +`bulbapedia_page` hint for enrich_unnumbered_promo_images.py. + +Run after harvest: + python tools/pokemon_jp/enrich_unnumbered_promo_images.py + python tools/pokemon_jp/merge_classic_missing.py +""" + +from __future__ import annotations + +import json +import re +import urllib.parse +import urllib.request +from pathlib import Path + +HERE = Path(__file__).resolve().parent +SETS = HERE / "classic_missing_sets.json" +PRINTS = HERE / "classic_missing_prints.json" + +SET_ID = "UnnumberedPromo" +SET_META = { + "name_en": "Unnumbered Promotional cards", + "name_ja": "番号なしプロモーションカード", + "releaseDate": "1997/03/06", +} + +UA = "CCM3-pokemon-jp-etl/1.0 (local; +https://github.com/sebastiandine/Card-Collection-Manager-3)" + +# {{TCG ID|Set|Name|num}} — name may contain δ / & etc. +TCG_ID_FULL_RE = re.compile( + r"\{\{TCG ID\|([^}|]+)\|([^}|]+)(?:\|([^}|]*))?\}\}", re.IGNORECASE +) +TCG_RE = re.compile(r"\{\{TCG\|([^}|]+)(?:\|[^}]*)?\}\}", re.IGNORECASE) +OBP_RE = re.compile(r"\{\{OBP\|([^}|]+)(?:\|[^}]*)?\}\}", re.IGNORECASE) +SMALL_TAG_RE = re.compile( + r"\s*'''?\s*\[([^\]]+)\]\s*'''?\s*", re.IGNORECASE +) +ITALIC_NOTE_RE = re.compile(r"\(''([^']+)''\)") +HTML_TAG_RE = re.compile(r"<[^>]+>") +TEMPLATE_RE = re.compile(r"\{\{[^{}]*\}\}") + + +def fetch_wikitext(page: str) -> str: + qs = urllib.parse.urlencode( + { + "action": "parse", + "page": page, + "prop": "wikitext", + "format": "json", + } + ) + url = f"https://bulbapedia.bulbagarden.net/w/api.php?{qs}" + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=90) as resp: + data = json.load(resp) + return data["parse"]["wikitext"]["*"] + + +def decode_wiki_text(s: str) -> str: + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("'", "'") + .strip() + ) + + +def bulbapedia_page_from_tcg_id(set_name: str, card_name: str, num: str) -> str: + """Best-effort Bulbapedia article title for a TCG ID triple.""" + set_name = decode_wiki_text(set_name) + card_name = decode_wiki_text(card_name) + num = decode_wiki_text(num or "").strip() + if not num or num.lower() == "promo": + return f"{card_name} ({set_name} promo)" + return f"{card_name} ({set_name} {num})" + + +def qualified_name_en(card_name: str, set_name: str, num: str, extras: list[str]) -> str: + """Distinct English title: Name (Set promo) plus optional [Jumbo]/ markers.""" + card_name = decode_wiki_text(card_name) + set_name = decode_wiki_text(set_name) + num = decode_wiki_text(num or "").strip() + if set_name: + if not num or num.lower() == "promo": + base = f"{card_name} ({set_name} promo)" + else: + base = f"{card_name} ({set_name} {num})" + else: + base = card_name + # Avoid duplicating qualifier already present in extras. + remaining = [ + e + for e in extras + if e.lower() not in base.lower() and e.lower() not in {"promo"} + ] + if remaining: + return f"{base} ({'; '.join(remaining)})" + return base + + +def collect_extras(raw: str) -> list[str]: + extras: list[str] = [] + for m in SMALL_TAG_RE.finditer(raw): + extras.append(m.group(1).strip()) + for m in ITALIC_NOTE_RE.finditer(raw): + extras.append(m.group(1).strip()) + if "Jumbo" in raw and not any("jumbo" in e.lower() for e in extras): + extras.append("Jumbo") + if "Mini" in raw and not any("mini" in e.lower() for e in extras): + extras.append("Mini") + return extras + + +def extract_entry(field: str) -> dict | None: + """Parse one Setlist/nmentry name field into print metadata.""" + raw = field.strip() + extras = collect_extras(raw) + + tcg = TCG_ID_FULL_RE.search(raw) + if tcg: + set_name = tcg.group(1).strip() + card_name = tcg.group(2).strip() + num = (tcg.group(3) or "").strip() + name_en = qualified_name_en(card_name, set_name, num, extras) + page = bulbapedia_page_from_tcg_id(set_name, card_name, num) + return { + "name_en": name_en, + "bulbapedia_page": page, + "tcg_set": decode_wiki_text(set_name), + "tcg_name": decode_wiki_text(card_name), + "tcg_num": decode_wiki_text(num) if num else "promo", + } + + name = None + for rx in (TCG_RE, OBP_RE): + m = rx.search(raw) + if m: + name = decode_wiki_text(m.group(1)) + break + if not name: + cleaned = TEMPLATE_RE.sub("", raw) + cleaned = HTML_TAG_RE.sub("", cleaned) + cleaned = cleaned.split("|", 1)[0].strip() + cleaned = re.sub(r"\[\[([^|\]]+)(?:\|[^\]]+)?\]\]", r"\1", cleaned) + name = decode_wiki_text(cleaned.strip(" '\"")) + if not name: + return None + + if extras: + name = f"{name} ({'; '.join(extras)})" + return {"name_en": name, "bulbapedia_page": name} + + +def harvest_entries(wikitext: str) -> list[dict]: + entries: list[dict] = [] + for m in re.finditer(r"\{\{Setlist/nmentry\|None\|", wikitext): + start = m.end() + depth = 0 + i = start + while i < len(wikitext): + if wikitext.startswith("{{", i): + depth += 1 + i += 2 + continue + if wikitext.startswith("}}", i): + depth = max(0, depth - 1) + i += 2 + continue + if wikitext[i] == "|" and depth == 0: + break + i += 1 + parsed = extract_entry(wikitext[start:i]) + if parsed: + entries.append(parsed) + return entries + + +def main() -> None: + pages = [ + "Unnumbered_Promotional_cards_(TCG)/1996-2005", + "Unnumbered_Promotional_cards_(TCG)", + ] + all_entries: list[dict] = [] + for page in pages: + wt = fetch_wikitext(page) + got = harvest_entries(wt) + print(f"{page}: {len(got)} setlist rows") + all_entries.extend(got) + + prints: list[dict] = [] + for i, entry in enumerate(all_entries, start=1): + row = { + "set_id": SET_ID, + "local_id": f"{i:03d}", + "name_en": entry["name_en"], + "name_ja": "", + "name_en_source": "manual", + "bulbapedia_page": entry.get("bulbapedia_page") or entry["name_en"], + } + for key in ("tcg_set", "tcg_name", "tcg_num"): + if entry.get(key): + row[key] = entry[key] + prints.append(row) + + sets_obj: dict = json.loads(SETS.read_text(encoding="utf-8")) + sets_obj[SET_ID] = SET_META + SETS.write_text( + json.dumps(sets_obj, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + existing: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8")) + kept = [p for p in existing if str(p.get("set_id")) != SET_ID] + kept.extend(prints) + PRINTS.write_text( + json.dumps(kept, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print( + f"wrote {SET_ID}: {len(prints)} prints " + f"(classic_missing_prints total={len(kept)})" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/merge_classic_missing.py b/tools/pokemon_jp/merge_classic_missing.py new file mode 100644 index 0000000..2d1fd74 --- /dev/null +++ b/tools/pokemon_jp/merge_classic_missing.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Merge classic TCGdex-missing JA products into pokemon_jp_en_catalog.json. + +Reads: + tools/pokemon_jp/classic_missing_sets.json + tools/pokemon_jp/classic_missing_prints.json + tools/pokemon_jp/set_en_names.json (updated with EN display names) + +Writes set metadata + prints into ui_wx/assets/pokemon_jp_en_catalog.json +without dropping existing TCGdex-backed entries. Replaces prior prints for +the same (set_id, local_id) keys from classic_missing_prints.json. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +SETS = HERE / "classic_missing_sets.json" +PRINTS = HERE / "classic_missing_prints.json" +EN_MAP = HERE / "set_en_names.json" +OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json" + +# Classic gym-deck / sheet products use synthetic set ids (not on TCGdex). +CLASSIC_SET_IDS = frozenset( + { + "UnnumberedPromo", + "ExpSheet1", + "ExpSheet2", + "ExpSheet3", + "NiviCG", + "HanadaCG", + "KuchibaCG", + "TamamushiCG", + "YamabukiCG", + "GurenTG", + "SouthernIslands", + } +) + +GYM_DECK_SET_IDS = frozenset( + {"NiviCG", "HanadaCG", "KuchibaCG", "TamamushiCG", "YamabukiCG", "GurenTG"} +) + + +def strip_gym_deck_donor_ids(classic_prints: list[dict]) -> int: + """Remove PMCG donor ids; gym-deck exclusives need printing-accurate art.""" + stripped = 0 + for p in classic_prints: + sid = str(p.get("set_id") or "") + if sid not in GYM_DECK_SET_IDS: + continue + if p.get("image_url"): + p.pop("tcgplayer_id", None) + continue + if p.pop("tcgplayer_id", None) is not None: + stripped += 1 + return stripped + + +def main() -> None: + missing_sets: dict[str, dict] = json.loads(SETS.read_text(encoding="utf-8")) + missing_prints: list[dict] = json.loads(PRINTS.read_text(encoding="utf-8")) + en_map: dict[str, str] = json.loads(EN_MAP.read_text(encoding="utf-8")) + + catalog: dict = {"sets": {}, "prints": []} + if OUT.exists(): + catalog = json.loads(OUT.read_text(encoding="utf-8")) + + sets_obj: dict = catalog.setdefault("sets", {}) + for sid, meta in missing_sets.items(): + name_en = str(meta.get("name_en") or "").strip() or sid + en_map[sid] = name_en + sets_obj[sid] = { + "name_en": name_en, + "name_ja": str(meta.get("name_ja") or ""), + "releaseDate": str(meta.get("releaseDate") or ""), + } + + EN_MAP.write_text( + json.dumps(dict(sorted(en_map.items())), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + classic_ids = {s for s in missing_sets} + classic_keys = { + (str(p.get("set_id")), str(p.get("local_id"))) for p in missing_prints + } + existing: list[dict] = catalog.get("prints") or [] + kept = [ + p + for p in existing + if (str(p.get("set_id")), str(p.get("local_id"))) not in classic_keys + or str(p.get("set_id")) not in classic_ids + ] + # Drop all prints for classic set ids, then append the curated list. + kept = [p for p in kept if str(p.get("set_id")) not in classic_ids] + stripped = strip_gym_deck_donor_ids(missing_prints) + kept.extend(missing_prints) + catalog["prints"] = kept + + OUT.write_text( + json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print( + f"merged {len(missing_sets)} classic sets and {len(missing_prints)} prints " + f"into {OUT} (total prints={len(kept)}, gym donor ids stripped={stripped})" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/merge_set_en_catalog.py b/tools/pokemon_jp/merge_set_en_catalog.py new file mode 100644 index 0000000..da788a7 --- /dev/null +++ b/tools/pokemon_jp/merge_set_en_catalog.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Merge curated English set names into pokemon_jp_en_catalog.json. + +Reads tools/pokemon_jp/_tcgdex_sets.json (TCGdex JA list snapshot) and +tools/pokemon_jp/set_en_names.json (curated id -> English display name). +Also preserves / refreshes classic TCGdex-missing products from +classic_missing_sets.json (UnnumberedPromo, City Gym decks, Expansion Sheets, +Southern Islands). +Never writes Japanese into name_en. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +SETS_SNAP = HERE / "_tcgdex_sets.json" +EN_MAP = HERE / "set_en_names.json" +CLASSIC = HERE / "classic_missing_sets.json" +OUT = ROOT / "ui_wx" / "assets" / "pokemon_jp_en_catalog.json" +JA_OVERRIDES = {"SV4a": "シャイニートレジャーex"} + + +def main() -> None: + sets = json.loads(SETS_SNAP.read_text(encoding="utf-8")) + en_map: dict[str, str] = json.loads(EN_MAP.read_text(encoding="utf-8")) + classic: dict[str, dict] = {} + if CLASSIC.exists(): + classic = json.loads(CLASSIC.read_text(encoding="utf-8")) + + prints = [] + if OUT.exists(): + try: + prints = json.loads(OUT.read_text(encoding="utf-8")).get("prints", []) + except Exception: + pass + + catalog: dict[str, dict] = {} + matched = 0 + for entry in sets: + sid = entry["id"] + if sid.startswith("CS"): + continue + name_ja = JA_OVERRIDES.get(sid, entry.get("name", "")) + name_en = en_map.get(sid, "").strip() + if name_en: + matched += 1 + else: + name_en = sid + catalog[sid] = { + "name_en": name_en, + "name_ja": name_ja, + "releaseDate": "", + } + + for sid, meta in classic.items(): + name_en = str(meta.get("name_en") or en_map.get(sid) or sid).strip() + catalog[sid] = { + "name_en": name_en, + "name_ja": str(meta.get("name_ja") or ""), + "releaseDate": str(meta.get("releaseDate") or ""), + } + if name_en and name_en != sid: + matched += 1 + + OUT.write_text( + json.dumps({"sets": catalog, "prints": prints}, ensure_ascii=False, indent=2) + + "\n", + encoding="utf-8", + ) + print(f"wrote {OUT}: {matched}/{len(catalog)} curated EN names") + + +if __name__ == "__main__": + main() diff --git a/tools/pokemon_jp/non_pokemon_en_by_ja.json b/tools/pokemon_jp/non_pokemon_en_by_ja.json new file mode 100644 index 0000000..bdea755 --- /dev/null +++ b/tools/pokemon_jp/non_pokemon_en_by_ja.json @@ -0,0 +1,263 @@ +{ + "Bugsyのテクニカルマシン01": "Bugsy's Technical Machine 01", + "Bugsyのテクニカルマシン02": "Bugsy's Technical Machine 02", + "Clair's Technical Machine 01": "Clair's Technical Machine 01", + "Clair's Technical Machine 02": "Clair's Technical Machine 02", + "Max Revive": "Max Revive", + "Moomooミルク": "Moomoo Milk", + "Morty's Technical Machine 01": "Morty's Technical Machine 01", + "Morty's Technical Machine 02": "Morty's Technical Machine 02", + "ecogym": "Eco Gym", + "exp。共有": "EXP. Share", + "いいきずぐすり": "Super Potion", + "お上品攻撃": "Refined Attack", + "きずぐすり": "Potion", + "きずぐすり配合エネルギー": "Potion Energy", + "くすぐりマシーン": "Tickling Machine", + "げんきのかけら": "Revive", + "せまいジム": "Narrow Gym", + "たたきつけろ!挑戦状": "Challenge!", + "なかよしポフィン": "Buddy-Buddy Poffin", + "なにかの化石": "Mysterious Fossil", + "なんでもなおし": "Full Heal", + "なんでもなおし配合エネルギー": "Full Heal Energy", + "にせオーキドの逆襲": "Impostor Professor Oak's Revenge", + "ねむれ!ねむれ!": "Sleep! Sleep!", + "ふうせん": "Air Balloon", + "ふしぎなアメ": "Rare Candy", + "まきちらせ!ベトベトガス": "Goop Gas Attack", + "アカマツ": "Carmine", + "アプリコーンの森": "Apricorn Forest", + "アーケードゲーム": "Arcade Game", + "ウィルのテクニカルマシン01": "Will's Technical Machine 01", + "ウィルのテクニカルマシン02": "Will's Technical Machine 02", + "ウォーターキューブ01": "Water Cube 01", + "エネルギーの流れ": "Energy Flow", + "エネルギーをリサイクルします": "Recycle Energy", + "エネルギーを高めます": "Boost Energy", + "エネルギーアーク": "Energy Ark", + "エネルギーサーキュレート": "Energy Circulate", + "エネルギースイッチ": "Energy Switch", + "エネルギースタジアム": "Energy Stadium", + "エネルギー・リムーブ": "Energy Removal", + "エネルギー回収": "Energy Retrieval", + "エネルギー回復": "Energy Restore", + "エネルギー増幅器": "Energy Amplifier", + "エネルギー検索": "Energy Search", + "エネルギー転送": "Energy Transfer", + "エネルギー除去2": "Energy Removal 2", + "エネルギー電荷": "Energy Charge", + "エリカ": "Erika", + "エリカのお付き": "Erika's Maids", + "エリカの親切": "Erika's Kindness", + "エリカの香水": "Erika's Perfume", + "エルム教授": "Professor Elm", + "エルム教授のトレーニング方法": "Professor Elm's Training Method", + "オーキドはかせ": "Professor Oak", + "オーク教授の研究": "Professor Oak's Research", + "カウンターゲイン": "Counter Gain", + "カスミ": "Misty", + "カスミのいかり": "Misty's Wrath", + "カスミのなみだ": "Misty's Tears", + "カスミのわがまま": "Misty's Wish", + "カスミの勝負": "Misty's Duel", + "カツラ": "Blaine", + "カツラのギャンブル": "Blaine's Gamble", + "カツラのクイズ その3": "Blaine's Quiz #3", + "カツラの奥の手": "Blaine's Last Resort", + "カレンのテクニカルマシン01": "Karen's Technical Machine 01", + "カレンのテクニカルマシン02": "Karen's Technical Machine 02", + "カードフリップゲーム": "Card Flip Game", + "キョウ": "Koga", + "キョウ秘伝, 変わり身の術": "Koga's Ninja Trick", + "ギャンブラー": "Gambler", + "クイックボール": "Quick Ball", + "クチバシティジム": "Vermilion City Gym", + "クリスタルエネルギー": "Crystal Energy", + "グッドマナー": "Good Manners", + "グラスキューブ01": "Grass Cube 01", + "グレンタウンジム": "Cinnabar Island Gym", + "ゴールドベリー": "Gold Berry", + "サイキックキューブ01": "Psychic Cube 01", + "サカキ": "Giovanni", + "サカキの切り札": "Giovanni's Last Resort", + "ジャグラー": "Juggler", + "ジャスミンのテクニカルマシン01": "Jasmine's Technical Machine 01", + "ジャスミンのテクニカルマシン02": "Jasmine's Technical Machine 02", + "ジャニーンのテクニカルマシン01": "Janine's Technical Machine 01", + "ジャニーンのテクニカルマシン02": "Janine's Technical Machine 02", + "スイッチ": "Switch", + "スイレンのお世話": "Lana's Aid", + "スパイ作戦": "Spy Work", + "スプラウトタワー": "Sprout Tower", + "スーパーエネルギー検索": "Super Energy Retrieval", + "スーパーエネルギー除去2": "Super Energy Removal 2", + "スーパースクープアップ": "Super Scoop Up", + "スーパーボール": "Great Ball", + "スーパーロッド": "Super Rod", + "セキチクシティジム": "Fuchsia City Gym", + "タイムカプセル": "Time Capsule", + "タイムシャード": "Time Shard", + "タケシ": "Brock", + "タケシの保護": "Brock's Protection", + "タケシの育て方": "Brock's Training Method", + "タマムシシティジム": "Celadon City Gym", + "ダウジングマシーン": "Item Finder", + "ダブル無色エネルギー": "Double Colorless Energy", + "チャックのテクニカルマシン01": "Chuck's Technical Machine 01", + "チャックのテクニカルマシン02": "Chuck's Technical Machine 02", + "チャリティ": "Charity", + "チームロケットの邪悪な行為": "Team Rocket's Evil Deeds", + "テクノレーダー": "Technical Machine: Evolution", + "ディフェンダー": "Defender", + "デュアルボール": "Dual Ball", + "トウコ": "Hilda", + "トキワシティジム": "Viridian City Gym", + "トラッシュ交換": "Trash Exchange", + "ナツメ": "Sabrina", + "ナツメのESP": "Sabrina's ESP", + "ナツメのサイキックコントロール": "Sabrina's Psychic Control", + "ナツメの眼": "Sabrina's Gaze", + "ナンジャモ": "Iono", + "ニビシティジム": "Pewter City Gym", + "ネストボール": "Nest Ball", + "ハイパーデボルブスプレー": "Hyper Devolution Spray", + "ハイパーボール": "Ultra Ball", + "ハナダシティジム": "Cerulean City Gym", + "バトルVIPパス": "Battle VIP Pass", + "バトル場は穴だらけ!": "The Field is Full of Holes!", + "バルーンベリー": "Balloon Berry", + "パソコン通信": "Computer Search", + "パワープロテイン": "Power Protein", + "ヒーリングフィールド": "Healing Field", + "ビルからのメール": "Mail from Bill", + "ビルのテレポーター": "Bill's Teleporter", + "ビルのメンテナンス": "Bill's Maintenance", + "ピッピ人形": "Clefairy Doll", + "ファイアキューブ01": "Fire Cube 01", + "フォーカスバンド": "Focus Band", + "フォークナーのテクニカルマシン01": "Falkner's Technical Machine 01", + "フォークナーのテクニカルマシン02": "Falkner's Technical Machine 02", + "フジろうじん": "Mr. Fuji", + "フルヒール": "Full Heal", + "ブルーノのテクニカルマシン01": "Bruno's Technical Machine 01", + "ブルーノのテクニカルマシン02": "Bruno's Technical Machine 02", + "ブレイブバングル": "Brave Bangle", + "プライスのテクニカルマシン01": "Pryce's Technical Machine 01", + "プライスのテクニカルマシン02": "Pryce's Technical Machine 02", + "プライムキャッチャー": "Prime Catcher", + "プラスパワー": "PlusPower", + "ベリー": "Berry", + "ペパー": "Arven", + "ホイットニーのテクニカルマシン01": "Whitney's Technical Machine 01", + "ホイットニーのテクニカルマシン02": "Whitney's Technical Machine 02", + "ボスのやりかた": "The Boss's Way", + "ボスの指令": "Boss's Orders", + "ポクギア": "Pokégear", + "ポケギア3.0": "Pokégear 3.0", + "ポケモンいれかえ": "Switch", + "ポケモンの笛": "Pokémon Flute", + "ポケモンキャッチャー": "Pokémon Catcher", + "ポケモンセンター": "Pokémon Center", + "ポケモンナース": "Pokémon Nurse", + "ポケモンパーク": "Pokémon Park", + "ポケモンパーソナリティテスト": "Pokémon Personality Test", + "ポケモンファンクラブ": "Pokémon Fan Club", + "ポケモンブリーダーフィールド": "Pokémon Breeder Fields", + "ポケモンマーチ": "Pokémon March", + "ポケモン交換おじさん": "Pokémon Trader", + "ポケモン再送信": "Pokémon Retransmit", + "ポケモン反転": "Pokémon Reversal", + "ポケモン回収": "Pokémon Retrieval", + "ポケモン図鑑": "Pokédex", + "ポケモン育て屋さん": "Pokémon Breeder", + "ポケモン通信": "Pokémon Communication", + "ポーション": "Potion", + "マサキ": "Bill", + "マスターボール": "Master Ball", + "マチス": "Lt. Surge", + "マチスの交渉": "Lt. Surge's Treaty", + "マチスの秘策": "Lt. Surge's Secret Plan", + "マルチテクニカルマシン01": "Multi Technical Machine 01", + "ミニスカート": "Lass", + "ミラクルベリー": "Miracle Berry", + "メアリー": "Mary", + "メアリーの衝動": "Mary's Impulse", + "メモリベリー": "Memory Berry", + "メンテナンス": "Maintenance", + "モンスターボール": "Poké Ball", + "ヤマブキシティジム": "Saffron City Gym", + "ライトニングキューブ01": "Lightning Cube 01", + "ラジオタワー": "Radio Tower", + "ラッキースタジアム": "Lucky Stadium", + "ランスのテクニカルマシン01": "Lance's Technical Machine 01", + "ランスのテクニカルマシン02": "Lance's Technical Machine 02", + "リコール": "Recall", + "リサイクル": "Recycle", + "リムーブ禁止ジム": "No Removal Gym", + "リーリエの決心": "Lillie's Determination", + "レインボーエネルギー": "Rainbow Energy", + "ロケットのスニーク攻撃": "Rocket's Sneak Attack", + "ロケットのテクニカルマシン01": "Rocket's Technical Machine 01", + "ロケットの隠れ家": "Rocket Hideout", + "ロケット団のおねーさん": "Rocket's Admin.", + "ロケット団のワナ": "Team Rocket's Trap", + "ロケット団の実験": "Team Rocket's Experiment", + "ロケット団の爆発ジム": "Explosion Gym", + "ロケット団の特訓ジム": "Training Center", + "ロケット団参上!": "Here Comes Team Rocket!", + "ロケット団員": "Team Rocket Grunt", + "ワープエネルギー": "Warp Energy", + "ワープポイント": "Warp Point", + "二重突風": "Double Gust", + "先見者": "Oracle", + "化石卵": "Fossil Egg", + "博士の研究": "Professor's Research", + "反撃の爪": "Counterattack Claws", + "古い棒": "Old Rod", + "基本ドラゴンエネルギー": "Dragon Energy", + "基本フェアリーエネルギー": "Fairy Energy", + "基本悪エネルギー": "Darkness Energy", + "基本水エネルギー": "Water Energy", + "基本炎エネルギー": "Fire Energy", + "基本草エネルギー": "Grass Energy", + "基本超エネルギー": "Psychic Energy", + "基本鋼エネルギー": "Metal Energy", + "基本闘エネルギー": "Fighting Energy", + "基本雷エネルギー": "Lightning Energy", + "壁を台無しにする[aerodactyl]": "Ruin Wall", + "壁を台無しにする[カブト]": "Ruin Wall", + "壊れた地上ジム": "Broken Ground Gym", + "夜のタンカ": "Night Stretcher", + "夜の廃品回収": "Nightly Garbage Run", + "大地の器": "Earthen Vessel", + "奇跡のエネルギー": "Miracle Energy", + "強さの魅力": "Power Charge", + "思い出させる": "Reminder", + "思考ウェーブマシン": "Thought Wave Machine", + "戦いキューブ01": "Fighting Cube 01", + "抵抗力低下ジム": "Resistance Gym", + "拡大鏡": "Magnifier", + "新しいpokedex": "New Pokédex", + "旅行セールスマン": "Traveling Salesman", + "森林保護者": "Forest Guardian", + "模倣": "Copycat", + "海底遺跡": "Undersea Ruins", + "町のボランティア": "Town Volunteers", + "癒しベリー": "Heal Berry", + "発電所": "Power Plant", + "礼儀作法": "Etiquette", + "突風": "Gust of Wind", + "粉末を癒します": "Heal Powder", + "脱力感ガード": "Weakness Guard", + "見えない壁": "Invisible Wall", + "詐欺師オーク教授": "Impostor Professor Oak", + "詐欺師オーク教授の発明": "Impostor Professor Oak's Invention", + "超エネルギーリムーブ": "Super Energy Removal", + "退化スプレー": "Devolution Spray", + "金属エネルギー": "Metal Energy", + "金属キューブ01": "Metal Cube 01", + "錯乱ジム": "Chaos Gym", + "闇のエネルギー": "Darkness Energy", + "闇キューブ01": "Darkness Cube 01" +} diff --git a/tools/pokemon_jp/set_en_names.json b/tools/pokemon_jp/set_en_names.json new file mode 100644 index 0000000..6ece3c4 --- /dev/null +++ b/tools/pokemon_jp/set_en_names.json @@ -0,0 +1,175 @@ +{ + "ADV1": "Expansion Pack ADV", + "ADV2": "Miracle of the Desert", + "ADV3": "Rulers of the Heavens", + "ADV4": "Flight of the Skies", + "ADV5": "Undone Seal", + "CP1": "Magma Gang VS Aqua Gang: Double Crisis", + "CP2": "Legendary Shine Collection", + "CP3": "PokéKyun Collection", + "CP4": "Premium Champion Pack", + "CP5": "Mythical & Legendary Dream Shine Collection", + "CP6": "Expansion Pack 20th Anniversary", + "E1": "Base Expansion Pack", + "E2": "The Town on No Map", + "E3": "Wind from the Sea", + "E4": "Split Earth", + "E5": "Mysterious Mountains", + "ExpSheet1": "Expansion Sheet Series 1", + "ExpSheet2": "Expansion Sheet Series 2", + "ExpSheet3": "Expansion Sheet Series 3", + "GurenTG": "Guren Town Gym", + "HanadaCG": "Hanada City Gym", + "KuchibaCG": "Kuchiba City Gym", + "L1a": "HeartGold Collection", + "L1b": "SoulSilver Collection", + "L2": "Reviving Legends", + "L3": "Clash at the Summit", + "LL": "Lost Link", + "M-P": "MEGA Promo", + "M1L": "Mega Symphonia", + "M1S": "Mega Symphonia", + "M2": "Inferno X", + "M2a": "MEGA Dream ex", + "M3": "Munikeith Zero", + "M4": "Ninja Spinner", + "M5": "MEGA Expansion", + "MC": "McDonald's Collection", + "NiviCG": "Nivi City Gym", + "PCG1": "Venusaur/Charizard/Blastoise Half Deck", + "PCG10": "Offense and Defense of the Furthest Ends", + "PCG2": "Flight of Fire", + "PCG3": "Clash of the Blue Sky", + "PCG4": "Rocket Gang Strikes Back", + "PCG5": "Golden Sky, Silvery Ocean", + "PCG6": "Mirage Forest", + "PCG7": "Holon Research Tower", + "PCG8": "Holon Phantom", + "PCG9": "Miracle Crystal", + "PMCG1": "Expansion Pack", + "PMCG2": "Pokémon Jungle", + "PMCG3": "Mystery of the Fossils", + "PMCG4": "Rocket Gang", + "PMCG5": "Leaders' Stadium", + "PMCG6": "Challenge from the Darkness", + "S10D": "Time Gazer", + "S10P": "Space Juggler", + "S10a": "Dark Abyss", + "S10b": "Pokémon GO", + "S11": "Lost Abyss", + "S11a": "Incandescent Arcana", + "S12": "Paradigm Trigger", + "S12a": "VSTAR Universe", + "S1H": "Shield", + "S1W": "Sword", + "S1a": "VMAX Rising", + "S2": "Rebellion Crash", + "S2a": "Explosive Walker", + "S3": "Infinity Zone", + "S3a": "Legendary Pulse", + "S4": "Amazing Volt Tackle", + "S4a": "Shiny Star V", + "S5I": "Single Strike Master", + "S5R": "Rapid Strike Master", + "S5a": "Matchless Fighters", + "S6H": "Silver Lance", + "S6K": "Jet-Black Spirit", + "S6a": "Eevee Heroes", + "S7D": "Skyscraping Perfection", + "S7R": "Blue Sky Stream", + "S8": "Fusion Arts", + "S8a": "Dark Phantasma", + "S8b": "VMAX Climax", + "S9": "Star Birth", + "S9a": "Battle Region", + "SM0": "Generation", + "SM1+": "Strength Expansion Pack Sun & Moon", + "SM10": "Double Blaze", + "SM10b": "Sky Legend", + "SM11a": "Remix Bout", + "SM11b": "Dream League", + "SM12": "Alter Genesis", + "SM12a": "Tag All Stars", + "SM1M": "Collection Moon", + "SM1S": "Collection Sun", + "SM2K": "Islands Await You", + "SM2L": "Alolan Moonlight", + "SM3+": "Shining Legends", + "SM3H": "Fighting Rainbow", + "SM3N": "Darkness that Consumes Light", + "SM4+": "GX Battle Boost", + "SM4A": "Ultradimensional Beasts", + "SM4S": "Awakened Heroes", + "SM5+": "Ultra Force Ultra Sun Ultra Moon", + "SM5M": "Ultra Force", + "SM5S": "Ultra Moon", + "SM6": "Forbidden Light", + "SM6a": "Dragon Storm", + "SM6b": "Champion Road", + "SM7": "Fairy Rise", + "SM7a": "Thunderclap Spark", + "SM7b": "Fairy Rise", + "SM8": "Explosive Impact", + "SM8a": "Dark Order", + "SM8b": "GX Ultra Shiny", + "SM9": "Tag Bolt", + "SM9a": "Night Unison", + "SM9b": "Full Metal Wall", + "SMP2": "Detective Pikachu", + "SV10": "Glory of Team Rocket", + "SV11B": "Black Bolt", + "SV11W": "White Flare", + "SV1S": "Scarlet ex", + "SV1V": "Violet ex", + "SV1a": "Triplet Beat", + "SV2D": "Clay Burst", + "SV2P": "Snow Hazard", + "SV2a": "Pokémon Card 151", + "SV3": "Ruler of the Black Flame", + "SV3a": "Raging Surf", + "SV4K": "Ancient Roar", + "SV4M": "Future Flash", + "SV4a": "Shiny Treasure ex", + "SV5K": "Wild Force", + "SV5M": "Cyber Judge", + "SV5a": "Crimson Haze", + "SV6": "Mask of Change", + "SV6a": "Night Wanderer", + "SV7": "Stellar Miracle", + "SV7a": "Paradise Dragona", + "SV8": "Super Electric Breaker", + "SV8a": "Terastal Festival ex", + "SV9": "Battle Partners", + "SV9a": "Heat Wave Arena", + "SVK": "Starter Set / Construction Dec", + "SVLN": "Starter Set Terastal Charizard ex", + "SVLS": "Starter Set Lucario & Roaring Moon", + "SouthernIslands": "Southern Islands", + "TamamushiCG": "Tamamushi City Gym", + "UnnumberedPromo": "Unnumbered Promotional cards", + "VS1": "Pokémon VS", + "XY10": "Awakening Psychic King", + "XY11a": "Cruel Traitor", + "XY11b": "Fever-Burst Fight", + "XY1a": "Collection X", + "XY1b": "Collection Y", + "XY2": "Wild Blaze", + "XY3": "Rising Fist", + "XY4": "Phantom Gate", + "XY5a": "Gaia Volcano", + "XY5b": "Tidal Storm", + "XY6": "Emerald Break", + "XY7": "Bandit Ring", + "XY8a": "Blue Shock", + "XY8b": "Red Flash", + "XY9": "Rage of the Broken Heavens", + "YamabukiCG": "Yamabuki City Gym", + "neo1": "Gold, Silver, to a New World...", + "neo2": "Crossing the Ruins...", + "neo3": "Awakening Legends", + "neo4": "Darkness, and to Light...", + "sm2+": "Facing a New Trial", + "sn10a": "GG End", + "sn11": "Miracle Twin", + "web1": "Pokémon Web" +} diff --git a/tools/pokemon_jp/species_en.json b/tools/pokemon_jp/species_en.json new file mode 100644 index 0000000..e3b3d19 --- /dev/null +++ b/tools/pokemon_jp/species_en.json @@ -0,0 +1,1027 @@ +[ + "Bulbasaur", + "Ivysaur", + "Venusaur", + "Charmander", + "Charmeleon", + "Charizard", + "Squirtle", + "Wartortle", + "Blastoise", + "Caterpie", + "Metapod", + "Butterfree", + "Weedle", + "Kakuna", + "Beedrill", + "Pidgey", + "Pidgeotto", + "Pidgeot", + "Rattata", + "Raticate", + "Spearow", + "Fearow", + "Ekans", + "Arbok", + "Pikachu", + "Raichu", + "Sandshrew", + "Sandslash", + "Nidoran♀", + "Nidorina", + "Nidoqueen", + "Nidoran♂", + "Nidorino", + "Nidoking", + "Clefairy", + "Clefable", + "Vulpix", + "Ninetales", + "Jigglypuff", + "Wigglytuff", + "Zubat", + "Golbat", + "Oddish", + "Gloom", + "Vileplume", + "Paras", + "Parasect", + "Venonat", + "Venomoth", + "Diglett", + "Dugtrio", + "Meowth", + "Persian", + "Psyduck", + "Golduck", + "Mankey", + "Primeape", + "Growlithe", + "Arcanine", + "Poliwag", + "Poliwhirl", + "Poliwrath", + "Abra", + "Kadabra", + "Alakazam", + "Machop", + "Machoke", + "Machamp", + "Bellsprout", + "Weepinbell", + "Victreebel", + "Tentacool", + "Tentacruel", + "Geodude", + "Graveler", + "Golem", + "Ponyta", + "Rapidash", + "Slowpoke", + "Slowbro", + "Magnemite", + "Magneton", + "Farfetch'd", + "Doduo", + "Dodrio", + "Seel", + "Dewgong", + "Grimer", + "Muk", + "Shellder", + "Cloyster", + "Gastly", + "Haunter", + "Gengar", + "Onix", + "Drowzee", + "Hypno", + "Krabby", + "Kingler", + "Voltorb", + "Electrode", + "Exeggcute", + "Exeggutor", + "Cubone", + "Marowak", + "Hitmonlee", + "Hitmonchan", + "Lickitung", + "Koffing", + "Weezing", + "Rhyhorn", + "Rhydon", + "Chansey", + "Tangela", + "Kangaskhan", + "Horsea", + "Seadra", + "Goldeen", + "Seaking", + "Staryu", + "Starmie", + "Mr. Mime", + "Scyther", + "Jynx", + "Electabuzz", + "Magmar", + "Pinsir", + "Tauros", + "Magikarp", + "Gyarados", + "Lapras", + "Ditto", + "Eevee", + "Vaporeon", + "Jolteon", + "Flareon", + "Porygon", + "Omanyte", + "Omastar", + "Kabuto", + "Kabutops", + "Aerodactyl", + "Snorlax", + "Articuno", + "Zapdos", + "Moltres", + "Dratini", + "Dragonair", + "Dragonite", + "Mewtwo", + "Mew", + "Chikorita", + "Bayleef", + "Meganium", + "Cyndaquil", + "Quilava", + "Typhlosion", + "Totodile", + "Croconaw", + "Feraligatr", + "Sentret", + "Furret", + "Hoothoot", + "Noctowl", + "Ledyba", + "Ledian", + "Spinarak", + "Ariados", + "Crobat", + "Chinchou", + "Lanturn", + "Pichu", + "Cleffa", + "Igglybuff", + "Togepi", + "Togetic", + "Natu", + "Xatu", + "Mareep", + "Flaaffy", + "Ampharos", + "Bellossom", + "Marill", + "Azumarill", + "Sudowoodo", + "Politoed", + "Hoppip", + "Skiploom", + "Jumpluff", + "Aipom", + "Sunkern", + "Sunflora", + "Yanma", + "Wooper", + "Quagsire", + "Espeon", + "Umbreon", + "Murkrow", + "Slowking", + "Misdreavus", + "Unown", + "Wobbuffet", + "Girafarig", + "Pineco", + "Forretress", + "Dunsparce", + "Gligar", + "Steelix", + "Snubbull", + "Granbull", + "Qwilfish", + "Scizor", + "Shuckle", + "Heracross", + "Sneasel", + "Teddiursa", + "Ursaring", + "Slugma", + "Magcargo", + "Swinub", + "Piloswine", + "Corsola", + "Remoraid", + "Octillery", + "Delibird", + "Mantine", + "Skarmory", + "Houndour", + "Houndoom", + "Kingdra", + "Phanpy", + "Donphan", + "Porygon2", + "Stantler", + "Smeargle", + "Tyrogue", + "Hitmontop", + "Smoochum", + "Elekid", + "Magby", + "Miltank", + "Blissey", + "Raikou", + "Entei", + "Suicune", + "Larvitar", + "Pupitar", + "Tyranitar", + "Lugia", + "Ho-Oh", + "Celebi", + "Treecko", + "Grovyle", + "Sceptile", + "Torchic", + "Combusken", + "Blaziken", + "Mudkip", + "Marshtomp", + "Swampert", + "Poochyena", + "Mightyena", + "Zigzagoon", + "Linoone", + "Wurmple", + "Silcoon", + "Beautifly", + "Cascoon", + "Dustox", + "Lotad", + "Lombre", + "Ludicolo", + "Seedot", + "Nuzleaf", + "Shiftry", + "Taillow", + "Swellow", + "Wingull", + "Pelipper", + "Ralts", + "Kirlia", + "Gardevoir", + "Surskit", + "Masquerain", + "Shroomish", + "Breloom", + "Slakoth", + "Vigoroth", + "Slaking", + "Nincada", + "Ninjask", + "Shedinja", + "Whismur", + "Loudred", + "Exploud", + "Makuhita", + "Hariyama", + "Azurill", + "Nosepass", + "Skitty", + "Delcatty", + "Sableye", + "Mawile", + "Aron", + "Lairon", + "Aggron", + "Meditite", + "Medicham", + "Electrike", + "Manectric", + "Plusle", + "Minun", + "Volbeat", + "Illumise", + "Roselia", + "Gulpin", + "Swalot", + "Carvanha", + "Sharpedo", + "Wailmer", + "Wailord", + "Numel", + "Camerupt", + "Torkoal", + "Spoink", + "Grumpig", + "Spinda", + "Trapinch", + "Vibrava", + "Flygon", + "Cacnea", + "Cacturne", + "Swablu", + "Altaria", + "Zangoose", + "Seviper", + "Lunatone", + "Solrock", + "Barboach", + "Whiscash", + "Corphish", + "Crawdaunt", + "Baltoy", + "Claydol", + "Lileep", + "Cradily", + "Anorith", + "Armaldo", + "Feebas", + "Milotic", + "Castform", + "Kecleon", + "Shuppet", + "Banette", + "Duskull", + "Dusclops", + "Tropius", + "Chimecho", + "Absol", + "Wynaut", + "Snorunt", + "Glalie", + "Spheal", + "Sealeo", + "Walrein", + "Clamperl", + "Huntail", + "Gorebyss", + "Relicanth", + "Luvdisc", + "Bagon", + "Shelgon", + "Salamence", + "Beldum", + "Metang", + "Metagross", + "Regirock", + "Regice", + "Registeel", + "Latias", + "Latios", + "Kyogre", + "Groudon", + "Rayquaza", + "Jirachi", + "Deoxys", + "Turtwig", + "Grotle", + "Torterra", + "Chimchar", + "Monferno", + "Infernape", + "Piplup", + "Prinplup", + "Empoleon", + "Starly", + "Staravia", + "Staraptor", + "Bidoof", + "Bibarel", + "Kricketot", + "Kricketune", + "Shinx", + "Luxio", + "Luxray", + "Budew", + "Roserade", + "Cranidos", + "Rampardos", + "Shieldon", + "Bastiodon", + "Burmy", + "Wormadam", + "Mothim", + "Combee", + "Vespiquen", + "Pachirisu", + "Buizel", + "Floatzel", + "Cherubi", + "Cherrim", + "Shellos", + "Gastrodon", + "Ambipom", + "Drifloon", + "Drifblim", + "Buneary", + "Lopunny", + "Mismagius", + "Honchkrow", + "Glameow", + "Purugly", + "Chingling", + "Stunky", + "Skuntank", + "Bronzor", + "Bronzong", + "Bonsly", + "Mime Jr.", + "Happiny", + "Chatot", + "Spiritomb", + "Gible", + "Gabite", + "Garchomp", + "Munchlax", + "Riolu", + "Lucario", + "Hippopotas", + "Hippowdon", + "Skorupi", + "Drapion", + "Croagunk", + "Toxicroak", + "Carnivine", + "Finneon", + "Lumineon", + "Mantyke", + "Snover", + "Abomasnow", + "Weavile", + "Magnezone", + "Lickilicky", + "Rhyperior", + "Tangrowth", + "Electivire", + "Magmortar", + "Togekiss", + "Yanmega", + "Leafeon", + "Glaceon", + "Gliscor", + "Mamoswine", + "Porygon-Z", + "Gallade", + "Probopass", + "Dusknoir", + "Froslass", + "Rotom", + "Uxie", + "Mesprit", + "Azelf", + "Dialga", + "Palkia", + "Heatran", + "Regigigas", + "Giratina", + "Cresselia", + "Phione", + "Manaphy", + "Darkrai", + "Shaymin", + "Arceus", + "Victini", + "Snivy", + "Servine", + "Serperior", + "Tepig", + "Pignite", + "Emboar", + "Oshawott", + "Dewott", + "Samurott", + "Patrat", + "Watchog", + "Lillipup", + "Herdier", + "Stoutland", + "Purrloin", + "Liepard", + "Pansage", + "Simisage", + "Pansear", + "Simisear", + "Panpour", + "Simipour", + "Munna", + "Musharna", + "Pidove", + "Tranquill", + "Unfezant", + "Blitzle", + "Zebstrika", + "Roggenrola", + "Boldore", + "Gigalith", + "Woobat", + "Swoobat", + "Drilbur", + "Excadrill", + "Audino", + "Timburr", + "Gurdurr", + "Conkeldurr", + "Tympole", + "Palpitoad", + "Seismitoad", + "Throh", + "Sawk", + "Sewaddle", + "Swadloon", + "Leavanny", + "Venipede", + "Whirlipede", + "Scolipede", + "Cottonee", + "Whimsicott", + "Petilil", + "Lilligant", + "Basculin", + "Sandile", + "Krokorok", + "Krookodile", + "Darumaka", + "Darmanitan", + "Maractus", + "Dwebble", + "Crustle", + "Scraggy", + "Scrafty", + "Sigilyph", + "Yamask", + "Cofagrigus", + "Tirtouga", + "Carracosta", + "Archen", + "Archeops", + "Trubbish", + "Garbodor", + "Zorua", + "Zoroark", + "Minccino", + "Cinccino", + "Gothita", + "Gothorita", + "Gothitelle", + "Solosis", + "Duosion", + "Reuniclus", + "Ducklett", + "Swanna", + "Vanillite", + "Vanillish", + "Vanilluxe", + "Deerling", + "Sawsbuck", + "Emolga", + "Karrablast", + "Escavalier", + "Foongus", + "Amoonguss", + "Frillish", + "Jellicent", + "Alomomola", + "Joltik", + "Galvantula", + "Ferroseed", + "Ferrothorn", + "Klink", + "Klang", + "Klinklang", + "Tynamo", + "Eelektrik", + "Eelektross", + "Elgyem", + "Beheeyem", + "Litwick", + "Lampent", + "Chandelure", + "Axew", + "Fraxure", + "Haxorus", + "Cubchoo", + "Beartic", + "Cryogonal", + "Shelmet", + "Accelgor", + "Stunfisk", + "Mienfoo", + "Mienshao", + "Druddigon", + "Golett", + "Golurk", + "Pawniard", + "Bisharp", + "Bouffalant", + "Rufflet", + "Braviary", + "Vullaby", + "Mandibuzz", + "Heatmor", + "Durant", + "Deino", + "Zweilous", + "Hydreigon", + "Larvesta", + "Volcarona", + "Cobalion", + "Terrakion", + "Virizion", + "Tornadus", + "Thundurus", + "Reshiram", + "Zekrom", + "Landorus", + "Kyurem", + "Keldeo", + "Meloetta", + "Genesect", + "Chespin", + "Quilladin", + "Chesnaught", + "Fennekin", + "Braixen", + "Delphox", + "Froakie", + "Frogadier", + "Greninja", + "Bunnelby", + "Diggersby", + "Fletchling", + "Fletchinder", + "Talonflame", + "Scatterbug", + "Spewpa", + "Vivillon", + "Litleo", + "Pyroar", + "Flabébé", + "Floette", + "Florges", + "Skiddo", + "Gogoat", + "Pancham", + "Pangoro", + "Furfrou", + "Espurr", + "Meowstic", + "Honedge", + "Doublade", + "Aegislash", + "Spritzee", + "Aromatisse", + "Swirlix", + "Slurpuff", + "Inkay", + "Malamar", + "Binacle", + "Barbaracle", + "Skrelp", + "Dragalge", + "Clauncher", + "Clawitzer", + "Helioptile", + "Heliolisk", + "Tyrunt", + "Tyrantrum", + "Amaura", + "Aurorus", + "Sylveon", + "Hawlucha", + "Dedenne", + "Carbink", + "Goomy", + "Sliggoo", + "Goodra", + "Klefki", + "Phantump", + "Trevenant", + "Pumpkaboo", + "Gourgeist", + "Bergmite", + "Avalugg", + "Noibat", + "Noivern", + "Xerneas", + "Yveltal", + "Zygarde", + "Diancie", + "Hoopa", + "Volcanion", + "Rowlet", + "Dartrix", + "Decidueye", + "Litten", + "Torracat", + "Incineroar", + "Popplio", + "Brionne", + "Primarina", + "Pikipek", + "Trumbeak", + "Toucannon", + "Yungoos", + "Gumshoos", + "Grubbin", + "Charjabug", + "Vikavolt", + "Crabrawler", + "Crabominable", + "Oricorio", + "Cutiefly", + "Ribombee", + "Rockruff", + "Lycanroc", + "Wishiwashi", + "Mareanie", + "Toxapex", + "Mudbray", + "Mudsdale", + "Dewpider", + "Araquanid", + "Fomantis", + "Lurantis", + "Morelull", + "Shiinotic", + "Salandit", + "Salazzle", + "Stufful", + "Bewear", + "Bounsweet", + "Steenee", + "Tsareena", + "Comfey", + "Oranguru", + "Passimian", + "Wimpod", + "Golisopod", + "Sandygast", + "Palossand", + "Pyukumuku", + "Type: Null", + "Silvally", + "Minior", + "Komala", + "Turtonator", + "Togedemaru", + "Mimikyu", + "Bruxish", + "Drampa", + "Dhelmise", + "Jangmo-o", + "Hakamo-o", + "Kommo-o", + "Tapu Koko", + "Tapu Lele", + "Tapu Bulu", + "Tapu Fini", + "Cosmog", + "Cosmoem", + "Solgaleo", + "Lunala", + "Nihilego", + "Buzzwole", + "Pheromosa", + "Xurkitree", + "Celesteela", + "Kartana", + "Guzzlord", + "Necrozma", + "Magearna", + "Marshadow", + "Poipole", + "Naganadel", + "Stakataka", + "Blacephalon", + "Zeraora", + "Meltan", + "Melmetal", + "Grookey", + "Thwackey", + "Rillaboom", + "Scorbunny", + "Raboot", + "Cinderace", + "Sobble", + "Drizzile", + "Inteleon", + "Skwovet", + "Greedent", + "Rookidee", + "Corvisquire", + "Corviknight", + "Blipbug", + "Dottler", + "Orbeetle", + "Nickit", + "Thievul", + "Gossifleur", + "Eldegoss", + "Wooloo", + "Dubwool", + "Chewtle", + "Drednaw", + "Yamper", + "Boltund", + "Rolycoly", + "Carkol", + "Coalossal", + "Applin", + "Flapple", + "Appletun", + "Silicobra", + "Sandaconda", + "Cramorant", + "Arrokuda", + "Barraskewda", + "Toxel", + "Toxtricity", + "Sizzlipede", + "Centiskorch", + "Clobbopus", + "Grapploct", + "Sinistea", + "Polteageist", + "Hatenna", + "Hattrem", + "Hatterene", + "Impidimp", + "Morgrem", + "Grimmsnarl", + "Obstagoon", + "Perrserker", + "Cursola", + "Sirfetch'd", + "Mr. Rime", + "Runerigus", + "Milcery", + "Alcremie", + "Falinks", + "Pincurchin", + "Snom", + "Frosmoth", + "Stonjourner", + "Eiscue", + "Indeedee", + "Morpeko", + "Cufant", + "Copperajah", + "Dracozolt", + "Arctozolt", + "Dracovish", + "Arctovish", + "Duraludon", + "Dreepy", + "Drakloak", + "Dragapult", + "Zacian", + "Zamazenta", + "Eternatus", + "Kubfu", + "Urshifu", + "Zarude", + "Regieleki", + "Regidrago", + "Glastrier", + "Spectrier", + "Calyrex", + "Wyrdeer", + "Kleavor", + "Ursaluna", + "Basculegion", + "Sneasler", + "Overqwil", + "Enamorus", + "Sprigatito", + "Floragato", + "Meowscarada", + "Fuecoco", + "Crocalor", + "Skeledirge", + "Quaxly", + "Quaxwell", + "Quaquaval", + "Lechonk", + "Oinkologne", + "Tarountula", + "Spidops", + "Nymble", + "Lokix", + "Pawmi", + "Pawmo", + "Pawmot", + "Tandemaus", + "Maushold", + "Fidough", + "Dachsbun", + "Smoliv", + "Dolliv", + "Arboliva", + "Squawkabilly", + "Nacli", + "Naclstack", + "Garganacl", + "Charcadet", + "Armarouge", + "Ceruledge", + "Tadbulb", + "Bellibolt", + "Wattrel", + "Kilowattrel", + "Maschiff", + "Mabosstiff", + "Shroodle", + "Grafaiai", + "Bramblin", + "Brambleghast", + "Toedscool", + "Toedscruel", + "Klawf", + "Capsakid", + "Scovillain", + "Rellor", + "Rabsca", + "Flittle", + "Espathra", + "Tinkatink", + "Tinkatuff", + "Tinkaton", + "Wiglett", + "Wugtrio", + "Bombirdier", + "Finizen", + "Palafin", + "Varoom", + "Revavroom", + "Cyclizar", + "Orthworm", + "Glimmet", + "Glimmora", + "Greavard", + "Houndstone", + "Flamigo", + "Cetoddle", + "Cetitan", + "Veluza", + "Dondozo", + "Tatsugiri", + "Annihilape", + "Clodsire", + "Farigiraf", + "Dudunsparce", + "Kingambit", + "Great Tusk", + "Scream Tail", + "Brute Bonnet", + "Flutter Mane", + "Slither Wing", + "Sandy Shocks", + "Iron Treads", + "Iron Bundle", + "Iron Hands", + "Iron Jugulis", + "Iron Moth", + "Iron Thorns", + "Frigibax", + "Arctibax", + "Baxcalibur", + "Gimmighoul", + "Gholdengo", + "Wo-Chien", + "Chien-Pao", + "Ting-Lu", + "Chi-Yu", + "Roaring Moon", + "Iron Valiant", + "Koraidon", + "Miraidon", + "Walking Wake", + "Iron Leaves", + "Dipplin", + "Poltchageist", + "Sinistcha", + "Okidogi", + "Munkidori", + "Fezandipiti", + "Ogerpon", + "Archaludon", + "Hydrapple", + "Gouging Fire", + "Raging Bolt", + "Iron Boulder", + "Iron Crown", + "Terapagos", + "Pecharunt" +] diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index f6cc847..5693388 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -9,8 +9,8 @@ - `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. - `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). - `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32. -- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant. -- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. +- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)` → `Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia). +- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, optional `appendPreSetRows` (Pokemon West/Asia region), Set picker (read-only `wxComboBox` with typeahead — prefix first, then substring, ASCII-fold so `Pokemon`/`Jungle` match `Pokémon Jungle` — and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices (`languagesForChoice()` hook; Pokemon filters by region), Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. - `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`. - `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 lines of hook overrides on top of the matching base template. - `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`). @@ -67,7 +67,7 @@ - When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`. 15. **Preview fallback behavior (CCM2 parity where applicable):** - Keep unresolved external previews user-visible by showing a per-game card-back image in `BaseSelectedCardPanel` instead of a blank/transparent bitmap. - - Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails. Digimon Digi-Battle uses a **bundled** PNG (`assets/digibattle99_card_back.png`) — keep those chains working when touching preview code. + - Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Japanese Pokémon uses the Japanese TCG Bulbagarden back (`TCG_Card_Back_Japanese.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails. Digimon Digi-Battle uses a **bundled** PNG (`assets/digibattle99_card_back.png`) — keep those chains working when touching preview code. - If you change fallback sourcing (URLs or bundled asset), keep the "always show a reasonable card-back fallback" behavior intact for **every** game with remote previews. 16. **Per-game auto-detect controls:** - Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game. diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt index 5bb90b5..f683e0a 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -27,6 +27,7 @@ add_library(ccm_ui_wx STATIC src/SettingsDialog.cpp src/SwitchCtrl.cpp src/ImageViewerDialog.cpp + src/VariantImagePreviewDialog.cpp src/IconListCtrl.cpp src/SvgIcons.cpp src/Theme.cpp diff --git a/ui_wx/assets/pokemon_jp_classic/TamamushiCG/016.jpg b/ui_wx/assets/pokemon_jp_classic/TamamushiCG/016.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9393084863fde6d7c8db202e79f8aa49bc20d214 GIT binary patch literal 48591 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

Au~poyEn z=^Ojo)d`yDx_4b{{Q)+ z{La+I$k#_KcJE*~HZu8IU<}$=mRA0K8me|4@&L+^Kh8)n$nQiueE85imIODlBS^F- z&hRxlb#Qp&9|7kTGcd)F4gU3G$dp4Ptm6Rl=vS^ujK@2EcjrL}mnxMK*VhQ*G!Rot zc{t9nzW}~Gz>B|1St0>$e1J{9)O%MA+0gQM@Q2u`{sA1fK(XnuNF>zajAF2z%jdwm z+5BW;-Oc)1%2C2EGLV7kJu9=MX#YaaI;FaVZI{-3t`wKpM&Fgh0VP8ka^*0U+GifGU!5PXTc@joi|{C^s$o2Xh=IY6U*R zzr`e23Ms^q*@36TWPM01A4@ODz6p5Rh1jwNuj^CegY-~^wYagsTtT9G775UT!j0eM zK;ZF^cM}fU=bSs#Xh>-0((EF`rLPC3zz@RP#1>b%YkEN3YlMaQTXuL)Y+Hk7Jg^0Z zk5uYm)%~C)ZMDZG9||#!hx}bT|e4WB}iuOdZ;Y zS09%qoWgoCX~izj?jvwy8iQ*HlRBF=qBN(hkshOf1k+ncUE5jL3)I@7f zu~;N3;-FO|qeXV}_e~;jOqRFM+v5iI`t;88-$p2SKmhZO0BhcZ$Nh$Ob zRK$i7@c{|&uP|r4k2r#u?<;ZW-uZQHIbg$s_*tSmw8oDF@C3bV$@wly1pYg6oVGmt zf!EVVG5>1OQ9lQbWWLI@+~er&2JNOWk| zs)o%(Fy*`Y4>#hvSVo(@TW_k$VcEM=pX5eu;F9t*i|^_cUULnzr4jYhq|Oc4Y%*Pe zL?-FvJ2t>Nxp!jxbfhvHbk3+vsHkVF>_XEVutlgV+aQ)E-sQZi$2hxS*y-E1vK~|V z>!M#YPEJa`)@RzJ68Y4v(SCsKRBjIWU|b0jVlb1GjEd%JF^{?1sz4k zQ=B#u@JCI6cNq~wDI9Hr3-t^)aHthg@~jLGa68HUT-Cap9IFhLLmdb^yjA((Mbk(9 z;hsb}F4SoS#)nH`h)4}d@^{G65CG>pGv5#@-+)WTGiXs#3paeA_{-28?9}wghU}kn zJJOlQxm+yj_}Lj)XiOAa_URY2)IYpVedjz^ zRl?ran+q1eGd=B`sYfnvt~TBgxs#}hzAT_VE{x)0Smp^d71kacVryU|1kk-WuV}T=;Uohb8~rtub1;@w(SbKGK`dl z-r;#hhJ;f&wpxM;RCbp=;YIiPC85iLO5{MXwIhv2?Hlpj1Ct+O48$HGGBT``ujk;O zX0Z#rE*^ggKjp@Y6)ZWdLoU}^JBrfmi0YNNQHAbw`zwX7>w!Pgf>G@jc(I_qYam~S z%ikn`d74^_;@7vo4o?&GgKyq_UH^PYY=?y>@Wq=iumLjV-s0Rxjzd$Ty*H=xvKBfy z2u{rp3uR@24No4Lat=LTd5-x_aF$=3XHFWUQ@BmhIP%AgF*bk5I!P1|*%B6})rY(w zOQF!b5}O!`yjEBh*$f@B_R6SSD_wi;{{ax3E_?FA@#&|~`P)kj6PfIE$lM*1`MGo{ zTM&jTE?t8hASVz33>(i=icF5$i7WFCy{1J~C#x-udDCm-tHA^sQ{LZoT+!u_pLS%*duE25 z*6#wH4FBgi(mctzu`tC8>A?%j+~+DM%4!gzCizs8%Xe%bTBF`Wuayj?l1zbE83brS zZV+1Tw|Rs_d_|Kpc=t*w-2@r9tihY|)$a~zl@Whx`mxnG{Y7f^- zaLL5L!ycfRx0#(3474S^W(SfzvO`+d>{bZ+4wr@<1T=FCYkSt zU4@!dJN&VLh9Ca5F#8j(5+z{D%r?WWxOC$vI@is?DbT(avlW)PVD!7=xHG!Z*TGm|$)({=dW zerHWTI!Irs95os#`EzvezLMv4vgRS;`^62)P|afMxzasl(J{O3fZHU%_pn+5(Y&lh z=ARK@CXSqRtQmla&23-YG6>hZR|%srYYZ_mcQ*NUv&yj^jsvGo10m+_S*rcS;XlEa zs!q%Mh?$g5;;iV2oB(dL6L`#BK~B)`5akZbnupH{bTc#NdxAP(zjX6N;+$-yzv)C} z-DMTrThfHRG?b2Oy4&p2NZzE^K}7Ku zW##T@0)d_kZrsd^ovZ~a;@662J5nZ@=5oK?77hC7MSJI|iUqt$EqLo_G+-B0I;!3Z z=7Byw^toh&?KA*aod>N|Fz*2BwYds}Y6!OUo9pqf``GOJPPF#V>SJkj3wZDuk*e4p z?KQI()t^ndeR!`)6-))kwj=Y8OCZHBo4@GF{DGHnBNyl(=P^!XS3n@A{mN8XIH@m9 z!5hS%#X;@rh~@m%aJ6eAdJn${{bWi$oWyU_^1cebpvbc^Y|BLZGhWEUzP75S7W(Cjs1E()(o!rv zLFt6`=g=LggEOvRcj^`fz94lZKH|Y5p7t3-7cinTr#3f)T>=8yR%aS1lBCa+Pv$Y; zkyM#Vl6Ck}$6Md0CS!iG4V;2a6?3{-s&pSIT+z&VB-%_$XJ zV1VPV%*!?Msfj?a<>-VC$ z`yz(cxm%W%2&EDjCJiB+GdVj&Kp|OwoxiR_;r%>n^-D>VH8)uQ_;F~6Q zRi;jq)P-?;uZ?gK`cyYUPd^fGB|>*PYG$vl=ejVY#z@+l+TV@6&vT_IpXZaJ9C}io z$Of+RpC}U=VEDl?hpMjtSSveBR4%cZ$L$q2aS3_%cH6|dd&eHHrU4}WaAwoq&5MZt zq*elsMo+u*k10~bMWJ5^g0=+13wuX%HN)QC+Wi-wLE%o;L4lYP8_oI_wA?KUd zql})1z?H4Dx*>nQLeG+pBa1k?S)cl@j6b%f7*u(2*?BmSc_vq4s&D<)sl$lqisco|4QC#*O!l`{9R!K*Ymrf zf6c?_P8lds2s)?y_%EU7iOyR_#&&}V6nyg5uXX}dnHRVh)Y87d8Av3T#5%6KMVpNy^p+ZRRg64f}%upl!g75&#= zgX)jZN+*))v88rvAVxjg)SP0mpRV87y&u=Tes;&>r&q%8$2KM29bv<9sjTX%6r*q_ z%7$;fiahk`*{#YE-B{RLXrXDXY`6OCRwVSVari!s+%vJzhrT?X=cZSmn!6)=jX%X{ zZ#zB7e)W|8oYr7GFm>Z(u{zmJc|^&!!vDrkg`gruB&+j+oBLcbp(>8+h^s%BdVLA* z8VmpXyES^JpRrQr`gB8d-jiJ4K691WGR%}qJ=ee4=Mx4(`yXp*uiXc`|3XfVAd2Q^ z6g9FW?qY@{AFn?4ZZp}|QrgIVd1*|jj=k`yuE_F=oYApTe-8(B1k(q4c_IT^Goc~- z?-Ngiv_45SW>EuHml)}3@r8QL=W5Y9_1+b!7JnU9X=+=vljZ^JMOo z977Ky1))><-#Gu=`PnCu4GqB+K34@_t2U7U?-Txw4?yo8|HQRcnLNt-K{p^bgc=ai zJNwa(49NW2qZXKX~E4)Ess1pWy5M`LcizpP`}{*2&ZGjC(&_!VjlwQYe4( z1_WGAQhMb#|E2RTmM3KVo7!d;&sFAUK(oE z)uXWx*1@$qK~E2IY0dXIYrg5_SSgk_*h@Zt^Y4ogSW~uFt=q-Lyz58>Skvc|2=eE5 zO$QvH9#Sux9DnzyYP`NtJ4_s#Nviva|lN`PoB~t&%9I zl5KkY-hp$g?Uaz|hg#b^89WMXwWob7KeHQpx>d%svJz_3Hr(mKW5)AjLY=R=fCRAH zlwUWK8lb)LoEHrK`3~)2?QM?TQU?xKNx)g42ZrDDMwmKg8$MWLd%Ss^#-litc&SC2 zl8``dpExH-XA9?(VO2HGJCbf$0y<;wxK=bm`2|(qHNU&lzFf|kmUgdv*h949cpu-& zGSazhzo*3abA|hWW;4vS$p9VA|5>2>(v%FYGp2xM7Jvgc>)mG1S~0e(8UJ4hqDQIx^ohul4Z@&2CmZ)LB7XZrHB zi<66S%{RoC&{fal47qWgzl?E?}hN~$g}AX;=HAk9z56l5%QvQ zRPCyla+Rt6$4rP^0xOu}UBNv?N?r-R169n;>By7OfdyU^~&?SKBlm%n4*WUDcK z8ZFniStDCOv&(+JU!K8v<7A+5%iU(wUE17Hs+Pv?EeY^4DaF!vqcudL;T3f9=jU`p zMz>hMa&G={d>r;nYb<1vu_pC_6W7!EsT;XAvB56ALArTQO13L^JcT^fR;MhCsRxY# zfW00X^~{kU%X^fUFP~b(sBpfn8U3J~yzAxOp%Rp9bK5>>3b1-)b(I)V`K+R)EP8co zaOBa|v5|!WF=kZKWo6HPrb}|QRTHP5t6E1%nY<*ux%)KqZS36r?1b0zk=VcbULhW_du&A?9L)rxUR_YygYF2c-O z@9RJOe>ql<_GXI%Shq(y56ZG>MuW z#aih+lFJpI4spFK%7)yS{Lz|EdD>S=KwF+gk?Te6hsdEbnRp6&KlA0vzQ^*E$Fs8%qdxwA{AMj)#?R2=ipY?9xg7}837j5RYg{*Mqu~)H{Hv+SfB1Kh zjlZDOLAr_`kMU&3*7|UX5BI$93Wuc85SL|*odMqR&e9S+G8Yrk@0#j9M~LaSmw=P`IVs;phK>jCHQO1yfRX1^`(ZBvH( zZsc8X9LRa*`>uQW#3@$8 z5*hx6*={X}=M5Ok;Q3;6!nbCk?_I?cq20fb8{MDM?39-1>nPLf_soyxt$ytc<_4<` zTEC|iklⓈ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 // // Header-only template for the modal create/edit form. Owns the parts every -// game shares — Name, Set picker (read-only combo with prefix typeahead), +// game shares — Name, Set picker (read-only combo with typeahead: prefix first, +// then substring, with ASCII-fold so "Pokemon"/"Jungle" match "Pokémon Jungle"), // Amount spin, Language and Condition choices, Note, Image list with // Add/Remove/double-click-to-view, OK/Cancel — and exposes hooks the // subclass uses to: @@ -48,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -102,6 +104,9 @@ protected: // wants; the base only owns the surrounding label. virtual void buildFlagsRow(wxBoxSizer* flagsBox) = 0; + // Subclass adds any labelled rows between Name and Set. Default does nothing. + virtual void appendPreSetRows(wxFlexGridSizer* /*grid*/) {} + // Subclass adds any extra game-specific labelled rows just below the // standard rows but above the Note row, by calling `appendRow(label, ctrl)` // (provided as a parameter). Default does nothing. @@ -114,6 +119,11 @@ protected: // Subclass copies the extra fields it owns from its widgets back into `card_`. virtual void writeExtraToCard() {} + // Languages offered in the Language choice. Default: allLanguages(). + [[nodiscard]] virtual std::span languagesForChoice() const { + return allLanguages(); + } + [[nodiscard]] virtual std::string updateMenuName() const { return "Update Sets"; } // Display name passed into errors and the dialog title hints. @@ -151,6 +161,12 @@ protected: return preloadedSets_ != nullptr ? *preloadedSets_ : sets_; } + void setPreloadedSetsPointer(const std::vector* sets) noexcept { + preloadedSets_ = sets; + } + + void refreshSetAndLanguageChoices() { populateChoices(); } + // Default: combo only. Yu-Gi-Oh! overrides to add set-code entry + toggle. virtual void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) { row.Add(combo, 1, wxEXPAND); @@ -193,6 +209,9 @@ private: }); appendRow(grid, "Name", nameCtrl_); + // Optional rows between Name and Set (e.g. Pokemon West/Asia region). + appendPreSetRows(grid); + // `setCombo_` must be parented to `setHost` so every control in the Set row // shares the same `wxPanel`; otherwise the combo stays a direct child of the // dialog while the sizer lives on `setHost`, which corrupts layout on MSW. @@ -311,9 +330,10 @@ private: languageChoice_->Clear(); int langIdx = 0; int i = 0; + const auto langsForChoice = languagesForChoice(); wxArrayString langs; - langs.Alloc(allLanguages().size()); - for (auto l : allLanguages()) { + langs.Alloc(langsForChoice.size()); + for (auto l : langsForChoice) { const std::string lang = std::string(to_string(l)); langs.Add(wxString::FromUTF8(lang.c_str())); if (l == card_.language) langIdx = i; @@ -486,20 +506,43 @@ private: #endif } + // Fold a few Latin-1 diacritics so typing ASCII "Pokemon" matches "Pokémon". + [[nodiscard]] static wxString foldAsciiForTypeahead(wxString s) { + s.MakeLower(); + s.Replace(wxString::FromUTF8("\xc3\xa9"), wxT("e")); // é + s.Replace(wxString::FromUTF8("\xc3\x89"), wxT("e")); // É (after lower: é) + s.Replace(wxString::FromUTF8("\xc3\xa8"), wxT("e")); // è + s.Replace(wxString::FromUTF8("\xc3\xaa"), wxT("e")); // ê + s.Replace(wxString::FromUTF8("\xc3\xa0"), wxT("a")); // à + s.Replace(wxString::FromUTF8("\xc3\xa1"), wxT("a")); // á + s.Replace(wxString::FromUTF8("\xc3\xb1"), wxT("n")); // ñ + s.Replace(wxString::FromUTF8("\xc3\xbc"), wxT("u")); // ü + s.Replace(wxString::FromUTF8("\xc3\xb6"), wxT("o")); // ö + return s; + } + void applySetTypeaheadSelection() { const auto& available = availableSets(); if (!setCombo_ || available.empty()) return; - wxString pref = setTypeaheadPrefix_; - pref.MakeLower(); + const wxString pref = foldAsciiForTypeahead(setTypeaheadPrefix_); if (pref.empty()) return; + // Prefer prefix matches, then substring (so "Jungle" finds "Pokémon Jungle"). for (std::size_t i = 0; i < available.size(); ++i) { - wxString name(wxString::FromUTF8(available[i].name)); - name.MakeLower(); + const wxString name = + foldAsciiForTypeahead(wxString::FromUTF8(available[i].name)); if (name.StartsWith(pref)) { setCombo_->SetSelection(static_cast(i)); return; } } + for (std::size_t i = 0; i < available.size(); ++i) { + const wxString name = + foldAsciiForTypeahead(wxString::FromUTF8(available[i].name)); + if (name.Contains(pref)) { + setCombo_->SetSelection(static_cast(i)); + return; + } + } } void onSetComboChar(wxKeyEvent& ev) { diff --git a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp index 5c6043c..c10a805 100644 --- a/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseSelectedCardPanel.hpp @@ -217,6 +217,12 @@ protected: [[nodiscard]] virtual Game gameId() const noexcept = 0; + // Preview / card-back routing. Defaults to `gameId()`; Pokemon overrides + // so Asia cards use the JapanesePokemon preview source + card back. + [[nodiscard]] virtual Game previewGameFor(const TCard& /*card*/) const noexcept { + return gameId(); + } + // Construction ------------------------------------------------------------ BaseSelectedCardPanel(wxWindow* parent, @@ -292,6 +298,9 @@ private: case Game::Pokemon: // Mirrors CCM2's unresolved-preview fallback image. return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg"; + case Game::JapanesePokemon: + // Japanese TCG back (distinct from the Western Cardback.jpg). + return "https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg"; case Game::YuGiOh: // Yugipedia English TCG backing (thumbnail — smaller than full scan). return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png"; @@ -379,7 +388,7 @@ private: auto state = state_; CardPreviewService* svcPtr = &cardPreview_; auto [name, setId, setNo] = previewKey(card); - const Game game = gameId(); + const Game game = previewGameFor(card); const std::string exeDirCopy = exeDirForBundledAssets_; std::thread([state, gen, svcPtr, name = std::move(name), diff --git a/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp index 1eb1698..05a197e 100644 --- a/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/PokemonCardEditDialog.hpp @@ -1,24 +1,28 @@ #pragma once -// PokemonCardEditDialog: typed Add/Edit form for a `PokemonCard`. Inherits -// the shared layout, set picker, and image management from -// `BaseCardEditDialog` and adds: -// - a `Set #` text input (between the Set picker and the Amount spin) -// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row +// PokemonCardEditDialog: Add/Edit form for a unified West/Asia PokemonCard. +// West/Asia switch drives set lists, language choices, preview APIs, and the +// Asia-only UnnumberedPromo print UX (from the former Japanese dialog). #include "ccm/domain/PokemonCard.hpp" #include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/services/CardPreviewService.hpp" #include "ccm/ui/BaseCardEditDialog.hpp" +#include "ccm/ui/SwitchCtrl.hpp" + #include +#include #include #include +#include #include #include namespace ccm::ui { +class VariantImagePreviewDialog; + class PokemonCardEditDialog final : public BaseCardEditDialog { public: PokemonCardEditDialog(wxWindow* parent, @@ -27,22 +31,27 @@ public: CardPreviewService& cardPreview, EditMode mode, PokemonCard initial, - const std::vector* preloadedSets = nullptr); + const std::vector* westSets = nullptr, + const std::vector* asiaSets = nullptr); ~PokemonCardEditDialog() override; protected: + void appendPreSetRows(wxFlexGridSizer* grid) override; void buildFlagsRow(wxBoxSizer* flagsBox) override; void appendExtraRows(wxFlexGridSizer* grid) override; void readExtraFromCard() override; void writeExtraToCard() override; [[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; } void onCardLookupContextChanged() override; + [[nodiscard]] std::span languagesForChoice() const override; private: struct VariantFetchState { std::atomic alive{true}; }; + void onRegionSwitch(wxCommandEvent&); + void applyRegion(PokemonRegion region, bool clearSetIfMissing); void onAutoDetectSetNo(wxCommandEvent&); void onNextSetNo(wxCommandEvent&); void onSetSelectionChanged(wxCommandEvent&); @@ -60,15 +69,37 @@ private: void rebuildVariantRingFromCache(); void syncRingPositionToControls(); void refreshVariantNextControls(); + void refreshSetNoRowMode(); + void applySelectedSetNo(std::string setNo); + void stepVariantRing(int delta); void scheduleDeferredVariantPrefetch(); void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch); + void closeUnnumberedPreview(); + void ensureUnnumberedPreviewOpen(); + void refreshUnnumberedPreview(); + void requestUnnumberedPreviewAsync(unsigned capturedEpoch, + std::string name, + std::string setId, + std::string setNo, + std::size_t ringIndex, + std::size_t ringCount); + [[nodiscard]] bool isUnnumberedPromoSelected() const; + [[nodiscard]] std::string currentRingSetNo() const; + [[nodiscard]] Game backendGame() const noexcept; + [[nodiscard]] PokemonRegion currentRegion() const noexcept; [[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl); [[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo); EditMode dialogMode_; unsigned variantFetchEpoch_{0}; + unsigned previewFetchEpoch_{0}; CardPreviewService& cardPreview_; + const std::vector* westSets_{nullptr}; + const std::vector* asiaSets_{nullptr}; std::shared_ptr variantFetchState_; + + SwitchCtrl* regionSwitch_{nullptr}; + wxStaticText* setNoLabel_{nullptr}; wxTextCtrl* setNoCtrl_{nullptr}; wxButton* autoSetNoBtn_{nullptr}; wxButton* nextSetNoBtn_{nullptr}; @@ -76,7 +107,9 @@ private: wxCheckBox* firstEditionCheck_{nullptr}; wxCheckBox* signedCheck_{nullptr}; wxCheckBox* alteredCheck_{nullptr}; + VariantImagePreviewDialog* unnumberedPreview_{nullptr}; + std::string selectedSetNo_; std::vector cachedVariants_; std::vector uniqueSetNos_; std::size_t setNoRingPos_{0}; diff --git a/ui_wx/include/ccm/ui/PokemonGameView.hpp b/ui_wx/include/ccm/ui/PokemonGameView.hpp index f9af24d..236cf9c 100644 --- a/ui_wx/include/ccm/ui/PokemonGameView.hpp +++ b/ui_wx/include/ccm/ui/PokemonGameView.hpp @@ -1,9 +1,7 @@ #pragma once -// PokemonGameView: IGameView for the Pokemon TCG. Mirrors `MagicGameView` — -// owns the Pokemon-typed list, selected, and edit-dialog widgets and -// delegates persistence to a `CollectionService` reference -// supplied by the composition root. +// PokemonGameView: unified West + Asia Pokemon UI. One collection file; +// separate West/Asia set caches; Sets > Update Pokemon refreshes both. #include "ccm/domain/PokemonCard.hpp" #include "ccm/games/IGameModule.hpp" @@ -49,7 +47,7 @@ public: private: void ensureSetsLoaded(); - const std::vector& setsForDialog(); + const std::vector& setsForDialog(PokemonRegion region); ConfigService& config_; CollectionService& collection_; @@ -60,7 +58,8 @@ private: PokemonCardListPanel* listPanel_{nullptr}; PokemonSelectedCardPanel* selectedPanel_{nullptr}; - std::vector setsCache_; + std::vector setsCacheWest_; + std::vector setsCacheAsia_; bool attemptedInitialSetLoad_{false}; }; diff --git a/ui_wx/include/ccm/ui/PokemonSelectedCardPanel.hpp b/ui_wx/include/ccm/ui/PokemonSelectedCardPanel.hpp index 8a41bc0..1d22bbb 100644 --- a/ui_wx/include/ccm/ui/PokemonSelectedCardPanel.hpp +++ b/ui_wx/include/ccm/ui/PokemonSelectedCardPanel.hpp @@ -25,6 +25,9 @@ protected: [[nodiscard]] std::tuple previewKey(const PokemonCard& card) const override; [[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; } + [[nodiscard]] Game previewGameFor(const PokemonCard& card) const noexcept override { + return pokemonBackendGame(card.region); + } }; } // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/VariantImagePreviewDialog.hpp b/ui_wx/include/ccm/ui/VariantImagePreviewDialog.hpp new file mode 100644 index 0000000..4494f4f --- /dev/null +++ b/ui_wx/include/ccm/ui/VariantImagePreviewDialog.hpp @@ -0,0 +1,45 @@ +#pragma once + +// VariantImagePreviewDialog: small modeless popup that shows a single card +// preview image (bytes decoded as wxImage). Used by Japanese Pokémon Add/Edit +// when cycling UnnumberedPromo prints. Prev/Next fire custom events so the +// edit dialog owns the variant ring. + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ccm::ui { + +wxDECLARE_EVENT(EVT_VARIANT_PREVIEW_PREV, wxCommandEvent); +wxDECLARE_EVENT(EVT_VARIANT_PREVIEW_NEXT, wxCommandEvent); + +class VariantImagePreviewDialog : public wxDialog { +public: + explicit VariantImagePreviewDialog(wxWindow* parent); + + void setCaption(const wxString& caption); + void setImageBytes(std::string_view bytes); + void clearImage(); + void setNavigationEnabled(bool enabled); + void repositionBesideParent(); + +private: + class ImageCanvas; + + void onPrev(wxCommandEvent&); + void onNext(wxCommandEvent&); + + ImageCanvas* imageHost_{nullptr}; + wxStaticText* caption_{nullptr}; + wxButton* prevButton_{nullptr}; + wxButton* nextButton_{nullptr}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/src/MainFrame.cpp b/ui_wx/src/MainFrame.cpp index d7d29d3..c6841d0 100644 --- a/ui_wx/src/MainFrame.cpp +++ b/ui_wx/src/MainFrame.cpp @@ -41,10 +41,11 @@ constexpr const char kFilterInputHint[] = "Filter"; std::string dirNameForGame(Game g) { switch (g) { - case Game::Magic: return "magic"; - case Game::Pokemon: return "pokemon"; - case Game::YuGiOh: return "yugioh"; - case Game::DigiBattle99: return "digibattle99"; + case Game::Magic: return "magic"; + case Game::Pokemon: return "pokemon"; + case Game::YuGiOh: return "yugioh"; + case Game::DigiBattle99: return "digibattle99"; + case Game::JapanesePokemon: return "pokemon"; } return "magic"; } diff --git a/ui_wx/src/PokemonCardEditDialog.cpp b/ui_wx/src/PokemonCardEditDialog.cpp index 57e61dd..f7ccf2b 100644 --- a/ui_wx/src/PokemonCardEditDialog.cpp +++ b/ui_wx/src/PokemonCardEditDialog.cpp @@ -1,12 +1,34 @@ #include "ccm/ui/PokemonCardEditDialog.hpp" #include "ccm/domain/Enums.hpp" +#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp" +#include "ccm/ui/Theme.hpp" +#include "ccm/ui/VariantImagePreviewDialog.hpp" + #include #include + #include #include namespace ccm::ui { +namespace { + +constexpr const char* kUnnumberedPromoSetId = "UnnumberedPromo"; + +constexpr const char* kJapanesePokemonCardBackUrl = + "https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg"; + +const std::vector kEmptySets; + +bool languageAllowedForRegion(Language lang, PokemonRegion region) { + for (const auto l : languagesForPokemonRegion(region)) { + if (l == lang) return true; + } + return false; +} + +} // namespace PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent, ImageService& imageService, @@ -14,15 +36,22 @@ PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent, CardPreviewService& cardPreview, EditMode mode, PokemonCard initial, - const std::vector* preloadedSets) + const std::vector* westSets, + const std::vector* asiaSets) : BaseCardEditDialog( parent, mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card", - imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets), + imageService, setService, mode, PokemonCard{}, Game::Pokemon, nullptr), dialogMode_(mode), cardPreview_(cardPreview), + westSets_(westSets), + asiaSets_(asiaSets), variantFetchState_(std::make_shared()) { + const PokemonRegion region = initial.region; + mutableCard() = std::move(initial); + setPreloadedSetsPointer(region == PokemonRegion::Asia ? asiaSets_ : westSets_); buildAndPopulate(); + refreshSetNoRowMode(); if (dialogMode_ == EditMode::Edit) { scheduleDeferredVariantPrefetch(); } @@ -32,12 +61,40 @@ PokemonCardEditDialog::~PokemonCardEditDialog() { if (variantFetchState_) { variantFetchState_->alive.store(false); } + closeUnnumberedPreview(); +} + +PokemonRegion PokemonCardEditDialog::currentRegion() const noexcept { + return constCard().region; +} + +Game PokemonCardEditDialog::backendGame() const noexcept { + return pokemonBackendGame(currentRegion()); +} + +std::span PokemonCardEditDialog::languagesForChoice() const { + return languagesForPokemonRegion(currentRegion()); } void PokemonCardEditDialog::onCardLookupContextChanged() { clearCachedPrintVariants(); } +void PokemonCardEditDialog::appendPreSetRows(wxFlexGridSizer* grid) { + auto* regionPanel = new wxPanel(this, wxID_ANY); + auto* row = new wxBoxSizer(wxHORIZONTAL); + regionSwitch_ = new SwitchCtrl(regionPanel, wxID_ANY, + constCard().region == PokemonRegion::Asia); + row->Add(new wxStaticText(regionPanel, wxID_ANY, wxString::FromUTF8("West")), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + row->Add(regionSwitch_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + row->Add(new wxStaticText(regionPanel, wxID_ANY, wxString::FromUTF8("Asia")), + 0, wxALIGN_CENTER_VERTICAL); + regionPanel->SetSizer(row); + regionSwitch_->Bind(EVT_CCM_SWITCH, &PokemonCardEditDialog::onRegionSwitch, this); + appendRow(grid, "Region", regionPanel); +} + void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) { holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo"); firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition"); @@ -50,6 +107,7 @@ void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) { } void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { + setNoLabel_ = new wxStaticText(this, wxID_ANY, "Set #"); auto* setNoPanel = new wxPanel(this, wxID_ANY); setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY); autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect"); @@ -63,20 +121,63 @@ void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL); setNoPanel->SetSizer(setNoRow); - appendRow(grid, "Set #", setNoPanel); + grid->Add(setNoLabel_, 0, wxALIGN_CENTER_VERTICAL); + grid->Add(setNoPanel, 1, wxEXPAND); if (auto* setCombo = setComboControl()) { setCombo->Bind(wxEVT_COMBOBOX, &PokemonCardEditDialog::onSetSelectionChanged, this); } } -std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) { - std::string out(setNo); - const auto slash = out.find('/'); - if (slash != std::string::npos) { - out.resize(slash); +void PokemonCardEditDialog::onRegionSwitch(wxCommandEvent&) { + const PokemonRegion next = + (regionSwitch_ && regionSwitch_->GetValue()) ? PokemonRegion::Asia + : PokemonRegion::West; + applyRegion(next, true); +} + +void PokemonCardEditDialog::applyRegion(PokemonRegion region, bool clearSetIfMissing) { + mutableCard().region = region; + if (regionSwitch_ && regionSwitch_->GetValue() != (region == PokemonRegion::Asia)) { + regionSwitch_->SetValue(region == PokemonRegion::Asia, false); } - return out; + + if (!languageAllowedForRegion(mutableCard().language, region)) { + mutableCard().language = defaultLanguageForPokemonRegion(region); + } + + const std::vector* sets = + region == PokemonRegion::Asia + ? (asiaSets_ != nullptr ? asiaSets_ : &kEmptySets) + : (westSets_ != nullptr ? westSets_ : &kEmptySets); + setPreloadedSetsPointer(sets); + + const std::string prevSetId = mutableCard().set.id; + bool setStillValid = false; + for (const auto& s : availableSets()) { + if (s.id == prevSetId) { + setStillValid = true; + mutableCard().set = s; + break; + } + } + if (clearSetIfMissing && !setStillValid) { + mutableCard().set = Set{}; + mutableCard().setNo.clear(); + selectedSetNo_.clear(); + if (setNoCtrl_) setNoCtrl_->ChangeValue(wxEmptyString); + } + + refreshSetAndLanguageChoices(); + clearCachedPrintVariants(); + refreshSetNoRowMode(); + scheduleDeferredVariantPrefetch(); + Layout(); + if (GetSizer()) Fit(); +} + +std::string PokemonCardEditDialog::normalizedStoredSetNo(std::string_view setNo) { + return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo); } std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) { @@ -84,20 +185,78 @@ std::string PokemonCardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctr return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8)); } +bool PokemonCardEditDialog::isUnnumberedPromoSelected() const { + if (currentRegion() != PokemonRegion::Asia) return false; + if (const auto* set = selectedSetFromControls()) { + return set->id == kUnnumberedPromoSetId; + } + return constCard().set.id == kUnnumberedPromoSetId; +} + +std::string PokemonCardEditDialog::currentRingSetNo() const { + if (!uniqueSetNos_.empty() && setNoRingPos_ < uniqueSetNos_.size()) { + return uniqueSetNos_[setNoRingPos_]; + } + return selectedSetNo_; +} + +void PokemonCardEditDialog::applySelectedSetNo(std::string setNo) { + selectedSetNo_ = normalizedStoredSetNo(setNo); + if (setNoCtrl_ && setNoCtrl_->IsShown()) { + setNoCtrl_->ChangeValue(wxString::FromUTF8(selectedSetNo_.c_str())); + } +} + +void PokemonCardEditDialog::refreshSetNoRowMode() { + const bool unnumbered = isUnnumberedPromoSelected(); + if (setNoLabel_) { + setNoLabel_->SetLabelText(unnumbered ? wxString::FromUTF8("Print") + : wxString::FromUTF8("Set #")); + } + if (setNoCtrl_) { + setNoCtrl_->Show(!unnumbered); + if (!unnumbered && !selectedSetNo_.empty()) { + setNoCtrl_->ChangeValue(wxString::FromUTF8(selectedSetNo_.c_str())); + } + if (auto* parent = setNoCtrl_->GetParent()) { + parent->Layout(); + } + } + if (!unnumbered) { + closeUnnumberedPreview(); + } + Layout(); + if (GetSizer()) Fit(); +} + void PokemonCardEditDialog::readExtraFromCard() { clearCachedPrintVariants(); + selectedSetNo_ = normalizedStoredSetNo(constCard().setNo); if (setNoCtrl_) { - setNoCtrl_->ChangeValue( - wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str())); + setNoCtrl_->ChangeValue(wxString::FromUTF8(selectedSetNo_.c_str())); } if (holoCheck_) holoCheck_->SetValue(constCard().holo); if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition); if (signedCheck_) signedCheck_->SetValue(constCard().signed_); if (alteredCheck_) alteredCheck_->SetValue(constCard().altered); + if (regionSwitch_) { + regionSwitch_->SetValue(constCard().region == PokemonRegion::Asia, false); + } + refreshSetNoRowMode(); } void PokemonCardEditDialog::writeExtraToCard() { - if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_); + mutableCard().region = + (regionSwitch_ && regionSwitch_->GetValue()) ? PokemonRegion::Asia + : PokemonRegion::West; + if (isUnnumberedPromoSelected()) { + mutableCard().setNo = normalizedStoredSetNo(selectedSetNo_); + } else if (setNoCtrl_) { + selectedSetNo_ = storedSetNoFromControls(setNoCtrl_); + mutableCard().setNo = selectedSetNo_; + } else { + mutableCard().setNo = normalizedStoredSetNo(selectedSetNo_); + } if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked(); if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked(); if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked(); @@ -106,9 +265,11 @@ void PokemonCardEditDialog::writeExtraToCard() { void PokemonCardEditDialog::clearCachedPrintVariants() { ++variantFetchEpoch_; + ++previewFetchEpoch_; cachedVariants_.clear(); uniqueSetNos_.clear(); setNoRingPos_ = 0; + closeUnnumberedPreview(); refreshVariantNextControls(); } @@ -142,9 +303,10 @@ void PokemonCardEditDialog::requestVariantsAsync(unsigned capturedEpoch, auto state = variantFetchState_; CardPreviewService* svc = &cardPreview_; PokemonCardEditDialog* self = this; + const Game game = backendGame(); std::thread([state, svc, self, capturedEpoch, name = std::move(name), - setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog]() { - auto detected = svc->detectPrintVariants(Game::Pokemon, name, setId); + setId = std::move(setId), fillSetNoOnSuccess, showFailureDialog, game]() { + auto detected = svc->detectPrintVariants(game, name, setId); wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected), fillSetNoOnSuccess, showFailureDialog]() mutable { if (!state->alive.load()) return; @@ -173,14 +335,20 @@ void PokemonCardEditDialog::applyDetectedVariants(unsigned capturedEpoch, } cachedVariants_ = std::move(detected).value(); - if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) { - setNoCtrl_->ChangeValue( - wxString::FromUTF8(cachedVariants_.front().setNo.c_str())); + if (fillSetNoOnSuccess && !cachedVariants_.empty()) { + applySelectedSetNo(cachedVariants_.front().setNo); } rebuildVariantRingFromCache(); syncRingPositionToControls(); refreshVariantNextControls(); + if (isUnnumberedPromoSelected() && !uniqueSetNos_.empty()) { + ensureUnnumberedPreviewOpen(); + refreshUnnumberedPreview(); + if (unnumberedPreview_ != nullptr) { + unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1); + } + } } void PokemonCardEditDialog::rebuildVariantRingFromCache() { @@ -197,8 +365,12 @@ void PokemonCardEditDialog::rebuildVariantRingFromCache() { } void PokemonCardEditDialog::syncRingPositionToControls() { - if (!setNoCtrl_) return; - const std::string current = storedSetNoFromControls(setNoCtrl_); + const std::string current = isUnnumberedPromoSelected() + ? normalizedStoredSetNo(selectedSetNo_) + : storedSetNoFromControls(setNoCtrl_); + if (!isUnnumberedPromoSelected() && setNoCtrl_) { + selectedSetNo_ = current; + } setNoRingPos_ = 0; for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) { if (uniqueSetNos_[i] == current) { @@ -206,26 +378,66 @@ void PokemonCardEditDialog::syncRingPositionToControls() { break; } } + if (!uniqueSetNos_.empty() && selectedSetNo_.empty()) { + applySelectedSetNo(uniqueSetNos_[setNoRingPos_]); + } } void PokemonCardEditDialog::refreshVariantNextControls() { if (!nextSetNoBtn_) return; - nextSetNoBtn_->Show(uniqueSetNos_.size() > 1); + const bool showNext = uniqueSetNos_.size() > 1; + nextSetNoBtn_->Show(showNext); + if (showNext) { + if (isUnnumberedPromoSelected()) { + const std::size_t i = setNoRingPos_ + 1; + const std::size_t n = uniqueSetNos_.size(); + nextSetNoBtn_->SetLabel(wxString::Format("Next (%zu/%zu)", i, n)); + } else { + const std::string setNo = currentRingSetNo(); + if (setNo.empty()) { + nextSetNoBtn_->SetLabel("Next"); + } else { + nextSetNoBtn_->SetLabel( + wxString::Format("Next (%s)", wxString::FromUTF8(setNo.c_str()))); + } + } + } else { + nextSetNoBtn_->SetLabel("Next"); + } + if (auto* parent = nextSetNoBtn_->GetParent()) { + parent->Layout(); + } Layout(); if (GetSizer()) Fit(); + if (unnumberedPreview_ != nullptr) { + unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1); + } } void PokemonCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) { autoDetectFromApi(); } -void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) { - if (uniqueSetNos_.size() <= 1) return; - setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size(); - if (setNoCtrl_) { - setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str())); - } +void PokemonCardEditDialog::stepVariantRing(int delta) { + if (uniqueSetNos_.size() <= 1 || delta == 0) return; + const auto n = static_cast(uniqueSetNos_.size()); + auto pos = static_cast(setNoRingPos_) + delta; + pos %= n; + if (pos < 0) pos += n; + setNoRingPos_ = static_cast(pos); + applySelectedSetNo(uniqueSetNos_[setNoRingPos_]); refreshVariantNextControls(); + if (isUnnumberedPromoSelected()) { + ensureUnnumberedPreviewOpen(); + refreshUnnumberedPreview(); + if (unnumberedPreview_ != nullptr) { + unnumberedPreview_->setNavigationEnabled(true); + } + } +} + +void PokemonCardEditDialog::onNextSetNo(wxCommandEvent&) { + stepVariantRing(1); } void PokemonCardEditDialog::autoDetectFromApi() { @@ -248,8 +460,115 @@ void PokemonCardEditDialog::autoDetectFromApi() { void PokemonCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) { clearCachedPrintVariants(); + refreshSetNoRowMode(); scheduleDeferredVariantPrefetch(); ev.Skip(); } +void PokemonCardEditDialog::closeUnnumberedPreview() { + ++previewFetchEpoch_; + if (unnumberedPreview_ != nullptr) { + unnumberedPreview_->Destroy(); + unnumberedPreview_ = nullptr; + } +} + +void PokemonCardEditDialog::ensureUnnumberedPreviewOpen() { + if (!isUnnumberedPromoSelected()) { + closeUnnumberedPreview(); + return; + } + if (unnumberedPreview_ != nullptr) { + unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1); + unnumberedPreview_->repositionBesideParent(); + return; + } + + unnumberedPreview_ = new VariantImagePreviewDialog(this); + const Theme theme = inferThemeFromWindow(this); + applyThemeToWindowTree(unnumberedPreview_, paletteForTheme(theme), theme); + unnumberedPreview_->Bind(wxEVT_CLOSE_WINDOW, [this](wxCloseEvent& ev) { + unnumberedPreview_ = nullptr; + ev.Skip(); + }); + unnumberedPreview_->Bind(EVT_VARIANT_PREVIEW_PREV, [this](wxCommandEvent&) { + stepVariantRing(-1); + }); + unnumberedPreview_->Bind(EVT_VARIANT_PREVIEW_NEXT, [this](wxCommandEvent&) { + stepVariantRing(1); + }); + unnumberedPreview_->setNavigationEnabled(uniqueSetNos_.size() > 1); + unnumberedPreview_->Show(true); + unnumberedPreview_->repositionBesideParent(); +} + +void PokemonCardEditDialog::refreshUnnumberedPreview() { + if (!isUnnumberedPromoSelected() || uniqueSetNos_.empty()) { + closeUnnumberedPreview(); + return; + } + ensureUnnumberedPreviewOpen(); + if (unnumberedPreview_ == nullptr) return; + + syncCardFromControls(); + const auto& card = constCard(); + const std::string setNo = currentRingSetNo(); + if (card.name.empty() || setNo.empty()) { + unnumberedPreview_->clearImage(); + unnumberedPreview_->setCaption(wxString::FromUTF8("Enter a card name")); + return; + } + + const std::size_t i = setNoRingPos_ + 1; + const std::size_t n = uniqueSetNos_.size(); + unnumberedPreview_->setCaption( + wxString::Format("%s (%zu/%zu)", + wxString::FromUTF8(card.name.c_str()), i, n)); + + const unsigned epoch = ++previewFetchEpoch_; + requestUnnumberedPreviewAsync(epoch, card.name, kUnnumberedPromoSetId, setNo, setNoRingPos_, + uniqueSetNos_.size()); +} + +void PokemonCardEditDialog::requestUnnumberedPreviewAsync(unsigned capturedEpoch, + std::string name, + std::string setId, + std::string setNo, + std::size_t ringIndex, + std::size_t ringCount) { + auto state = variantFetchState_; + CardPreviewService* svc = &cardPreview_; + PokemonCardEditDialog* self = this; + std::thread([state, svc, self, capturedEpoch, name = std::move(name), + setId = std::move(setId), setNo = std::move(setNo), ringIndex, + ringCount]() { + auto bytes = svc->fetchPreviewBytes(Game::JapanesePokemon, name, setId, setNo); + std::string payload; + bool usedFallback = false; + if (bytes) { + payload = std::move(bytes).value(); + } else { + auto fallback = svc->fetchImageBytesByUrl(kJapanesePokemonCardBackUrl); + if (fallback) { + payload = std::move(fallback).value(); + usedFallback = true; + } + } + wxTheApp->CallAfter([state, self, capturedEpoch, payload = std::move(payload), + name, ringIndex, ringCount, usedFallback]() mutable { + if (!state->alive.load()) return; + if (capturedEpoch != self->previewFetchEpoch_) return; + if (self->unnumberedPreview_ == nullptr) return; + + self->unnumberedPreview_->setImageBytes(payload); + if (usedFallback) { + self->unnumberedPreview_->setCaption( + wxString::Format("%s (%zu/%zu) — preview unavailable", + wxString::FromUTF8(name.c_str()), + ringIndex + 1, ringCount)); + } + }); + }).detach(); +} + } // namespace ccm::ui diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index a123629..66d2fc2 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -31,18 +31,22 @@ void PokemonGameView::ensureSetsLoaded() { if (attemptedInitialSetLoad_) return; attemptedInitialSetLoad_ = true; - auto cached = sets_.getSets(Game::Pokemon); - if (cached) { - setsCache_ = std::move(cached).value(); - if (!setsCache_.empty()) return; - } else { - setsCache_.clear(); - } + auto loadOrRefresh = [this](Game game, std::vector& cache) { + auto cached = sets_.getSets(game); + if (cached) { + cache = std::move(cached).value(); + if (!cache.empty()) return; + } else { + cache.clear(); + } + auto refreshed = sets_.updateSets(game); + if (refreshed) { + cache = std::move(refreshed).value(); + } + }; - auto refreshed = sets_.updateSets(Game::Pokemon); - if (refreshed) { - setsCache_ = std::move(refreshed).value(); - } + loadOrRefresh(Game::Pokemon, setsCacheWest_); + loadOrRefresh(Game::JapanesePokemon, setsCacheAsia_); } wxPanel* PokemonGameView::listPanel(wxWindow* parent) { @@ -81,13 +85,20 @@ void PokemonGameView::refreshCollection() { if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); } -const std::vector& PokemonGameView::setsForDialog() { +const std::vector& PokemonGameView::setsForDialog(PokemonRegion region) { ensureSetsLoaded(); - if (!setsCache_.empty()) return setsCache_; + if (region == PokemonRegion::Asia) { + if (!setsCacheAsia_.empty()) return setsCacheAsia_; + auto loaded = sets_.getSets(Game::JapanesePokemon); + if (loaded) setsCacheAsia_ = std::move(loaded).value(); + else setsCacheAsia_.clear(); + return setsCacheAsia_; + } + if (!setsCacheWest_.empty()) return setsCacheWest_; auto loaded = sets_.getSets(Game::Pokemon); - if (loaded) setsCache_ = std::move(loaded).value(); - else setsCache_.clear(); - return setsCache_; + if (loaded) setsCacheWest_ = std::move(loaded).value(); + else setsCacheWest_.clear(); + return setsCacheWest_; } void PokemonGameView::onAddCard(wxWindow* parentWindow) { @@ -98,11 +109,13 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) { } PokemonCard fresh; fresh.amount = 1; + fresh.region = PokemonRegion::West; fresh.language = Language::English; fresh.condition = Condition::NearMint; PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh, - &setsForDialog()); + &setsForDialog(PokemonRegion::West), + &setsForDialog(PokemonRegion::Asia)); themeModalDialog(&dlg, config_.current().theme); CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; @@ -147,7 +160,8 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) { return; } PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel, - &setsForDialog()); + &setsForDialog(PokemonRegion::West), + &setsForDialog(PokemonRegion::Asia)); themeModalDialog(&dlg, config_.current().theme); CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; @@ -181,15 +195,46 @@ void PokemonGameView::onDeleteCard(wxWindow* parentWindow) { } std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) { - auto out = sets_.updateSets(Game::Pokemon); - if (!out) { - showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(), - "Error", wxOK | wxICON_ERROR); + auto westOut = sets_.updateSets(Game::Pokemon); + auto asiaOut = sets_.updateSets(Game::JapanesePokemon); + + if (westOut) { + setsCacheWest_ = westOut.value(); + } + if (asiaOut) { + setsCacheAsia_ = asiaOut.value(); + } + + if (!westOut && !asiaOut) { + showThemedMessageDialog( + parentWindow, + "Failed to update West sets: " + westOut.error() + + "\nFailed to update Asia sets: " + asiaOut.error(), + "Error", wxOK | wxICON_ERROR); return "Update failed"; } - setsCache_ = out.value(); - showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Pokemon sets.", - "Sets updated", wxOK | wxICON_INFORMATION); + if (!westOut) { + showThemedMessageDialog( + parentWindow, + "Updated " + std::to_string(asiaOut.value().size()) + + " Asia Pokemon sets, but West failed: " + westOut.error(), + "Sets partially updated", wxOK | wxICON_WARNING); + return "Pokemon sets partially updated."; + } + if (!asiaOut) { + showThemedMessageDialog( + parentWindow, + "Updated " + std::to_string(westOut.value().size()) + + " West Pokemon sets, but Asia failed: " + asiaOut.error(), + "Sets partially updated", wxOK | wxICON_WARNING); + return "Pokemon sets partially updated."; + } + + showThemedMessageDialog( + parentWindow, + "Updated " + std::to_string(westOut.value().size()) + " West and " + + std::to_string(asiaOut.value().size()) + " Asia Pokemon sets.", + "Sets updated", wxOK | wxICON_INFORMATION); return "Pokemon sets updated."; } diff --git a/ui_wx/src/PokemonSelectedCardPanel.cpp b/ui_wx/src/PokemonSelectedCardPanel.cpp index ceecdde..1fa4295 100644 --- a/ui_wx/src/PokemonSelectedCardPanel.cpp +++ b/ui_wx/src/PokemonSelectedCardPanel.cpp @@ -11,6 +11,7 @@ enum PokemonDetailKey : int { kName = 0, kSet, kSetNo, + kRegion, kLanguage, kCondition, kAmount, @@ -34,6 +35,7 @@ PokemonSelectedCardPanel::declareDetailRows() const { {"Name", kName, "(no card selected)"}, {"Set", kSet, ""}, {"Set #", kSetNo, ""}, + {"Region", kRegion, ""}, {"Language", kLanguage, ""}, {"Condition", kCondition, ""}, {"Amount", kAmount, ""}, @@ -56,6 +58,7 @@ std::string PokemonSelectedCardPanel::detailValueFor(const PokemonCard& card, case kName: return card.name; case kSet: return card.set.name; case kSetNo: return card.setNo; + case kRegion: return std::string(to_string(card.region)); case kLanguage: return std::string(to_string(card.language)); case kCondition: return std::string(to_string(card.condition)); case kAmount: return std::to_string(card.amount); diff --git a/ui_wx/src/SettingsDialog.cpp b/ui_wx/src/SettingsDialog.cpp index a941627..c8c94c9 100644 --- a/ui_wx/src/SettingsDialog.cpp +++ b/ui_wx/src/SettingsDialog.cpp @@ -14,10 +14,11 @@ namespace { wxString displayLabelForGame(Game g) { switch (g) { - case Game::Magic: return "Magic"; - case Game::Pokemon: return "Pokemon"; - case Game::YuGiOh: return "Yu-Gi-Oh!"; - case Game::DigiBattle99: return "Digimon (Digi-Battle)"; + case Game::Magic: return "Magic"; + case Game::Pokemon: return "Pokemon"; + case Game::YuGiOh: return "Yu-Gi-Oh!"; + case Game::DigiBattle99: return "Digimon (Digi-Battle)"; + case Game::JapanesePokemon: return "Pokemon"; // internal; not in allGames() } return wxString::FromUTF8(to_string(g).data()); } diff --git a/ui_wx/src/VariantImagePreviewDialog.cpp b/ui_wx/src/VariantImagePreviewDialog.cpp new file mode 100644 index 0000000..50183b1 --- /dev/null +++ b/ui_wx/src/VariantImagePreviewDialog.cpp @@ -0,0 +1,176 @@ +#include "ccm/ui/VariantImagePreviewDialog.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace ccm::ui { + +wxDEFINE_EVENT(EVT_VARIANT_PREVIEW_PREV, wxCommandEvent); +wxDEFINE_EVENT(EVT_VARIANT_PREVIEW_NEXT, wxCommandEvent); + +class VariantImagePreviewDialog::ImageCanvas : public wxPanel { +public: + explicit ImageCanvas(wxWindow* parent) : wxPanel(parent, wxID_ANY) { + SetBackgroundStyle(wxBG_STYLE_PAINT); + Bind(wxEVT_PAINT, &ImageCanvas::onPaint, this); + Bind(wxEVT_SIZE, [this](wxSizeEvent& ev) { + Refresh(); + ev.Skip(); + }); + } + + void setImage(const wxImage& img) { + original_ = img; + cachedScaled_ = wxBitmap(); + cachedScaledFor_ = wxSize(-1, -1); + Refresh(); + } + + void clear() { + original_ = wxImage(); + cachedScaled_ = wxBitmap(); + cachedScaledFor_ = wxSize(-1, -1); + Refresh(); + } + +private: + void onPaint(wxPaintEvent&) { + wxPaintDC dc(this); + dc.Clear(); + if (!original_.IsOk()) return; + + const wxSize ws = GetClientSize(); + if (ws.GetWidth() <= 0 || ws.GetHeight() <= 0) return; + + const double scale = std::min( + static_cast(ws.GetWidth()) / original_.GetWidth(), + static_cast(ws.GetHeight()) / original_.GetHeight()); + const int w = std::max(1, static_cast(original_.GetWidth() * scale)); + const int h = std::max(1, static_cast(original_.GetHeight() * scale)); + const wxSize scaledSize(w, h); + if (!cachedScaled_.IsOk() || cachedScaledFor_ != scaledSize) { + wxImage scaled = original_.Scale(w, h, wxIMAGE_QUALITY_HIGH); + cachedScaled_ = wxBitmap(scaled); + cachedScaledFor_ = scaledSize; + } + dc.DrawBitmap(cachedScaled_, + (ws.GetWidth() - w) / 2, + (ws.GetHeight() - h) / 2, + true); + } + + wxImage original_; + wxBitmap cachedScaled_; + wxSize cachedScaledFor_{-1, -1}; +}; + +VariantImagePreviewDialog::VariantImagePreviewDialog(wxWindow* parent) + : wxDialog(parent, wxID_ANY, "Print preview", + wxDefaultPosition, wxSize(280, 420), + wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER | wxSTAY_ON_TOP) { + auto* root = new wxBoxSizer(wxVERTICAL); + + imageHost_ = new ImageCanvas(this); + root->Add(imageHost_, 1, wxEXPAND | wxALL, 6); + + caption_ = new wxStaticText(this, wxID_ANY, ""); + root->Add(caption_, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 6); + + auto* nav = new wxBoxSizer(wxHORIZONTAL); + prevButton_ = new wxButton(this, wxID_ANY, "<< Prev"); + nextButton_ = new wxButton(this, wxID_ANY, "Next >>"); + nav->Add(prevButton_, 0, wxRIGHT, 6); + nav->Add(nextButton_, 0); + root->Add(nav, 0, wxALIGN_CENTER_HORIZONTAL | wxLEFT | wxRIGHT | wxBOTTOM, 6); + + prevButton_->Bind(wxEVT_BUTTON, &VariantImagePreviewDialog::onPrev, this); + nextButton_->Bind(wxEVT_BUTTON, &VariantImagePreviewDialog::onNext, this); + + SetSizer(root); + Layout(); + repositionBesideParent(); +} + +void VariantImagePreviewDialog::repositionBesideParent() { + wxWindow* parent = GetParent(); + if (parent == nullptr) return; + + const wxRect parentScreen = parent->GetScreenRect(); + const wxSize size = GetSize(); + int x = parentScreen.GetRight() + 20; + int y = parentScreen.GetTop(); + + const int displayIdx = wxDisplay::GetFromWindow(parent); + if (displayIdx != wxNOT_FOUND) { + const wxRect work = wxDisplay(displayIdx).GetClientArea(); + if (x + size.GetWidth() > work.GetRight()) { + x = std::max(work.GetLeft(), work.GetRight() - size.GetWidth()); + } + if (y + size.GetHeight() > work.GetBottom()) { + y = std::max(work.GetTop(), work.GetBottom() - size.GetHeight()); + } + if (x < work.GetLeft()) x = work.GetLeft(); + if (y < work.GetTop()) y = work.GetTop(); + } + + SetPosition(wxPoint(x, y)); +} + +void VariantImagePreviewDialog::setCaption(const wxString& caption) { + if (caption_) { + caption_->SetLabelText(caption); + Layout(); + } +} + +void VariantImagePreviewDialog::setImageBytes(std::string_view bytes) { + if (!imageHost_) return; + if (bytes.empty()) { + clearImage(); + return; + } + + wxMemoryInputStream stream(bytes.data(), bytes.size()); + wxImage img; + bool decoded = false; + { + wxLogNull suppressPngWarnings; + decoded = img.LoadFile(stream, wxBITMAP_TYPE_ANY); + } + if (!decoded || !img.IsOk()) { + clearImage(); + return; + } + imageHost_->setImage(img); +} + +void VariantImagePreviewDialog::clearImage() { + if (imageHost_) { + imageHost_->clear(); + } +} + +void VariantImagePreviewDialog::setNavigationEnabled(bool enabled) { + if (prevButton_) prevButton_->Enable(enabled); + if (nextButton_) nextButton_->Enable(enabled); +} + +void VariantImagePreviewDialog::onPrev(wxCommandEvent&) { + wxCommandEvent ev(EVT_VARIANT_PREVIEW_PREV, GetId()); + ev.SetEventObject(this); + ProcessWindowEvent(ev); +} + +void VariantImagePreviewDialog::onNext(wxCommandEvent&) { + wxCommandEvent ev(EVT_VARIANT_PREVIEW_NEXT, GetId()); + ev.SetEventObject(this); + ProcessWindowEvent(ev); +} + +} // namespace ccm::ui