Compare commits

...

14 Commits

Author SHA1 Message Date
Sebastian Dine 2eb7c59f78 Minor: New Game Yu-Gi-Oh! Bandai (#22) 2026-07-30 14:51:53 +02:00
Sebastian Dine 7cf25d671f convenience features (#21) 2026-07-24 08:56:53 +02:00
Sebastian Dine ab0e3c5ae2 Fix: replace api.pokemontcg.io with api.tcgdex.net (#20) 2026-07-23 17:58:00 +02:00
Sebastian Dine 9917e364c1 Minor: Set completion tracking (#19) 2026-07-23 10:29:56 +02:00
Sebastian Dine c9e6bc2b6b Minor: Add Asian Pokemon Card Support (#18) 2026-07-22 11:13:42 +02:00
Sebastian Dine e5c830e945 minor: New Game Digimon Digi-Battle (#17)
* digimon digi battle added to supported games

* sonarqube update

* readme update

---------

Co-authored-by: sdine <sdine@sdine.com>
2026-07-19 12:06:57 +02:00
Sebastian Dine 42926f2fb5 patch: Feature/ygo set selection (#16) 2026-05-13 21:16:41 +02:00
Sebastian Dine 8a50e8daba patch: Feature/pkm autodetect (#15) 2026-05-12 15:49:37 +02:00
Sebastian Dine 98f2575b5a patch: Patch/ygo 25th set 2 (#14) 2026-05-11 11:27:32 +02:00
Sebastian Dine d6c7f60aee patch: Patch/ygo set 25th (#13) 2026-05-11 09:51:19 +02:00
Sebastian Dine 7935f2b18e fix: Fix/ci cd issues (#12) 2026-05-11 08:42:07 +02:00
Sebastian Dine 5805101d24 fix: unittests 2026-05-10 12:18:45 +02:00
Sebastian Dine 8b7d45fdac patch: Patch/misc (#10)
* yugioh adjustments

* test coverage
2026-05-10 11:43:25 +02:00
Sebastian Dine c1d42bdadd patch: sonarqube fixes. 2026-05-09 19:57:02 +02:00
233 changed files with 94899 additions and 699 deletions
+2
View File
@@ -38,6 +38,8 @@ GitHub Actions workflows for CI, release automation, and policy checks.
- Prefer minimal, surgical edits; avoid large workflow rewrites unless requested.
- Reusable workflows should declare explicit `workflow_call` inputs for required context (e.g., version, merge SHA).
- Sonar coverage steps that use `gcovr` must exclude third-party build trees at discovery time with `--exclude-directories` (for example `build/_deps`) so gcov does not process dependency `.gcda` files.
- The Sonar scan step passes `-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**` so the coverage quality gate reflects **`ccm_core_tests`** only (wx UI and the composition root are not executed under test). `sonar.sources` stays `core,ui_wx,app`.
- The Sonar scan also sets `-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp` so intentionally parallel wx per-game UI scaffolding does not dominate the duplication quality gate.
- For Linux Sonar coverage jobs, keep compiler and gcov toolchain aligned; because `cmake/Toolchain.cmake` prefers Clang by default, set `-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++` explicitly in the coverage configure step when using gcovr default `gcov`.
- Keep `permissions` least-privilege:
- reusable build workflows: `contents: read`
+4 -1
View File
@@ -55,9 +55,10 @@ jobs:
--sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/"
--exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5
uses: SonarSource/sonarqube-scan-action@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -69,6 +70,8 @@ jobs:
-Dsonar.sources=core,ui_wx,app
-Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
linux:
name: Linux build + tests
+4 -1
View File
@@ -55,9 +55,10 @@ jobs:
--sonarqube build/sonarqube-coverage.xml
--exclude "build/_deps/"
--exclude-directories "build/_deps"
--exclude "^tests/"
- name: SonarQube Cloud scan
uses: SonarSource/sonarqube-scan-action@v5
uses: SonarSource/sonarqube-scan-action@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@@ -69,6 +70,8 @@ jobs:
-Dsonar.sources=core,ui_wx,app
-Dsonar.cfamily.compile-commands=build/compile_commands.json
-Dsonar.coverageReportPaths=build/sonarqube-coverage.xml
-Dsonar.coverage.exclusions=**/ui_wx/**,**/app/**
-Dsonar.cpd.exclusions=**/ui_wx/src/*GameView.cpp,**/ui_wx/src/*CardEditDialog.cpp,**/ui_wx/src/*SelectedCardPanel.cpp
compute-version:
name: Determine semantic version
+3
View File
@@ -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/
+21 -5
View File
@@ -5,7 +5,7 @@ C++ desktop implementation (originally based on a Tauri Rust+TS version) — sin
## Project structure
- `core/``ccm_core` static library. UI-agnostic domain, ports, services, infra adapters. **Never** depends on wxWidgets. See `core/AGENTS.md`.
- `ui_wx/``ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` (Yu-Gi-Oh! offline preview fallback); `app/CMakeLists.txt` copies it to `<exeDir>/assets/` when linking `ccm`.
- `ui_wx/``ccm_ui_wx` static library. The only place that touches wxWidgets. See `ui_wx/AGENTS.md`. Ships `ui_wx/assets/ygo_card_back.png` and `ui_wx/assets/digibattle99_card_back.png` (offline preview fallbacks); `app/CMakeLists.txt` copies them to `<exeDir>/assets/` when linking `ccm`.
- `app/``ccm` executable (composition root). Wires concrete adapters into services. See `app/AGENTS.md`.
- `tests/``ccm_core_tests` doctest binary. Pure-logic tests against in-memory fakes. See `tests/AGENTS.md`.
- `docs/` — long-form developer documentation. Start with `docs/adding-a-new-game.md` for the canonical end-to-end procedure for extending the app with a new TCG. See `docs/AGENTS.md`.
@@ -52,9 +52,17 @@ Run from the **workspace root**.
- Run the app:
`./build/bin/ccm3` (`.\build\bin\ccm3.exe` on Windows)
- Run tests (CCM_BUILD_TESTS defaults to ON):
`ctest --test-dir build --output-on-failure` — current baseline: **180 tests, all green**.
`ctest --test-dir build --output-on-failure` — current baseline: **226 tests, all green**.
- Build tests only:
`cmake --build build --target ccm_core_tests`
- Local coverage env setup (one-time, Windows/MSYS2):
`python -m venv .venv_cov`
`& "P:/msys2/msys64/usr/bin/pacman.exe" -S --noconfirm mingw-w64-ucrt-x86_64-python-lxml mingw-w64-ucrt-x86_64-python-gcovr`
- Coverage check (core-focused):
`cmake -S . -B build-cov -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Debug -DCCM_BUILD_TESTS=ON -DCMAKE_C_FLAGS=--coverage -DCMAKE_CXX_FLAGS=--coverage -DCMAKE_EXPORT_COMPILE_COMMANDS=ON`
`cmake --build build-cov --target ccm_core_tests --parallel`
`ctest --test-dir build-cov --output-on-failure`
`& "P:/msys2/msys64/ucrt64/bin/gcovr.exe" -r . --object-directory build-cov --filter "core/" --exclude "build/_deps/" --exclude "build-cov/_deps/" --exclude-directories "build/_deps" --exclude-directories "build-cov/_deps" --print-summary`
> **Windows runtime note**: `cpr` is built as a shared library, so `build/bin/` ends up with `libcpr.dll`, `libcurl.dll`, `libzlib.dll` next to `ccm3.exe`. With MinGW-w64 you also need `libgcc_s_seh-1.dll` and `libstdc++-6.dll` from your MSYS2 UCRT64 `bin/` on `PATH` (or copied alongside the exe) to launch from Explorer.
>
@@ -76,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 `<exeDir>/.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`) 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.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
@@ -84,7 +92,7 @@ Run from the **workspace root**.
- Treat UI text from domain/services as UTF-8 and convert explicitly at wx boundaries (`wxString::FromUTF8(...)` for display, `ToStdString(wxConvUTF8)` for write-back); do not rely on implicit `std::string` conversions on Windows.
- For dialogs (`wxDialog`) and frames (`wxFrame`), apply title-bar dark mode through top-level-window handling (not frame-only handling), otherwise modal window headers stay light.
- The `wxListCtrl` native header can ignore dark hints; if native theming is unreliable, use a custom themed header row and preserve key UX parity (single-click sort, edge-drag resize, divider double-click autosize).
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven, and for critical fields (for example the top-right filter box) enforce colors through `WM_CTLCOLOREDIT` handling in `MainFrame` when needed.
- Do **not** apply `Explorer` class theming to `wxTextCtrl` in dark mode; some Windows builds force black typed text. Keep edit controls palette-driven via `applyPaletteToTextCtrl` / `hardenTextCtrlNativeTheme` in `Theme.cpp` (opt out of immersive dark mode + parent `WM_CTLCOLOREDIT` subclass — that message goes to the EDIT's parent, not `MainFrame`).
- Theme modal dialogs explicitly before `ShowModal()` (Settings, Create/Edit, image viewer, etc.) so they don't inherit mismatched defaults from Windows.
- For button hover/pressed contrast fixes in dark theme, prefer explicit state handling in `Theme.cpp`; native Windows button states can override wx colors and produce unreadable white-on-white combinations.
- Keep button theming state dynamic across theme switches (Dark <-> Light). Avoid lambdas that permanently capture old theme colors or behavior; stale handlers can make light-mode buttons look wrong.
@@ -96,11 +104,19 @@ Run from the **workspace root**.
- After modifying a domain type's fields or JSON layout you **must** update the matching round-trip test in `tests/domain_json_tests.cpp` and re-run tests.
- After adding a new `.cpp` to `core/` or `ui_wx/` you **must** add it to that package's `CMakeLists.txt`. There is no glob.
- After adding a new dependency you **must** verify its license is compatible with this repository's MIT license before merging.
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files.
- After changing SonarQube coverage generation, keep dependency build outputs excluded at gcov discovery time (for example `gcovr --exclude-directories "build/_deps"`); output-only excludes are not enough for third-party `.gcda` files. The Sonar scan uses `sonar.coverage.exclusions` for `**/ui_wx/**` and `**/app/**` so the coverage percentage matches the hermetic `ccm_core_tests` surface (`core/`); analyzed sources are unchanged for other Sonar metrics.
- For new code, keep duplication to an absolute minimum: prefer extracting shared helpers/components instead of copy/paste so Sonar duplication stays comfortably below the quality gate.
- For new code, add or update unit tests so behavior is covered and overall test coverage remains high. Exercise both outcomes of meaningful conditionals (success vs error, empty vs non-empty, cache hit vs miss, `NotFound` vs `Transient`, early return vs fall-through), not only the happy path — Sonar condition coverage on `core/` is a separate signal from line coverage.
- For new code, run the local coverage workflow (`build-cov` + `gcovr` with `--filter "core/"`) and keep core line coverage at or above 80% before opening or updating a PR. When checking coverage locally, also review branch/condition metrics (for example `gcovr ... --txt-metric branch` or Sonar's condition coverage on the same `core/` surface); there is no repo-wide condition threshold in CI yet — use Sonar's per-file condition list to prioritize gaps.
- After adding a new game module you **must**: (1) extend `Game` enum + string mappings in `core/include/ccm/domain/Enums.hpp`, (2) register the module in `app/main.cpp`, (3) add a directory mapping in `app/main.cpp::dirNameForGame`, (4) implement an `IGameView` derived class (or `<Name>GameView`) and add it to `AppContext::gameViews` in the composition root.
- After changing the per-game seams (`IGameModule`, `IGameView`, the `BaseCard*Panel` template hooks) you **must** update `docs/adding-a-new-game.md` so the canonical "add a new game" walkthrough stays in sync with the code.
- After changing `formatTextForFs` or `parseIndexFromFilename` you **must** update `tests/fs_names_tests.cpp` — these functions exist to stay byte-compatible with the original Rust `util/fs.rs`.
## Agent collaboration (Cursor / AI)
- **Never** `git commit` or `git push` unless the user **explicitly** asked you to commit and/or push (e.g. “commit this”, “push to origin”). Preparing diffs and suggesting commands is fine; performing those Git writes without explicit instruction is not.
- **Never** check out another branch **to change it** unless the user **explicitly** asked you to work on that branch. Temporarily checking out another branch **read-only** (inspect history, compare files, run `git show`) is fine without asking; switch back to the working branch before making edits unless instructed otherwise.
## Anti-patterns
- Don't include `wx/...` headers from `core/` (breaks layering and tests will refuse to build).
+28 -4
View File
@@ -1,6 +1,5 @@
# Card Collection Manager 3
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=alert_status&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=bugs&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=security_rating&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3)
@@ -10,21 +9,46 @@ Currently, the application supports the following TCGs:
- Magic the Gathering
- Pokemon TCG
- Yu-Gi-Oh!
- Yu-Gi-Oh! (Bandai)
- Digimon (Digi-Battle)
## Screenshots
### Magic The Gathering
<details open>
<summary>Magic The Gathering</summary>
![CCM3 Demo - Magic: The Gathering](docs/assets/images/demo-mtg.png)
### Pokemon TCG
</details>
<details>
<summary>Pokemon TCG</summary>
![CCM3 Demo - Pokemon](docs/assets/images/demo-pkm.png)
### Yu-Gi-Oh!
</details>
<details>
<summary>Yu-Gi-Oh!</summary>
![CCM3 Demo - YuGiOh](docs/assets/images/demo-ygo.png)
</details>
<details>
<summary>Yu-Gi-Oh! (Bandai)</summary>
![CCM3 Demo - Yu-Gi-Oh! (Bandai)](docs/assets/images/demo-ygo-bandai.png)
</details>
<details>
<summary>Digimon (Digi-Battle)</summary>
![CCM3 Demo - Digimon Digi-Battle](docs/assets/images/demo-digibattle99.png)
</details>
## Migrating From CCM1 And CCM2
+5 -4
View File
@@ -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 `$<TARGET_FILE_DIR:ccm>/assets/` and copies `ui_wx/assets/ygo_card_back.png` there so Yu-Gi-Oh! 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 `$<TARGET_FILE_DIR:ccm>/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<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `YuGiOhGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonCollectionRepository<YuGiOhBandaiCard>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `YuGiOhBandaiSetCatalogService`, `PokemonSetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `YuGiOhBandaiGameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, `YuGiOhBandaiGameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`).
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 `<Name>CardPreviewSource` directly; it calls `previewSvc_->registerModule(*<name>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`** 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 `$<TARGET_FILE_DIR:ccm>` so the exe does not load a mismatched runtime from `PATH`.
- After adding a new game module you **must**: (1) add a `unique_ptr<<Name>GameModule>` member in declaration-order-correct position, (2) construct it in `OnInit()`, (3) call `setSvc_->registerModule(<name>Mod_.get())`, (4) call `previewSvc_->registerModule(*<name>Mod_)` (no-op when the module has no preview source), (5) extend `dirNameForGame`, (6) add a typed `JsonCollectionRepository<<Name>Card>` + `CollectionService<<Name>Card>` if the game has a custom card type, (7) construct a `<Name>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.
+30 -2
View File
@@ -19,9 +19,37 @@ target_link_libraries(ccm
ccm_warnings
)
# Yu-Gi-Oh! preview fallback image (used when network card-back URLs fail).
# 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 "$<TARGET_FILE_DIR:ccm>/assets"
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_classic"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/ui_wx/assets/ygo_card_back.png"
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png")
"$<TARGET_FILE_DIR:ccm>/assets/ygo_card_back.png"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/ui_wx/assets/digibattle99_card_back.png"
"$<TARGET_FILE_DIR:ccm>/assets/digibattle99_card_back.png"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_en_catalog.json"
"$<TARGET_FILE_DIR:ccm>/assets/pokemon_jp_en_catalog.json"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/ui_wx/assets/pokemon_jp_classic"
"$<TARGET_FILE_DIR:ccm>/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}"
"$<TARGET_FILE_DIR:ccm>/${_ccm_rt_dll}"
VERBATIM)
endif()
endforeach()
endif()
+90 -7
View File
@@ -2,12 +2,18 @@
// it to the wxWidgets UI layer. This is the only place where concrete adapter
// types are mentioned - everything downstream depends on interfaces.
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
#include "ccm/games/magic/MagicGameModule.hpp"
#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/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/infra/JsonCollectionRepository.hpp"
#include "ccm/infra/JsonSetRepository.hpp"
@@ -17,12 +23,18 @@
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/PokemonSetCatalogService.hpp"
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/AppContext.hpp"
#include "ccm/ui/DigiBattle99GameView.hpp"
#include "ccm/ui/MagicGameView.hpp"
#include "ccm/ui/MainFrame.hpp"
#include "ccm/ui/PokemonGameView.hpp"
#include "ccm/ui/YuGiOhBandaiGameView.hpp"
#include "ccm/ui/YuGiOhGameView.hpp"
#include <wx/app.h>
@@ -42,9 +54,12 @@ 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::Magic: return "magic";
case ccm::Game::Pokemon: return "pokemon";
case ccm::Game::YuGiOh: return "yugioh";
case ccm::Game::DigiBattle99: return "digibattle99";
case ccm::Game::YuGiOhBandai: return "yugiohbandai";
case ccm::Game::JapanesePokemon: return "pokemon";
}
return "magic";
}
@@ -80,6 +95,19 @@ public:
magicMod_ = std::make_unique<ccm::MagicGameModule>(*http_);
pokeMod_ = std::make_unique<ccm::PokemonGameModule>(*http_);
ygoMod_ = std::make_unique<ccm::YuGiOhGameModule>(*http_);
digiBattle99Mod_ = std::make_unique<ccm::DigiBattle99GameModule>(*http_);
ygoBandaiMod_ = std::make_unique<ccm::YuGiOhBandaiGameModule>(*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<ccm::JapanesePokemonGameModule>(*http_, std::move(jpCatalog));
magicRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::MagicCard>>(
*fs_, *config_, &dirNameForGame);
@@ -87,7 +115,21 @@ public:
*fs_, *config_, &dirNameForGame);
ygoRepo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhCard>>(
*fs_, *config_, &dirNameForGame);
digiBattle99Repo_ =
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
*fs_, *config_, &dirNameForGame);
ygoBandaiRepo_ =
std::make_unique<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>>(
*fs_, *config_, &dirNameForGame);
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
digiBattle99CatalogStore_ =
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoCatalogStore_ =
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoBandaiCatalogStore_ =
std::make_unique<ccm::YuGiOhBandaiSetCatalogService>(*fs_, *config_, &dirNameForGame);
pokeCatalogStore_ =
std::make_unique<ccm::PokemonSetCatalogService>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
@@ -97,10 +139,19 @@ public:
*pokeRepo_, *imgStore_);
ygoCollSvc_ = std::make_unique<ccm::CollectionService<ccm::YuGiOhCard>>(
*ygoRepo_, *imgStore_);
digiBattle99CollSvc_ =
std::make_unique<ccm::CollectionService<ccm::DigiBattle99Card>>(
*digiBattle99Repo_, *imgStore_);
ygoBandaiCollSvc_ =
std::make_unique<ccm::CollectionService<ccm::YuGiOhBandaiCard>>(
*ygoBandaiRepo_, *imgStore_);
setSvc_ = std::make_unique<ccm::SetService>(*setRepo_);
setSvc_->registerModule(magicMod_.get());
setSvc_->registerModule(pokeMod_.get());
setSvc_->registerModule(ygoMod_.get());
setSvc_->registerModule(digiBattle99Mod_.get());
setSvc_->registerModule(ygoBandaiMod_.get());
setSvc_->registerModule(jpPokeMod_.get());
// Disk-backed preview cache lives next to the executable, in the same
// location scope as config.json - NOT inside the user's data-storage
@@ -116,18 +167,33 @@ public:
previewCache_ = std::make_unique<ccm::LocalPreviewByteCache>(
*fs_,
exeDir / ".cache" / "preview-cache");
previewSvc_ = std::make_unique<ccm::CardPreviewService>(*http_, previewCache_.get());
previewSvc_ = std::make_unique<ccm::CardPreviewService>(
*http_,
previewCache_.get(),
fs_.get(),
exeDir / "assets");
previewSvc_->registerModule(*magicMod_);
previewSvc_->registerModule(*pokeMod_);
previewSvc_->registerModule(*ygoMod_);
previewSvc_->registerModule(*digiBattle99Mod_);
previewSvc_->registerModule(*ygoBandaiMod_);
previewSvc_->registerModule(*jpPokeMod_);
// Per-game UI bundles. Order here is the order shown in the Game menu.
magicView_ = std::make_unique<ccm::ui::MagicGameView>(
*config_, *magicCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *magicMod_);
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_, *jpPokeMod_,
*pokeCatalogStore_);
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
*ygoCatalogStore_);
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*digiBattle99Mod_, *digiBattle99CatalogStore_);
ygoBandaiView_ = std::make_unique<ccm::ui::YuGiOhBandaiGameView>(
*config_, *ygoBandaiCollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*ygoBandaiMod_, *ygoBandaiCatalogStore_);
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
*config_,
@@ -137,7 +203,11 @@ public:
*magicMod_,
*pokeMod_,
*ygoMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get() },
*digiBattle99Mod_,
*ygoBandaiMod_,
*jpPokeMod_,
{ magicView_.get(), pokeView_.get(), ygoView_.get(), ygoBandaiView_.get(),
digiBattle99View_.get() },
});
auto* frame = new ccm::ui::MainFrame(*ctx_);
@@ -158,21 +228,34 @@ private:
std::unique_ptr<ccm::MagicGameModule> magicMod_;
std::unique_ptr<ccm::PokemonGameModule> pokeMod_;
std::unique_ptr<ccm::YuGiOhGameModule> ygoMod_;
std::unique_ptr<ccm::DigiBattle99GameModule> digiBattle99Mod_;
std::unique_ptr<ccm::YuGiOhBandaiGameModule> ygoBandaiMod_;
std::unique_ptr<ccm::JapanesePokemonGameModule> jpPokeMod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::MagicCard>> magicRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::PokemonCard>> pokeRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhBandaiCard>> ygoBandaiRepo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
std::unique_ptr<ccm::YuGiOhBandaiSetCatalogService> ygoBandaiCatalogStore_;
std::unique_ptr<ccm::PokemonSetCatalogService> pokeCatalogStore_;
std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::PokemonCard>> pokeCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhCard>> ygoCollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::DigiBattle99Card>> digiBattle99CollSvc_;
std::unique_ptr<ccm::CollectionService<ccm::YuGiOhBandaiCard>> ygoBandaiCollSvc_;
std::unique_ptr<ccm::SetService> setSvc_;
std::unique_ptr<ccm::LocalPreviewByteCache> previewCache_;
std::unique_ptr<ccm::CardPreviewService> previewSvc_;
std::unique_ptr<ccm::ui::MagicGameView> magicView_;
std::unique_ptr<ccm::ui::PokemonGameView> pokeView_;
std::unique_ptr<ccm::ui::YuGiOhGameView> ygoView_;
std::unique_ptr<ccm::ui::DigiBattle99GameView> digiBattle99View_;
std::unique_ptr<ccm::ui::YuGiOhBandaiGameView> ygoBandaiView_;
std::unique_ptr<ccm::ui::AppContext> ctx_;
};
+1 -1
View File
@@ -31,7 +31,7 @@ FetchContent_MakeAvailable(nlohmann_json)
# ---------------------------------------------------------------------------
# cpr - C++ Requests (libcurl wrapper). Builds curl in-tree so we don't need
# a system libcurl. Used for Scryfall + pokemontcg.io REST calls.
# a system libcurl. Used for Scryfall + TCGdex REST calls.
#
# Pinned at 1.10.5 deliberately. 1.11.x adds an `install(EXPORT cprTargets)`
# rule that references `libcurl_shared`, which isn't in any export set when
+5 -5
View File
@@ -4,12 +4,12 @@
## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `YuGiOhBandaiCard`, `YuGiOhBandaiSetCatalog` (Bandai pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `PokemonSetCatalog` (Pokemon West/Asia pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`).
- `include/ccm/services/`high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `YuGiOhBandaiSetCompletion` / `DigiBattle99SetCompletion` / `PokemonSetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `YuGiOhBandaiSetCatalogService` (`yugiohbandai/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`), `PokemonSetCatalogService` (`pokemon/set-catalog-west.json` / `set-catalog-asia.json`). They depend only on ports / domain.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `yugiohbandai/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all expose a fully working set source + card preview source. `YuGiOhSetSource`, `YuGiOhBandaiSetSource`, `DigiBattle99SetSource`, `PokemonSetSource`, and `JapanesePokemonSetSource` also expose `fetchAllWithCatalog` (and related catalog parsers) for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect / Asia set-completion gap-fill.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
- `src/` mirrors `include/ccm/` for non-template implementations.
## Conventions
@@ -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<std::string>` 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 (`<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>`) 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 `<setNo>||<rarity>||<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 `<exeDir>/.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 `<hash>.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`) 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.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
+28
View File
@@ -7,6 +7,13 @@ add_library(ccm_core STATIC
src/domain/MagicCard.cpp
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/YuGiOhBandaiCard.cpp
src/domain/YuGiOhBandaiSetCatalog.cpp
src/domain/DigiBattle99Card.cpp
src/domain/DigiBattle99SetCatalog.cpp
src/domain/YuGiOhSetCatalog.cpp
src/domain/PokemonSetCatalog.cpp
src/domain/JapanesePokemonCard.cpp
src/domain/Configuration.cpp
src/services/ConfigService.cpp
@@ -15,6 +22,14 @@ add_library(ccm_core STATIC
src/services/CardPreviewService.cpp
src/services/CardSorter.cpp
src/services/CardFilter.cpp
src/services/DigiBattle99SetCompletion.cpp
src/services/DigiBattle99SetCatalogService.cpp
src/services/YuGiOhSetCompletion.cpp
src/services/YuGiOhSetCatalogService.cpp
src/services/YuGiOhBandaiSetCompletion.cpp
src/services/YuGiOhBandaiSetCatalogService.cpp
src/services/PokemonSetCompletion.cpp
src/services/PokemonSetCatalogService.cpp
src/infra/CprHttpClient.cpp
src/infra/StdFileSystem.cpp
@@ -25,14 +40,27 @@ add_library(ccm_core STATIC
src/games/magic/MagicSetSource.cpp
src/games/magic/MagicCardPreviewSource.cpp
src/games/magic/MagicGameModule.cpp
src/games/pokemon/PokemonWestSetId.cpp
src/games/pokemon/PokemonCollectionSetSync.cpp
src/games/pokemon/PokemonSetSource.cpp
src/games/pokemon/PokemonCardPreviewSource.cpp
src/games/pokemon/PokemonGameModule.cpp
src/games/yugioh/YuGiOhSetSource.cpp
src/games/yugioh/YuGiOhCardPreviewSource.cpp
src/games/yugioh/YuGiOhGameModule.cpp
src/games/digibattle99/DigiBattle99SetSource.cpp
src/games/digibattle99/DigiBattle99CardPreviewSource.cpp
src/games/digibattle99/DigiBattle99GameModule.cpp
src/games/yugiohbandai/YuGiOhBandaiSetSource.cpp
src/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.cpp
src/games/yugiohbandai/YuGiOhBandaiGameModule.cpp
src/games/pokemonjp/JapanesePokemonEnCatalog.cpp
src/games/pokemonjp/JapanesePokemonSetSource.cpp
src/games/pokemonjp/JapanesePokemonCardPreviewSource.cpp
src/games/pokemonjp/JapanesePokemonGameModule.cpp
src/util/FsNames.cpp
src/util/SetNoNatural.cpp
)
target_include_directories(ccm_core
@@ -0,0 +1,38 @@
#pragma once
// DigiBattle99Card - Digimon Digi-Battle (1999 English) card model.
// Pokémon-shaped field set (setNo / holo / firstEdition / signed / altered).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct DigiBattle99Card {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
Condition condition{Condition::NearMint};
bool firstEdition{false};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const DigiBattle99Card&, const DigiBattle99Card&) = default;
};
void to_json(nlohmann::json& j, const DigiBattle99Card& c);
void from_json(const nlohmann::json& j, DigiBattle99Card& c);
} // namespace ccm
@@ -0,0 +1,52 @@
#pragma once
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
// completion. Filled from digimoncard.io bulk search.php (same payload as the
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99CatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const DigiBattle99CatalogCard&,
const DigiBattle99CatalogCard&) = default;
};
struct DigiBattle99SetCatalogPack {
std::string setId;
std::string setName;
std::vector<DigiBattle99CatalogCard> cards;
friend bool operator==(const DigiBattle99SetCatalogPack&,
const DigiBattle99SetCatalogPack&) = default;
};
struct DigiBattle99SetCatalog {
std::vector<DigiBattle99SetCatalogPack> packs;
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const DigiBattle99SetCatalog&,
const DigiBattle99SetCatalog&) = default;
};
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
} // namespace ccm
+28 -7
View File
@@ -10,6 +10,7 @@
#include <array>
#include <optional>
#include <span>
#include <string>
#include <string_view>
@@ -19,6 +20,14 @@ enum class Game {
Magic,
Pokemon,
YuGiOh,
DigiBattle99,
YuGiOhBandai,
JapanesePokemon, // internal Asia sets/preview routing; not in allGames()
};
enum class PokemonRegion {
West,
Asia,
};
enum class Language {
@@ -27,8 +36,10 @@ enum class Language {
French,
Spanish,
Italian,
Chinese,
SimplifiedChinese, // JSON / display: "S-Chinese" (legacy "Chinese" accepted)
TraditionalChinese, // JSON / display: "T-Chinese"
Japanese,
Korean,
Russian,
};
@@ -48,24 +59,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<Game> gameFromString(std::string_view s) noexcept;
std::optional<Language> languageFromString(std::string_view s) noexcept;
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
std::optional<Theme> themeFromString(std::string_view s) noexcept;
std::optional<Game> gameFromString(std::string_view s) noexcept;
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept;
std::optional<Language> languageFromString(std::string_view s) noexcept;
std::optional<Condition> conditionFromString(std::string_view s) noexcept;
std::optional<Theme> themeFromString(std::string_view s) noexcept;
const std::array<Game, 3>& allGames() noexcept;
const std::array<Language, 8>& allLanguages() noexcept;
// User-facing games (Game menu / Settings). JapanesePokemon is internal-only.
const std::array<Game, 5>& allGames() noexcept;
const std::array<Language, 10>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
[[nodiscard]] std::span<const Language> 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);
@@ -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 <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
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<std::string> 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
+3 -1
View File
@@ -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;
};
@@ -0,0 +1,52 @@
#pragma once
// PokemonSetCatalog: offline pack → card checklist for Pokemon set
// completion. West and Asia each persist their own file under
// `<dataStorage>/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`).
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const PokemonCatalogCard&,
const PokemonCatalogCard&) = default;
};
struct PokemonSetCatalogPack {
std::string setId;
std::string setName;
std::vector<PokemonCatalogCard> cards;
friend bool operator==(const PokemonSetCatalogPack&,
const PokemonSetCatalogPack&) = default;
};
struct PokemonSetCatalog {
std::vector<PokemonSetCatalogPack> packs;
[[nodiscard]] const PokemonSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const PokemonSetCatalog&,
const PokemonSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const PokemonCatalogCard& c);
void from_json(const nlohmann::json& j, PokemonCatalogCard& c);
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p);
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p);
void to_json(nlohmann::json& j, const PokemonSetCatalog& c);
void from_json(const nlohmann::json& j, PokemonSetCatalog& c);
} // namespace ccm
@@ -0,0 +1,37 @@
#pragma once
// YuGiOhBandaiCard - Bandai Carddass (pre-Konami) card model.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <string>
#include <vector>
namespace ccm {
struct YuGiOhBandaiCard {
std::uint32_t id{0};
std::uint8_t amount{1};
std::string name;
Set set;
std::string setNo;
std::string rarity;
std::string note;
std::vector<std::string> images;
Language language{Language::Japanese};
Condition condition{Condition::NearMint};
bool holo{false};
bool signed_{false};
bool altered{false};
friend bool operator==(const YuGiOhBandaiCard&, const YuGiOhBandaiCard&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c);
} // namespace ccm
@@ -0,0 +1,52 @@
#pragma once
// YuGiOhBandaiSetCatalog: offline pack → card checklist for Bandai set
// completion. Filled from Yugipedia set-gallery wikitext and persisted at
// `<dataStorage>/yugiohbandai/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhBandaiCatalogCard {
std::string setNo;
std::string name;
std::string rarity;
friend bool operator==(const YuGiOhBandaiCatalogCard&,
const YuGiOhBandaiCatalogCard&) = default;
};
struct YuGiOhBandaiSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhBandaiCatalogCard> cards;
friend bool operator==(const YuGiOhBandaiSetCatalogPack&,
const YuGiOhBandaiSetCatalogPack&) = default;
};
struct YuGiOhBandaiSetCatalog {
std::vector<YuGiOhBandaiSetCatalogPack> packs;
[[nodiscard]] const YuGiOhBandaiSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhBandaiSetCatalog&,
const YuGiOhBandaiSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c);
} // namespace ccm
-1
View File
@@ -20,7 +20,6 @@ struct YuGiOhCard {
Set set;
std::string setNo;
std::string rarity;
std::string rarityCode;
std::string note;
std::vector<std::string> images;
Language language{Language::English};
@@ -0,0 +1,52 @@
#pragma once
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const YuGiOhCatalogCard&,
const YuGiOhCatalogCard&) = default;
};
struct YuGiOhSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhCatalogCard> cards;
friend bool operator==(const YuGiOhSetCatalogPack&,
const YuGiOhSetCatalogPack&) = default;
};
struct YuGiOhSetCatalog {
std::vector<YuGiOhSetCatalogPack> packs;
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhSetCatalog&,
const YuGiOhSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
} // namespace ccm
+4
View File
@@ -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<std::vector<Set>> 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<Set>& /*sets*/) const {}
};
class IGameModule {
@@ -0,0 +1,67 @@
#pragma once
// DigiBattle99CardPreviewSource: digimoncard.io search + CDN card images for
// Digimon Digi-Battle (1999 English).
//
// Preview key middle slot is Set.name (pack display name) so search.php?pack=
// works without a reverse slug map. When setNo is present, the CDN URL is
// built directly — no search round-trip.
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class DigiBattle99CardPreviewSource final : public ICardPreviewSource {
public:
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
static constexpr const char* kImageBase =
"https://images.digimoncard.io/images/cards/";
explicit DigiBattle99CardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setName,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setName) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setName) override;
// Uppercase the alphabetic prefix of a Digi-Battle card number (bo-88 -> BO-88).
// Does not invent zero-padding — CDN keys match API ids literally.
static std::string normalizeCardNumber(std::string_view setNo);
// CDN preview URL for a normalized card id (.jpg — wxImage registers
// JPEG/PNG only; digimoncard.io also serves .webp but we cannot decode it).
static std::string buildImageUrl(std::string_view setNo);
// digimoncard.io search URL: n= / pack= / series= / optional card=.
// setName is the pack display name (Set.name), not the slug id.
static std::string buildSearchUrl(std::string_view name,
std::string_view setName,
std::string_view setNo);
// Parse a digimoncard.io search.php body into a CDN image URL for the
// first exact name match (optional pack filter applied by the request).
static Result<std::string, PreviewLookupError>
parseImageUrlFromSearch(const std::string& body,
std::string_view wantedCardName);
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setName,
std::string_view wantedCardName);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,27 @@
#pragma once
// DigiBattle99GameModule: Digimon Digi-Battle (1999 English) via digimoncard.io.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
namespace ccm {
class DigiBattle99GameModule final : public IGameModule {
public:
explicit DigiBattle99GameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::DigiBattle99; }
[[nodiscard]] std::string dirName() const override { return "digibattle99"; }
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
DigiBattle99SetSource setSource_;
DigiBattle99CardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,49 @@
#pragma once
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
// The same payload also builds the set-completion catalog (parseCatalog).
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class DigiBattle99SetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint =
"https://digimoncard.io/api-public/search.php?"
"series=Digimon%20Digi-Battle%20Card%20Game&limit=1000&sort=name&sortdirection=asc";
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
struct FetchWithCatalog {
std::vector<Set> sets;
DigiBattle99SetCatalog catalog;
};
explicit DigiBattle99SetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// One HTTP round-trip producing both the set list and the pack catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
static std::string slugifyPackName(std::string_view packName);
private:
IHttpClient& http_;
};
} // namespace ccm
@@ -1,17 +1,16 @@
#pragma once
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
// TCG. Calls the Pokemon TCG search endpoint at
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
// and returns `data[0].images.large` (with `images.small` as a graceful
// fallback). Mirrors the established `getImage` flow in
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
// PokemonCardPreviewSource: West Pokemon previews via TCGdex EN.
// Prefers GET /v2/en/cards/{setId}-{localId}, then filtered card search, then
// set-detail name match for auto-detect. Image URLs append /high.png (wxImage
// decodes PNG, not webp).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -19,24 +18,48 @@ class PokemonCardPreviewSource final : public ICardPreviewSource {
public:
explicit PokemonCardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
// Exposed for unit testing and to keep encoding rules in one place.
// Strip everything after the first '/' (e.g. "4/102" -> "4").
static std::string normalizeCollectorNumber(std::string_view setNo);
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
static std::string buildSetDetailUrl(std::string_view setId);
static std::string buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo);
static std::string imageUrlFromBase(std::string_view imageBase);
struct SetCardRow {
std::string localId;
std::string name;
std::string imageBase;
std::string rarity;
};
static Result<std::vector<SetCardRow>, PreviewLookupError>
parseSetCards(const std::string& body);
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`. Errors are classified:
// - JSON parse failure or missing/non-array `data` => Transient.
// - Empty `data` array or missing image variants => NotFound.
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
parseCardByIdResponse(const std::string& body);
// Parse a slim TCGdex cards-array search response; prefer first hit with image.
static Result<std::string, PreviewLookupError>
parseSearchResponse(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
parsePrintVariants(const std::string& body,
std::string_view setId,
std::string_view wantedCardName);
private:
IHttpClient& http_;
@@ -0,0 +1,21 @@
#pragma once
// Sync Pokemon collection cards against freshly fetched set lists:
// - West: canonicalize legacy pokemontcg set ids, then refresh name/date
// - Asia: refresh name/date when the set id is present in the Asia list
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/Set.hpp"
#include <cstddef>
#include <vector>
namespace ccm {
// Mutates cards in place. Returns how many cards changed at least one set field.
[[nodiscard]] std::size_t syncPokemonCollectionSets(
std::vector<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& asiaSets);
} // namespace ccm
@@ -1,7 +1,7 @@
#pragma once
// PokemonGameModule: IGameModule for the Pokemon TCG. Owns its set source
// and card preview source, both backed by api.pokemontcg.io/v2.
// and card preview source, both backed by TCGdex EN (api.tcgdex.net/v2/en).
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
@@ -1,27 +1,45 @@
#pragma once
// PokemonSetSource: ISetSource implementation for the Pokemon TCG.
// Calls the Pokemon TCG API at https://api.pokemontcg.io/v2/sets, maps the
// response into our `Set` domain type, and sorts by release date ascending.
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
// so no rewriting is needed (unlike Scryfall's `released_at`).
// Behavior matches `pokemon/set_services.rs::update_sets`.
// PokemonSetSource: ISetSource for West Pokemon via TCGdex EN
// (https://api.tcgdex.net/v2/en). List endpoint returns a slim array; release
// dates and set-completion checklists come from per-set detail GETs.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class PokemonSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/en/sets";
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
explicit PokemonSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
// List + per-set detail (cards + release date) for the offline checklist.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseListResponse(const std::string& body);
static Result<std::string> parseReleaseDate(const std::string& detailBody);
static std::string rewriteReleaseDate(std::string_view isoDate);
static std::string buildSetDetailUrl(std::string_view setId);
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set);
private:
IHttpClient& http_;
@@ -0,0 +1,16 @@
#pragma once
// Canonicalize legacy pokemontcg.io West set ids to TCGdex EN ids.
// Identity when the id is already TCGdex (or unknown). Asia set ids must not
// be passed through this helper.
#include <string>
#include <string_view>
namespace ccm {
// Returns the TCGdex EN set id for a West Pokemon card.set.id. Unknown ids
// and ids that already match TCGdex are returned unchanged.
[[nodiscard]] std::string canonicalizeWestSetId(std::string_view setId);
} // namespace ccm
@@ -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 <string>
#include <string_view>
#include <vector>
namespace ccm {
class JapanesePokemonCardPreviewSource final : public ICardPreviewSource {
public:
JapanesePokemonCardPreviewSource(IHttpClient& http,
const JapanesePokemonEnCatalog& catalog);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> 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<std::vector<SetCardRow>, PreviewLookupError>
parseSetCards(const std::string& body);
static Result<std::string, PreviewLookupError>
parseCardImageUrl(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
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<std::vector<AutoDetectedPrint>>
detectPrintVariantsFromCatalog(std::string_view setId,
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog);
private:
IHttpClient& http_;
const JapanesePokemonEnCatalog& catalog_;
};
} // namespace ccm
@@ -0,0 +1,75 @@
#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 <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
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<JapanesePokemonEnCatalog>
parse(const std::string& jsonBody);
[[nodiscard]] bool empty() const noexcept {
return sets_.empty() && printsByKey_.empty();
}
[[nodiscard]] std::optional<JapanesePokemonSetEnInfo>
findSet(std::string_view setId) const;
[[nodiscard]] std::optional<JapanesePokemonPrintEnInfo>
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<JapanesePokemonPrintEnInfo>
findPrintsByName(std::string_view setId, std::string_view cardName) const;
[[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept;
// All prints for a set (catalog gap-fill / set-completion checklists).
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
printsForSet(std::string_view setId) const;
// TCGPlayer product-image CDN URL for classic JA gap-fill.
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
// Prefer imageUrl; else build from tcgplayerId; else empty.
[[nodiscard]] static std::string previewImageUrlFromPrint(
const JapanesePokemonPrintEnInfo& print);
private:
std::unordered_map<std::string, JapanesePokemonSetEnInfo> sets_;
std::unordered_map<std::string, JapanesePokemonPrintEnInfo> printsByKey_;
// setId -> print keys for name scans
std::unordered_map<std::string, std::vector<std::string>> printKeysBySet_;
};
} // namespace ccm
@@ -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
@@ -0,0 +1,64 @@
#pragma once
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
// dates and set-completion checklists. English display names come from
// JapanesePokemonEnCatalog when present.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class JapanesePokemonSetSource final : public ISetSource {
public:
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
Result<std::vector<Set>> fetchAll() override;
// List + per-set detail (cards + release date) + EN catalog gap-fill.
Result<FetchWithCatalog> fetchAllWithCatalog();
void augmentCachedSets(std::vector<Set>& sets) const override;
// Pure parsers for hermetic tests.
static Result<std::vector<Set>> parseListResponse(const std::string& body);
static Result<std::string> 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);
// Build one pack checklist from a set-detail body, then gap-fill from catalog.
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog);
// Catalog-only pack (classic products with no TCGdex detail).
static PokemonSetCatalogPack catalogPackFromEnCatalog(
const Set& set, const JapanesePokemonEnCatalog& enCatalog);
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
static void appendMissingClassicProducts(std::vector<Set>& sets);
private:
IHttpClient& http_;
const JapanesePokemonEnCatalog& catalog_;
};
} // namespace ccm
@@ -1,21 +1,45 @@
#pragma once
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
// Sets come from cardsets.php; the set-completion catalog is built from the
// unfiltered cardinfo.php dump (card_sets[] per card).
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <vector>
namespace ccm {
class YuGiOhSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
static constexpr const char* kCardInfoEndpoint =
"https://db.ygoprodeck.com/api/v7/cardinfo.php";
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhSetCatalog catalog;
};
explicit YuGiOhSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for
// the pack checklist catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Build the offline checklist from a cardinfo.php body, resolving pack
// ids against the already-parsed sets list (by set_name → Set.id).
static Result<YuGiOhSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
private:
IHttpClient& http_;
};
@@ -0,0 +1,77 @@
#pragma once
// YuGiOhBandaiCardPreviewSource: Yugipedia pageimages + SMW ask for Bandai
// Carddass previews and auto-detect (by English name or Bandai number).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class YuGiOhBandaiCardPreviewSource final : public ICardPreviewSource {
public:
explicit YuGiOhBandaiCardPreviewSource(IHttpClient& http);
[[nodiscard]] bool supportsAutoDetectPrint() const noexcept override { return true; }
Result<std::string, PreviewLookupError>
fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) override;
Result<AutoDetectedPrint> detectFirstPrint(std::string_view name,
std::string_view setId) override;
Result<std::vector<AutoDetectedPrint>> detectPrintVariants(std::string_view name,
std::string_view setId) override;
Result<AutoDetectedPrint> detectBySetNo(std::string_view setNo) override;
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(
std::string_view setNo) override;
// Prefer "<Name> (Bandai)" / English / Sealdass page depending on setId.
static std::string preferredPageTitle(std::string_view name,
std::string_view setId,
std::string_view setNo);
static std::string buildPageImagesUrl(std::string_view pageTitle);
static std::string buildAskByNameUrl(std::string_view englishName);
static std::string buildAskByNumberUrl(std::string_view setNo);
// True for Jump/Toei promo codes (J1, TA2, …). Yugipedia's SMW
// `Bandai number` property is numeric-only, so these must use the
// promotional gallery instead of `action=ask`.
[[nodiscard]] static bool isAlphanumericPromoNumber(std::string_view setNo);
static Result<std::vector<AutoDetectedPrint>>
parsePromoGalleryResponse(const std::string& body,
std::string_view wantedSetNo);
static Result<std::string, PreviewLookupError>
parsePageImagesResponse(const std::string& body);
static Result<std::vector<AutoDetectedPrint>>
parseAskResponse(const std::string& body, std::string_view preferredSetId);
static AutoDetectedPrint enrichPrint(AutoDetectedPrint print,
std::string_view pageTitle);
private:
Result<std::string, PreviewLookupError> fetchPageImage(std::string_view pageTitle);
Result<std::vector<AutoDetectedPrint>> askByName(std::string_view name,
std::string_view setId);
Result<std::vector<AutoDetectedPrint>> askByNumber(std::string_view setNo);
IHttpClient& http_;
};
} // namespace ccm
@@ -0,0 +1,29 @@
#pragma once
// YuGiOhBandaiGameModule: Bandai Carddass via Yugipedia.
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
namespace ccm {
class YuGiOhBandaiGameModule final : public IGameModule {
public:
explicit YuGiOhBandaiGameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::YuGiOhBandai; }
[[nodiscard]] std::string dirName() const override { return "yugiohbandai"; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh! (Bandai)"; }
ISetSource& setSource() override { return setSource_; }
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
YuGiOhBandaiSetSource& bandaiSetSource() noexcept { return setSource_; }
private:
YuGiOhBandaiSetSource setSource_;
YuGiOhBandaiCardPreviewSource previewSource_;
};
} // namespace ccm
@@ -0,0 +1,67 @@
#pragma once
// YuGiOhBandaiSetSource: hardcoded Bandai set manifest + Yugipedia gallery
// wikitext catalogs for set completion.
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class YuGiOhBandaiSetSource final : public ISetSource {
public:
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhBandaiSetCatalog catalog;
};
struct SetManifestEntry {
const char* id;
const char* name;
const char* releaseDate; // YYYY/MM/DD
const char* galleryPage; // Yugipedia page title (may be shared)
// For the shared promo gallery: keep cards whose setNo starts with
// this prefix (empty = keep all from that page into this pack).
const char* setNoPrefix;
};
explicit YuGiOhBandaiSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
Result<FetchWithCatalog> fetchAllWithCatalog();
[[nodiscard]] static const std::vector<SetManifestEntry>& setManifest();
static Result<std::vector<Set>> parseResponse(const std::string& /*unused*/);
// Parse one gallery wikitext body into checklist cards.
static Result<std::vector<YuGiOhBandaiCatalogCard>>
parseGalleryWikitext(const std::string& wikitext);
// Map a Bandai number string to a set id (ban1/ban2/ban3/promos/sealdass).
static std::string setIdForNumber(std::string_view setNo);
static std::string setNameForId(std::string_view setId);
// Normalize printed numbers: strip leading zeros on pure-decimal values;
// uppercase letter prefixes (j1 → J1). Sealdass stays unpadded decimal.
static std::string normalizeCardNumber(std::string_view setNo);
static std::string expandRarityCode(std::string_view code);
static std::string buildGalleryParseUrl(std::string_view pageTitle);
static std::string englishNameFromGalleryTitle(std::string_view pageTitle);
private:
IHttpClient& http_;
};
} // namespace ccm
+17
View File
@@ -7,6 +7,7 @@
#include "ccm/ports/IHttpClient.hpp"
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
@@ -23,7 +24,21 @@ namespace ccm {
// only fires one outbound request at a time anyway.
class CprHttpClient final : public IHttpClient {
public:
struct RawResponse {
bool transportError{false};
std::string transportMessage;
int statusCode{0};
std::string body;
};
using GetExecutor = std::function<Result<std::string>(std::string_view)>;
using RawGetExecutor = std::function<RawResponse(std::string_view)>;
explicit CprHttpClient(std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
CprHttpClient(RawGetExecutor rawExecutor,
std::chrono::milliseconds timeout = std::chrono::milliseconds{30000});
~CprHttpClient() override;
Result<std::string> get(std::string_view url) override;
@@ -31,6 +46,8 @@ public:
private:
std::chrono::milliseconds timeout_;
std::unique_ptr<cpr::Session> session_;
GetExecutor executor_;
RawGetExecutor rawExecutor_;
std::mutex sessionMutex_;
};
+5 -1
View File
@@ -1,6 +1,8 @@
#pragma once
// JsonSetRepository: persists vector<Set> to `<dataStorage>/<game>/sets.json`.
// JsonSetRepository: persists vector<Set> under `<dataStorage>/<dirName>/`.
// 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<std::vector<Set>> parseSetsText(const std::string& text) const;
};
} // namespace ccm
@@ -19,6 +19,12 @@ namespace ccm {
struct AutoDetectedPrint {
std::string setNo;
std::string rarity;
// Optional fields used by games that resolve set/name/language during
// auto-detect (e.g. Yu-Gi-Oh! Bandai). Existing games leave them empty.
std::string name;
std::string setId;
std::string setName;
std::string language; // Language enum spelling when known ("Japanese" / "English")
};
// Classified error returned by ICardPreviewSource::fetchImageUrl. The kind
@@ -79,6 +85,18 @@ public:
return Result<std::vector<AutoDetectedPrint>>::err(
"Print variant listing not supported by this game.");
}
// Optional lookup by collector / Bandai number (fills name + set + rarity).
virtual Result<AutoDetectedPrint> detectBySetNo(std::string_view /*setNo*/) {
return Result<AutoDetectedPrint>::err(
"Detect-by-number not supported by this game.");
}
virtual Result<std::vector<AutoDetectedPrint>>
detectVariantsBySetNo(std::string_view /*setNo*/) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Detect-by-number variants not supported by this game.");
}
};
} // namespace ccm
+3 -1
View File
@@ -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: `<dataStorage>/<dirName>/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"
+16 -1
View File
@@ -19,8 +19,11 @@
// * An empty filter matches every row, exactly as in JS where every string
// `.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/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <string_view>
@@ -34,11 +37,23 @@ 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);
[[nodiscard]] bool matchesYuGiOhFilter(const YuGiOhCard& card,
std::string_view filter);
// Digi-Battle mirrors Pokemon searchable columns (includes setNo).
[[nodiscard]] bool matchesDigiBattle99Filter(const DigiBattle99Card& card,
std::string_view filter);
// Bandai: name, set.name, setNo, rarity, language, condition, amount, note.
[[nodiscard]] bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card,
std::string_view filter);
// Japanese Pokemon mirrors Pokemon searchable columns (includes setNo).
[[nodiscard]] bool matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter);
} // namespace ccm
@@ -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 <cstddef>
#include <filesystem>
#include <list>
#include <mutex>
#include <string>
@@ -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
@@ -77,6 +81,11 @@ public:
std::string_view name,
std::string_view setId);
Result<AutoDetectedPrint> detectBySetNo(Game game, std::string_view setNo);
Result<std::vector<AutoDetectedPrint>> detectVariantsBySetNo(Game game,
std::string_view setNo);
// Download image bytes from a fully-qualified URL without going through
// per-game preview-source resolution. Cached by URL (same LRU bound).
Result<std::string> fetchImageBytesByUrl(std::string_view url);
@@ -96,6 +105,9 @@ private:
Result<std::string> fetchAndCache(const std::string& cacheKey,
std::string_view url);
Result<std::string, PreviewLookupError> 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 +119,8 @@ private:
IHttpClient& http_;
IPreviewByteCache* persistentCache_{nullptr};
IFileSystem* fs_{nullptr};
std::filesystem::path assetRoot_;
std::unordered_map<Game, ICardPreviewSource*> sources_;
// LRU: list holds entries in MRU-first order; map points at list nodes
+55
View File
@@ -16,8 +16,11 @@
// UI relies on it so successive clicks on different columns compose predictably
// (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/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include <vector>
@@ -59,6 +62,49 @@ enum class YuGiOhSortColumn {
Language,
Condition,
Amount,
Rarity,
FirstEdition,
Signed,
Altered,
Note,
};
// Digi-Battle mirrors Pokemon columns (setNo is filter-only, not a sort column).
enum class DigiBattle99SortColumn {
Name,
SetReleaseDate,
Language,
Condition,
Amount,
Holo,
FirstEdition,
Signed,
Altered,
Note,
};
enum class YuGiOhBandaiSortColumn {
Name,
SetReleaseDate,
SetNo,
Rarity,
Language,
Condition,
Amount,
Holo,
Signed,
Altered,
Note,
};
// Japanese Pokemon mirrors Pokemon columns.
enum class JapanesePokemonSortColumn {
Name,
SetReleaseDate,
Language,
Condition,
Amount,
Holo,
FirstEdition,
Signed,
Altered,
@@ -73,5 +119,14 @@ void sortPokemonCards(std::vector<PokemonCard>& cards, PokemonSortColumn column,
bool ascending);
void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
bool ascending);
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
DigiBattle99SortColumn column,
bool ascending);
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
YuGiOhBandaiSortColumn column,
bool ascending);
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& cards,
JapanesePokemonSortColumn column,
bool ascending);
} // namespace ccm
@@ -80,6 +80,15 @@ public:
return repo_.save(game, map);
}
// Replace the entire collection map in one save (e.g. after bulk set-id sync).
Result<void> saveAll(Game game, std::vector<TCard> cards) {
Map map;
for (auto& card : cards) {
map.insert_or_assign(card.id, std::move(card));
}
return repo_.save(game, map);
}
// Remove the card with the given id. Also deletes any associated images
// via the IImageStore (best-effort - image removal failures are logged in
// the error string but the card itself is still purged from the JSON).
@@ -0,0 +1,36 @@
#pragma once
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
// the configured dataStorage path.
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class DigiBattle99SetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<DigiBattle99SetCatalog> load() const;
Result<void> save(const DigiBattle99SetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,59 @@
#pragma once
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
// inflate the numerator. An optional languageFilter restricts ownership to
// cards of that language (packs with zero matches are omitted).
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99SetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct DigiBattle99ChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,36 @@
#pragma once
// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and
// pokemon/set-catalog-asia.json under the configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class PokemonSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<PokemonSetCatalog> load(PokemonRegion region) const;
Result<void> save(PokemonRegion region, const PokemonSetCatalog& catalog);
[[nodiscard]] bool exists(PokemonRegion region) const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const;
};
} // namespace ccm
@@ -0,0 +1,73 @@
#pragma once
// Pure helpers: Pokemon set-completion progress and per-set checklists.
// Ownership requires matching PokemonRegion for the pack (West vs Asia),
// matching set.id, and a normalized collector number / localId. Duplicates /
// amount / holo / firstEdition do not inflate the numerator. Optional
// regionFilter and languageFilter restrict which cards count (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonSetCompletionProgress {
PokemonRegion region{PokemonRegion::West};
std::string setId;
std::string setName;
std::string releaseDate; // YYYY/MM/DD from owned cards; may be empty
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct PokemonChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection (optionally region-scoped),
// in allLanguages() order.
[[nodiscard]] std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter = std::nullopt);
// Distinct regions that have ≥1 owned card matching a catalog pack.
[[nodiscard]] std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog);
// Packs where the collection owns ≥1 matching card, ordered by releaseDate
// then setName then region. When regionFilter is set, only that region's
// catalog/cards count.
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter = std::nullopt,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
[[nodiscard]] std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
+4
View File
@@ -27,6 +27,10 @@ public:
// repository, and return the new list.
Result<std::vector<Set>> updateSets(Game game);
// Persist an already-fetched set list (no HTTP). Used when a game-specific
// Update Sets path fetches sets + side payloads in one round-trip.
Result<void> saveSets(Game game, const std::vector<Set>& sets);
// Cached read; returns an error if no local data exists yet.
Result<std::vector<Set>> getSets(Game game);
@@ -0,0 +1,35 @@
#pragma once
// YuGiOhBandaiSetCatalogService: load/save yugiohbandai/set-catalog.json.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhBandaiSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhBandaiSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhBandaiSetCatalog> load() const;
Result<void> save(const YuGiOhBandaiSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,51 @@
#pragma once
// Pure helpers: Bandai set-completion progress and per-set checklists.
// Ownership keys on (set.id, normalized setNo). Never name-only.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhBandaiCard.hpp"
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhBandaiSetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct YuGiOhBandaiChecklistEntry {
std::string setNo;
std::string name;
std::string rarity;
bool owned{false};
};
[[nodiscard]] std::vector<Language>
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& collection);
[[nodiscard]] std::vector<YuGiOhBandaiSetCompletionProgress>
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
[[nodiscard]] std::vector<YuGiOhBandaiChecklistEntry>
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,36 @@
#pragma once
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
// configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhSetCatalog> load() const;
Result<void> save(const YuGiOhSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,60 @@
#pragma once
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
// amount / rarity / firstEdition do not inflate the numerator. An optional
// languageFilter restricts ownership to cards of that language (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhSetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct YuGiOhChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
// ASCII-only tolower for sort/filter parity with the legacy TS path:
// String.prototype.toLowerCase() on English/German/etc. card metadata behaves
// identically for this byte range.
[[nodiscard]] inline std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
} // namespace ccm
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "ccm/util/Result.hpp"
#include <string>
#include <string_view>
namespace ccm {
// Shared classification for raw HTTP GET outcomes (transport vs status vs OK).
// `CprHttpClient::get` delegates here so doctest can exercise the branches
// without touching libcpr or the network stack.
[[nodiscard]] inline Result<std::string> mapHttpGetResponse(bool curlTransportError,
std::string_view curlErrorMessage,
long httpStatusCode,
std::string responseBody,
std::string_view requestUrl) {
if (curlTransportError) {
return Result<std::string>::err(std::string("HTTP error: ") +
std::string(curlErrorMessage));
}
if (httpStatusCode < 200 || httpStatusCode >= 300) {
return Result<std::string>::err(
"HTTP " + std::to_string(httpStatusCode) + " from " +
std::string(requestUrl));
}
return Result<std::string>::ok(std::move(responseBody));
}
} // namespace ccm
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <sstream>
#include <string>
#include <string_view>
namespace ccm {
// Percent-encode all bytes that are not unreserved per RFC 3986
// (A-Z / a-z / 0-9 / - . _ ~). Needed because cpr does not encode the URL
// string passed to IHttpClient::get.
[[nodiscard]] inline std::string rfc3986PercentEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
} // namespace ccm
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Natural (alphanumeric) ordering for collector / set numbers.
// Digit runs compare as integers so "2" < "10" < "100"; non-digit runs use
// ordinary string order (e.g. "SWSH001" < "SWSH002").
#include <string_view>
namespace ccm {
// strcmp-style: <0 if a < b, 0 if equal (after natural + lex tie-break), >0 if a > b.
[[nodiscard]] int compareSetNoNatural(std::string_view a, std::string_view b) noexcept;
} // namespace ccm
@@ -69,4 +69,31 @@ namespace ccm {
&& std::isdigit(static_cast<unsigned char>(tail[1])) != 0;
}
// Canonical short-form for Yu-Gi-Oh rarities used by the overview table.
// Returns empty when rarity is unknown.
[[nodiscard]] inline std::string ygoRarityShortCode(std::string_view rarity) {
std::string normalized;
normalized.reserve(rarity.size());
for (unsigned char c : rarity) {
if (std::isspace(c) != 0) continue;
if (c == '\'' || c == '`' || c == '-') continue;
normalized.push_back(static_cast<char>(std::tolower(c)));
}
if (normalized == "common") return "C";
if (normalized == "rare") return "R";
if (normalized == "superrare") return "SR";
if (normalized == "ultrarare") return "UR";
if (normalized == "secretrare") return "ScR";
if (normalized == "quartercenturysecretrare") return "QCScR";
if (normalized == "qcsr") return "QCScR";
if (normalized == "starlightrare") return "StR";
if (normalized == "collectorsrare") return "CR";
if (normalized == "ghostrare") return "GR";
if (normalized == "ultimaterare") return "UtR";
if (normalized == "platinumsecretrare") return "PlScR";
if (normalized == "prismaticsecretrare") return "PScR";
return {};
}
} // namespace ccm
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// Resolves a Yu-Gi-Oh! product code (YGOPRODeck `set_code`, stored as `Set.id`)
// against a cached set list. Used by the Yu-Gi-Oh! edit dialog "set code" mode.
#include "ccm/domain/Set.hpp"
#include <cctype>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhSetShorthandLookup {
enum class Kind { Unique, NotFound, Ambiguous };
Kind kind{Kind::NotFound};
std::size_t index{0};
};
[[nodiscard]] inline std::string normalizeYuGiOhSetIdForLookup(std::string_view id) {
std::string out;
out.reserve(id.size());
for (unsigned char uch : id) {
out.push_back(static_cast<char>(std::tolower(uch)));
}
return out;
}
[[nodiscard]] inline std::string_view trimAsciiWhitespace(std::string_view s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
s.remove_prefix(1);
}
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
s.remove_suffix(1);
}
return s;
}
[[nodiscard]] inline YuGiOhSetShorthandLookup lookupYuGiOhSetByShorthand(
std::string_view query, const std::vector<Set>& sets) {
const std::string_view trimmed = trimAsciiWhitespace(query);
if (trimmed.empty()) {
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
}
const std::string qNorm = normalizeYuGiOhSetIdForLookup(trimmed);
std::size_t firstIdx = 0;
int matchCount = 0;
for (std::size_t i = 0; i < sets.size(); ++i) {
if (normalizeYuGiOhSetIdForLookup(sets[i].id) == qNorm) {
if (matchCount == 0) firstIdx = i;
++matchCount;
if (matchCount > 1) {
return {YuGiOhSetShorthandLookup::Kind::Ambiguous, 0};
}
}
}
if (matchCount == 1) {
return {YuGiOhSetShorthandLookup::Kind::Unique, firstIdx};
}
return {YuGiOhSetShorthandLookup::Kind::NotFound, 0};
}
} // namespace ccm
+5
View File
@@ -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);
}
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/DigiBattle99Card.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const DigiBattle99Card& 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, DigiBattle99Card& 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
@@ -0,0 +1,40 @@
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
namespace ccm {
const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack(
std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+99 -35
View File
@@ -3,29 +3,48 @@
#include <stdexcept>
#include <string>
#if defined(__GNUC__) || defined(__clang__)
#define CCM_UNREACHABLE() __builtin_unreachable()
#else
#define CCM_UNREACHABLE() ((void)0)
#endif
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::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "YuGiOh";
case Game::DigiBattle99: return "DigiBattle99";
case Game::YuGiOhBandai: return "YuGiOhBandai";
case Game::JapanesePokemon: return "JapanesePokemon";
}
return "Magic";
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";
}
return "English";
CCM_UNREACHABLE();
}
std::string_view to_string(Condition c) noexcept {
@@ -38,7 +57,7 @@ std::string_view to_string(Condition c) noexcept {
case Condition::Played: return "Played";
case Condition::Poor: return "Poor";
}
return "Mint";
CCM_UNREACHABLE();
}
std::string_view to_string(Theme t) noexcept {
@@ -46,25 +65,38 @@ std::string_view to_string(Theme t) noexcept {
case Theme::Light: return "Light";
case Theme::Dark: return "Dark";
}
return "Light";
CCM_UNREACHABLE();
}
std::optional<Game> 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 == "Magic") return Game::Magic;
if (s == "Pokemon") return Game::Pokemon;
if (s == "YuGiOh") return Game::YuGiOh;
if (s == "DigiBattle99") return Game::DigiBattle99;
if (s == "YuGiOhBandai") return Game::YuGiOhBandai;
if (s == "JapanesePokemon") return Game::JapanesePokemon;
return std::nullopt;
}
std::optional<PokemonRegion> pokemonRegionFromString(std::string_view s) noexcept {
if (s == "West") return PokemonRegion::West;
if (s == "Asia") return PokemonRegion::Asia;
return std::nullopt;
}
std::optional<Language> 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;
}
@@ -85,15 +117,18 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 3>& allGames() noexcept {
static constexpr std::array<Game, 3> v{Game::Magic, Game::Pokemon, Game::YuGiOh};
const std::array<Game, 5>& allGames() noexcept {
static constexpr std::array<Game, 5> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::YuGiOhBandai,
Game::DigiBattle99};
return v;
}
const std::array<Language, 8>& allLanguages() noexcept {
static constexpr std::array<Language, 8> v{
const std::array<Language, 10>& allLanguages() noexcept {
static constexpr std::array<Language, 10> 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;
}
@@ -111,16 +146,45 @@ const std::array<Theme, 2>& 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<const Language> languagesForPokemonRegion(PokemonRegion r) noexcept {
static constexpr std::array<Language, 6> kWest{
Language::English, Language::German, Language::French,
Language::Spanish, Language::Italian, Language::Russian};
static constexpr std::array<Language, 4> 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<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Game value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, PokemonRegion& v) {
auto parsed = pokemonRegionFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown PokemonRegion value: " + j.get<std::string>());
v = *parsed;
}
void from_json(const nlohmann::json& j, Language& v) {
auto parsed = languageFromString(j.get<std::string>());
if (!parsed) throw std::invalid_argument("Unknown Language value: " + j.get<std::string>());
+39
View File
@@ -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
+10
View File
@@ -1,5 +1,7 @@
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const PokemonCard& c) {
@@ -17,6 +19,7 @@ void to_json(nlohmann::json& j, const PokemonCard& c) {
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
{"region", c.region},
};
}
@@ -34,6 +37,13 @@ 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);
// Migrate legacy pokemontcg.io West set ids to TCGdex EN on load so the
// next collection save persists canonical ids. Asia ids are untouched.
if (c.region == PokemonRegion::West && !c.set.id.empty()) {
c.set.id = canonicalizeWestSetId(c.set.id);
}
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/PokemonSetCatalog.hpp"
namespace ccm {
const PokemonSetCatalogPack* PokemonSetCatalog::findPack(std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const PokemonCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, PokemonCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const PokemonSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, PokemonSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/YuGiOhBandaiCard.hpp"
namespace ccm {
void to_json(nlohmann::json& j, const YuGiOhBandaiCard& c) {
j = nlohmann::json{
{"id", c.id},
{"amount", c.amount},
{"name", c.name},
{"set", c.set},
{"setNo", c.setNo},
{"rarity", c.rarity},
{"note", c.note},
{"images", c.images},
{"language", c.language},
{"condition", c.condition},
{"holo", c.holo},
{"signed", c.signed_},
{"altered", c.altered},
};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiCard& c) {
j.at("id").get_to(c.id);
j.at("amount").get_to(c.amount);
j.at("name").get_to(c.name);
j.at("set").get_to(c.set);
j.at("setNo").get_to(c.setNo);
j.at("rarity").get_to(c.rarity);
j.at("note").get_to(c.note);
j.at("images").get_to(c.images);
j.at("language").get_to(c.language);
j.at("condition").get_to(c.condition);
j.at("holo").get_to(c.holo);
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
} // namespace ccm
@@ -0,0 +1,45 @@
#include "ccm/domain/YuGiOhBandaiSetCatalog.hpp"
namespace ccm {
const YuGiOhBandaiSetCatalogPack* YuGiOhBandaiSetCatalog::findPack(
std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const YuGiOhBandaiCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}, {"rarity", c.rarity}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
if (j.contains("rarity")) {
j.at("rarity").get_to(c.rarity);
} else {
c.rarity.clear();
}
}
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const YuGiOhBandaiSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, YuGiOhBandaiSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
-3
View File
@@ -15,7 +15,6 @@ void to_json(nlohmann::json& j, const YuGiOhCard& c) {
{"condition", c.condition},
{"firstEdition", c.firstEdition},
{"rarity", c.rarity},
{"rarityCode", c.rarityCode},
{"signed", c.signed_},
{"altered", c.altered},
};
@@ -33,8 +32,6 @@ void from_json(const nlohmann::json& j, YuGiOhCard& c) {
j.at("condition").get_to(c.condition);
j.at("firstEdition").get_to(c.firstEdition);
j.at("rarity").get_to(c.rarity);
if (j.contains("rarityCode")) j.at("rarityCode").get_to(c.rarityCode);
else c.rarityCode.clear();
j.at("signed").get_to(c.signed_);
j.at("altered").get_to(c.altered);
}
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/YuGiOhSetCatalog.hpp"
namespace ccm {
const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
@@ -0,0 +1,222 @@
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <string>
#include <unordered_set>
#include <vector>
namespace ccm {
namespace {
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
std::string toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
bool cardInPack(const nlohmann::json& card, std::string_view packName) {
if (packName.empty()) return true;
if (!card.contains("set_name") || !card.at("set_name").is_array()) return false;
for (const auto& pack : card.at("set_name")) {
if (pack.is_string() && pack.get<std::string>() == packName) return true;
}
return false;
}
} // namespace
DigiBattle99CardPreviewSource::DigiBattle99CardPreviewSource(IHttpClient& http)
: http_(http) {}
std::string DigiBattle99CardPreviewSource::normalizeCardNumber(std::string_view setNo) {
std::string s = trim(std::string(setNo));
if (s.empty()) return s;
// Uppercase leading alphabetic prefix (ST / BO / MO / Fx-style).
std::size_t i = 0;
while (i < s.size() && std::isalpha(static_cast<unsigned char>(s[i]))) {
s[i] = static_cast<char>(std::toupper(static_cast<unsigned char>(s[i])));
++i;
}
return s;
}
std::string DigiBattle99CardPreviewSource::buildImageUrl(std::string_view setNo) {
const std::string id = normalizeCardNumber(setNo);
return std::string(kImageBase) + id + ".jpg";
}
std::string DigiBattle99CardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName,
std::string_view setNo) {
std::string url = "https://digimoncard.io/api-public/search.php?series=";
url += rfc3986PercentEncode(kSeries);
if (!name.empty()) {
url += "&n=";
url += rfc3986PercentEncode(name);
}
if (!setName.empty()) {
url += "&pack=";
url += rfc3986PercentEncode(setName);
}
const std::string num = normalizeCardNumber(setNo);
if (!num.empty()) {
url += "&card=";
url += rfc3986PercentEncode(num);
}
url += "&sort=name&sortdirection=asc";
return url;
}
Result<std::string, PreviewLookupError>
DigiBattle99CardPreviewSource::parseImageUrlFromSearch(const std::string& body,
std::string_view wantedCardName) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return R::err({K::NotFound, j.value("error", std::string{"No cards found."})});
}
if (!j.is_array()) {
return R::err({K::Transient, "digimoncard.io Digi-Battle response is not a JSON array."});
}
if (j.empty()) {
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
}
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
const nlohmann::json* chosen = nullptr;
for (const auto& card : j) {
if (!wantedLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedLower) continue;
}
chosen = &card;
break;
}
if (chosen == nullptr) {
return R::err({K::NotFound, "digimoncard.io returned no matching Digi-Battle cards."});
}
const std::string id = normalizeCardNumber(chosen->value("id", ""));
if (id.empty()) {
return R::err({K::NotFound, "Digi-Battle card has no id / card number."});
}
return R::ok(buildImageUrl(id));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
DigiBattle99CardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setName,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string num = normalizeCardNumber(setNo);
if (!num.empty()) {
return R::ok(buildImageUrl(num));
}
if (name.empty()) {
return R::err({K::NotFound, "Digi-Battle preview requires a card name or set number."});
}
const std::string url = buildSearchUrl(name, setName, "");
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parseImageUrlFromSearch(resp.value(), name);
}
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setName,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return R::err(j.value("error", std::string{"No cards found."}));
}
if (!j.is_array() || j.empty()) {
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
}
const std::string wantedPack = trim(std::string(setName));
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
for (const auto& card : j) {
if (!wantedNameLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedNameLower) continue;
}
if (!cardInPack(card, wantedPack)) continue;
AutoDetectedPrint out;
out.setNo = normalizeCardNumber(card.value("id", ""));
out.rarity = ""; // Digi-Battle UI is Pokémon-like; rarity not persisted.
if (out.setNo.empty()) continue;
collected.push_back(std::move(out));
}
if (collected.empty()) {
if (!wantedNameLower.empty() && !wantedPack.empty()) {
return R::err("Could not auto-detect Digi-Battle set print metadata.");
}
return R::err("digimoncard.io returned no matching Digi-Battle cards.");
}
std::vector<AutoDetectedPrint> deduped;
deduped.reserve(collected.size());
std::unordered_set<std::string> seen;
seen.reserve(collected.size() * 2);
for (auto& p : collected) {
if (seen.insert(p.setNo).second) deduped.push_back(std::move(p));
}
return R::ok(std::move(deduped));
} catch (const std::exception& e) {
return R::err(std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
}
Result<AutoDetectedPrint> DigiBattle99CardPreviewSource::detectFirstPrint(
std::string_view name,
std::string_view setName) {
auto list = detectPrintVariants(name, setName);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect Digi-Battle set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> DigiBattle99CardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setName) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildSearchUrl(name, setName, "");
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setName, name);
}
// Retry name-only; still filter by pack in parsePrintVariants.
const std::string fallbackUrl = buildSearchUrl(name, "", "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setName, name);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/digibattle99/DigiBattle99GameModule.hpp"
namespace ccm {
DigiBattle99GameModule::DigiBattle99GameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
@@ -0,0 +1,206 @@
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <string>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
// Curated EN release dates for the vintage Digi-Battle product line.
// Series 1 Starter is verified 1999-06-01; other entries use digimoncard.io /
// checklist years (day unknown -> YYYY/01/01 or mid-year anchors for ordering).
const std::unordered_map<std::string, std::string>& curatedReleaseDates() {
static const std::unordered_map<std::string, std::string> kDates{
{"Series 1 Starter Set", "1999/06/01"},
{"Series 1 Booster Pack", "1999/06/01"},
{"Series 2 Booster Pack", "1999/09/01"},
{"Series 3 Booster Pack", "2000/01/01"},
{"Series 4 Booster Pack", "2000/06/01"},
{"Series 5 Booster Pack", "2000/10/01"},
{"Series 6 Booster Pack", "2001/01/01"},
{"Street Starter Set 1", "2001/01/01"},
{"Street Starter Set 2", "2001/02/01"},
{"Street Starter Set 3", "2001/03/01"},
{"Street Starter Set 4", "2001/04/01"},
{"Digimon The Movie Promo Cards", "2000/10/01"},
};
return kDates;
}
std::string releaseDateForPack(const std::string& packName) {
const auto& dates = curatedReleaseDates();
const auto it = dates.find(packName);
if (it != dates.end()) return it->second;
return {};
}
Result<nlohmann::json> parseSearchArray(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return Result<nlohmann::json>::err(
j.value("error", std::string{"digimoncard.io set search error"}));
}
if (!j.is_array()) {
return Result<nlohmann::json>::err(
"digimoncard.io Digi-Battle response is not a JSON array.");
}
return Result<nlohmann::json>::ok(j);
} catch (const std::exception& e) {
return Result<nlohmann::json>::err(
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
}
} // namespace
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
std::string out;
out.reserve(packName.size());
bool pendingHyphen = false;
for (unsigned char ch : packName) {
if (std::isalnum(ch)) {
if (pendingHyphen && !out.empty()) out.push_back('-');
pendingHyphen = false;
out.push_back(static_cast<char>(std::tolower(ch)));
} else {
pendingHyphen = !out.empty();
}
}
return out;
}
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
auto arr = parseSearchArray(body);
if (!arr) return Result<std::vector<Set>>::err(arr.error());
// Preserve first-seen order of pack names, then sort by release date.
std::unordered_set<std::string> seen;
std::vector<std::string> packNames;
packNames.reserve(16);
for (const auto& entry : arr.value()) {
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string name = pack.get<std::string>();
if (name.empty()) continue;
if (seen.insert(name).second) packNames.push_back(name);
}
}
std::vector<Set> out;
out.reserve(packNames.size());
for (const auto& name : packNames) {
Set s;
s.id = slugifyPackName(name);
s.name = name;
s.releaseDate = releaseDateForPack(name);
if (s.id.empty()) continue;
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
return a.name < b.name;
});
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<DigiBattle99SetCatalog> DigiBattle99SetSource::parseCatalog(const std::string& body) {
auto arr = parseSearchArray(body);
if (!arr) return Result<DigiBattle99SetCatalog>::err(arr.error());
// pack display name -> (setId, ordered unique cards by first-seen setNo)
struct PackBuild {
std::string setId;
std::string setName;
std::unordered_set<std::string> seenNos;
std::vector<DigiBattle99CatalogCard> cards;
};
std::unordered_map<std::string, PackBuild> byName;
for (const auto& entry : arr.value()) {
if (!entry.contains("name") || !entry.at("name").is_string()) continue;
if (!entry.contains("id") || !entry.at("id").is_string()) continue;
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
DigiBattle99CatalogCard card;
card.name = entry.at("name").get<std::string>();
card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(
entry.at("id").get<std::string>());
if (card.setNo.empty()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string packName = pack.get<std::string>();
if (packName.empty()) continue;
auto& build = byName[packName];
if (build.setName.empty()) {
build.setName = packName;
build.setId = slugifyPackName(packName);
}
if (build.setId.empty()) continue;
if (!build.seenNos.insert(card.setNo).second) continue;
build.cards.push_back(card);
}
}
DigiBattle99SetCatalog catalog;
catalog.packs.reserve(byName.size());
for (auto& [_, build] : byName) {
if (build.setId.empty()) continue;
std::sort(build.cards.begin(), build.cards.end(),
[](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
DigiBattle99SetCatalogPack pack;
pack.setId = std::move(build.setId);
pack.setName = std::move(build.setName);
pack.cards = std::move(build.cards);
catalog.packs.push_back(std::move(pack));
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) {
return a.setName < b.setName;
});
return Result<DigiBattle99SetCatalog>::ok(std::move(catalog));
}
Result<DigiBattle99SetSource::FetchWithCatalog>
DigiBattle99SetSource::fetchAllWithCatalog() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<FetchWithCatalog>::err(resp.error());
auto sets = parseResponse(resp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
auto catalog = parseCatalog(resp.value());
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog).value();
return Result<FetchWithCatalog>::ok(std::move(out));
}
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
auto both = fetchAllWithCatalog();
if (!both) return Result<std::vector<Set>>::err(both.error());
return Result<std::vector<Set>>::ok(std::move(both).value().sets);
}
} // namespace ccm
@@ -1,40 +1,16 @@
#include "ccm/games/magic/MagicCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
namespace ccm {
namespace {
// Percent-encode all bytes that are not unreserved per RFC 3986
// (A-Z / a-z / 0-9 / - . _ ~). Spaces become %20, quotes become %22, etc.
// Used to keep Scryfall's `q=...` parameter syntactically valid through cpr,
// which does not URL-encode the URL string we hand it.
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
// Apply the same name massaging as the legacy query path before sending.
std::string sanitizeName(std::string_view name) {
std::string s(name);
@@ -59,7 +35,8 @@ std::string MagicCardPreviewSource::buildSearchUrl(std::string_view name,
query += sanitized;
query += "\" AND set:";
query += std::string(setId);
return std::string("https://api.scryfall.com/cards/search?q=") + urlEncode(query);
return std::string("https://api.scryfall.com/cards/search?q=") +
rfc3986PercentEncode(query);
}
Result<std::string, PreviewLookupError>
@@ -1,47 +1,28 @@
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <sstream>
#include <string>
#include <unordered_set>
#include <vector>
namespace ccm {
namespace {
// RFC 3986 percent-encoder for the search-query payload. Same rules as the
// Magic implementation; kept private so the two can drift independently if a
// future API requires it.
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
return s;
}
// Strip everything after the first '/' in a Pokemon collector number.
// The Pokemon TCG API expects `number:"4"`, but cards are commonly stored as
// `4/102`. Without this, no API match is found.
std::string normalizeNumber(std::string_view setNo) {
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
std::string toLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
return s;
}
@@ -50,54 +31,145 @@ std::string normalizeNumber(std::string_view setNo) {
PokemonCardPreviewSource::PokemonCardPreviewSource(IHttpClient& http) : http_(http) {}
std::string PokemonCardPreviewSource::normalizeCollectorNumber(std::string_view setNo) {
std::string s(setNo);
const auto slash = s.find('/');
if (slash != std::string::npos) {
s = s.substr(0, slash);
}
return s;
}
std::string PokemonCardPreviewSource::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";
}
std::string PokemonCardPreviewSource::buildCardByIdUrl(std::string_view setId,
std::string_view setNo) {
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
std::string id = idCanon + "-" + num;
return std::string("https://api.tcgdex.net/v2/en/cards/") + rfc3986PercentEncode(id);
}
std::string PokemonCardPreviewSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/en/sets/") +
rfc3986PercentEncode(canonicalizeWestSetId(setId));
}
std::string PokemonCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
// Build the unencoded query first so the output matches what the Pokemon
// TCG search syntax expects: name:"<name>" set.id:<setId> number:<num>.
std::string query = "name:\"";
query += std::string(name);
query += "\"";
if (!setId.empty()) {
query += " set.id:";
query += std::string(setId);
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
std::string url = "https://api.tcgdex.net/v2/en/cards?";
bool first = true;
auto append = [&](std::string_view key, std::string_view value) {
if (value.empty()) return;
if (!first) url += '&';
first = false;
url += std::string(key);
url += "=eq:";
url += rfc3986PercentEncode(value);
};
if (!idCanon.empty() && !num.empty()) {
append("set.id", idCanon);
append("localId", num);
} else {
append("name", name);
append("set.id", idCanon);
append("localId", num);
}
const std::string num = normalizeNumber(setNo);
if (!num.empty()) {
query += " number:";
query += num;
return url;
}
Result<std::vector<PokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
PokemonCardPreviewSource::parseSetCards(const std::string& body) {
using R = Result<std::vector<SetCardRow>, 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 EN set detail missing 'cards' array."});
}
std::vector<SetCardRow> 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()) {
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) row.localId = id.substr(dash + 1);
}
row.name = card.value("name", "");
row.rarity = card.value("rarity", "");
if (card.contains("image") && card.at("image").is_string()) {
row.imageBase = card.at("image").get<std::string>();
}
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 EN set detail JSON parse error: ") + e.what()});
}
return std::string("https://api.pokemontcg.io/v2/cards?q=") + urlEncode(query);
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseResponse(const std::string& body) {
PokemonCardPreviewSource::parseCardByIdResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
return R::err({K::Transient, "Pokemon TCG response missing 'data' array."});
if (!j.is_object()) {
return R::err({K::Transient, "TCGdex EN card response is not a JSON object."});
}
const auto& data = j.at("data");
if (data.empty()) {
return R::err({K::NotFound, "Pokemon TCG returned no matching cards."});
if (!j.contains("image") || j.at("image").is_null()) {
return R::err({K::NotFound, "TCGdex EN card has no image."});
}
const auto& first = data.at(0);
if (!first.contains("images") || !first.at("images").is_object()) {
return R::err({K::NotFound, "Card has no 'images' object."});
if (!j.at("image").is_string()) {
return R::err({K::Transient, "TCGdex EN card image field is not a string."});
}
const auto& images = first.at("images");
if (images.contains("large") && images.at("large").is_string()) {
return R::ok(images.at("large").get<std::string>());
const std::string base = j.at("image").get<std::string>();
if (base.empty()) {
return R::err({K::NotFound, "TCGdex EN card has no image."});
}
if (images.contains("small") && images.at("small").is_string()) {
return R::ok(images.at("small").get<std::string>());
}
return R::err({K::NotFound, "Card has no 'large' or 'small' image variant."});
return R::ok(imageUrlFromBase(base));
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Pokemon TCG JSON parse error: ") + e.what()});
std::string("TCGdex EN card JSON parse error: ") + e.what()});
}
}
Result<std::string, PreviewLookupError>
PokemonCardPreviewSource::parseSearchResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return R::err({K::Transient, "TCGdex EN cards search response is not an array."});
}
if (j.empty()) {
return R::err({K::NotFound, "TCGdex EN returned no matching cards."});
}
for (const auto& card : j) {
if (!card.contains("image") || !card.at("image").is_string()) continue;
const std::string base = card.at("image").get<std::string>();
if (base.empty()) continue;
return R::ok(imageUrlFromBase(base));
}
return R::err({K::NotFound, "TCGdex EN matching cards have no image."});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("TCGdex EN cards search JSON parse error: ") + e.what()});
}
}
@@ -107,10 +179,124 @@ PokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
using K = PreviewLookupError::Kind;
const std::string url = buildSearchUrl(name, setId, setNo);
const std::string idCanon = canonicalizeWestSetId(setId);
const std::string num = normalizeCollectorNumber(setNo);
if (!idCanon.empty() && !num.empty()) {
auto byId = http_.get(buildCardByIdUrl(idCanon, num));
if (byId) {
auto img = parseCardByIdResponse(byId.value());
if (img) return img;
// NotFound / Transient schema: fall through to search.
}
}
const std::string url = buildSearchUrl(name, idCanon, num);
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parseResponse(resp.value());
return parseSearchResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view /*setId*/,
std::string_view wantedCardName) {
using R = Result<std::vector<AutoDetectedPrint>>;
auto rows = parseSetCards(body);
if (!rows) {
return R::err(rows.error().message);
}
const std::string wantedLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
if (!wantedLower.empty()) {
if (toLower(trim(row.name)) != wantedLower) continue;
}
const std::string localId = normalizeCollectorNumber(row.localId);
if (localId.empty() || !seen.insert(localId + '\0' + row.rarity).second) continue;
AutoDetectedPrint print;
print.setNo = localId;
print.rarity = row.rarity;
out.push_back(std::move(print));
}
if (out.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::ok(std::move(out));
}
Result<AutoDetectedPrint> PokemonCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list || list.value().empty()) {
if (!list) return Result<AutoDetectedPrint>::err(list.error());
return Result<AutoDetectedPrint>::err("Could not auto-detect set print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>> PokemonCardPreviewSource::detectPrintVariants(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string idCanon = canonicalizeWestSetId(setId);
if (!idCanon.empty()) {
auto detail = http_.get(buildSetDetailUrl(idCanon));
if (detail) {
auto parsed = parsePrintVariants(detail.value(), idCanon, name);
if (parsed) return parsed;
}
}
// Fallback: filtered cards search by name (+ optional set).
const std::string url = buildSearchUrl(name, idCanon, "");
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
try {
const auto j = nlohmann::json::parse(resp.value());
if (!j.is_array() || j.empty()) {
return R::err("TCGdex EN returned no matching cards.");
}
const std::string wantedLower = toLower(trim(std::string(name)));
std::vector<AutoDetectedPrint> collected;
std::unordered_set<std::string> seen;
for (const auto& card : j) {
if (!wantedLower.empty()) {
const std::string cardName = trim(card.value("name", ""));
if (toLower(cardName) != wantedLower) continue;
}
if (!idCanon.empty()) {
std::string cardSetId;
if (card.contains("set") && card.at("set").is_object()) {
cardSetId = trim(card.at("set").value("id", ""));
} else if (card.contains("id") && card.at("id").is_string()) {
// Slim search hits are "setId-localId".
const std::string id = card.at("id").get<std::string>();
const auto dash = id.rfind('-');
if (dash != std::string::npos) cardSetId = id.substr(0, dash);
}
if (cardSetId != idCanon) continue;
}
AutoDetectedPrint print;
print.setNo = normalizeCollectorNumber(card.value("localId", ""));
print.rarity = trim(card.value("rarity", ""));
if (print.setNo.empty() && print.rarity.empty()) continue;
const std::string key = print.setNo + '\0' + print.rarity;
if (!seen.insert(key).second) continue;
collected.push_back(std::move(print));
}
if (collected.empty()) {
return R::err("Could not auto-detect set print metadata.");
}
return R::ok(std::move(collected));
} catch (const std::exception& e) {
return R::err(std::string("TCGdex EN cards search JSON parse error: ") + e.what());
}
}
} // namespace ccm
@@ -0,0 +1,70 @@
#include "ccm/games/pokemon/PokemonCollectionSetSync.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
namespace ccm {
namespace {
std::unordered_map<std::string, const Set*> indexById(const std::vector<Set>& sets) {
std::unordered_map<std::string, const Set*> out;
out.reserve(sets.size());
for (const auto& s : sets) {
if (s.id.empty()) continue;
out.emplace(s.id, &s);
}
return out;
}
bool applySetMetadata(PokemonCard& card, const Set& upstream) {
bool changed = false;
if (card.set.name != upstream.name) {
card.set.name = upstream.name;
changed = true;
}
if (card.set.releaseDate != upstream.releaseDate) {
card.set.releaseDate = upstream.releaseDate;
changed = true;
}
return changed;
}
} // namespace
std::size_t syncPokemonCollectionSets(std::vector<PokemonCard>& cards,
const std::vector<Set>& westSets,
const std::vector<Set>& asiaSets) {
const auto westById = indexById(westSets);
const auto asiaById = indexById(asiaSets);
std::size_t touched = 0;
for (auto& card : cards) {
bool changed = false;
if (card.region == PokemonRegion::West) {
if (!card.set.id.empty()) {
const std::string canon = canonicalizeWestSetId(card.set.id);
if (canon != card.set.id) {
card.set.id = canon;
changed = true;
}
}
if (!card.set.id.empty()) {
if (const auto it = westById.find(card.set.id); it != westById.end()) {
if (applySetMetadata(card, *it->second)) changed = true;
}
}
} else if (card.region == PokemonRegion::Asia) {
if (!card.set.id.empty()) {
if (const auto it = asiaById.find(card.set.id); it != asiaById.end()) {
if (applySetMetadata(card, *it->second)) changed = true;
}
}
}
if (changed) ++touched;
}
return touched;
}
} // namespace ccm
+141 -16
View File
@@ -1,45 +1,170 @@
#include "ccm/games/pokemon/PokemonSetSource.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <string>
#include <unordered_set>
#include <utility>
namespace ccm {
PokemonSetSource::PokemonSetSource(IHttpClient& http) : http_(http) {}
Result<std::vector<Set>> PokemonSetSource::parseResponse(const std::string& body) {
std::string PokemonSetSource::rewriteReleaseDate(std::string_view isoDate) {
std::string out(isoDate);
for (char& ch : out) {
if (ch == '-') ch = '/';
}
return out;
}
std::string PokemonSetSource::buildSetDetailUrl(std::string_view setId) {
return std::string("https://api.tcgdex.net/v2/en/sets/") +
rfc3986PercentEncode(setId);
}
Result<std::vector<Set>> PokemonSetSource::parseListResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("data") || !j.at("data").is_array()) {
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"Pokemon TCG API response missing 'data' array.");
"TCGdex EN sets response is not a JSON array.");
}
std::vector<Set> out;
out.reserve(j.at("data").size());
for (const auto& entry : j.at("data")) {
out.reserve(j.size());
for (const auto& entry : j) {
Set s;
s.id = entry.value("id", "");
s.name = entry.value("name", "");
// Pokemon TCG API already returns "releaseDate" in YYYY/MM/DD;
// no separator rewrite needed (cf. Scryfall's "released_at").
s.releaseDate = entry.value("releaseDate", "");
s.id = entry.value("id", "");
if (s.id.empty()) continue;
s.name = entry.value("name", "");
s.releaseDate = {}; // filled from set detail
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("Pokemon TCG JSON parse error: ") + e.what());
std::string("TCGdex EN sets JSON parse error: ") + e.what());
}
}
Result<std::string> PokemonSetSource::parseReleaseDate(const std::string& detailBody) {
try {
const auto j = nlohmann::json::parse(detailBody);
if (!j.is_object()) {
return Result<std::string>::err(
"TCGdex EN set detail response is not a JSON object.");
}
const std::string raw = j.value("releaseDate", "");
if (raw.empty()) {
return Result<std::string>::ok(std::string{});
}
return Result<std::string>::ok(rewriteReleaseDate(raw));
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("TCGdex EN set detail JSON parse error: ") + e.what());
}
}
Result<PokemonSetCatalogPack> PokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set) {
auto rows = PokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
const std::string localId =
PokemonCardPreviewSource::normalizeCollectorNumber(row.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name = row.name;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> PokemonSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return parsed;
std::vector<Set> out = std::move(parsed).value();
for (auto& s : out) {
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<std::vector<Set>>::ok(std::move(out));
}
Result<PokemonSetSource::FetchWithCatalog> PokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> sets = std::move(parsed).value();
PokemonSetCatalog catalog;
catalog.packs.reserve(sets.size());
for (auto& s : sets) {
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) continue;
if (s.releaseDate.empty()) {
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
auto pack = parseCatalogPackFromSetDetail(detail.value(), s);
if (pack) {
catalog.packs.push_back(std::move(pack).value());
}
}
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
FetchWithCatalog out;
out.sets = std::move(sets);
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -0,0 +1,92 @@
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include <unordered_map>
namespace ccm {
namespace {
// Built by name-matching PokemonTCG/pokemon-tcg-data set ids against
// api.tcgdex.net/v2/en/sets. Only divergences are listed; shared ids
// (base1, swsh1, sv10, sve, …) pass through unchanged.
const std::unordered_map<std::string, std::string>& legacyAliases() {
static const std::unordered_map<std::string, std::string> kMap{
// Classic / EX / HGSS renames
{"base6", "lc"},
{"bp", "bog"},
{"tk1a", "tk-ex-latia"},
{"tk1b", "tk-ex-latio"},
{"tk2a", "tk-ex-p"},
{"tk2b", "tk-ex-m"},
{"hsp", "hgssp"},
// McDonald's Collections
{"mcd11", "2011bw"},
{"mcd12", "2012bw"},
{"mcd14", "2014xy"},
{"mcd15", "2015xy"},
{"mcd16", "2016xy"},
{"mcd17", "2017sm"},
{"mcd18", "2018sm"},
{"mcd19", "2019sm"},
{"mcd21", "2021swsh"},
{"mcd22", "2022swsh"},
{"mcd23", "2023sv"},
{"mcd24", "2024sv"},
// SM specials
{"sm35", "sm3.5"},
{"sm75", "sm7.5"},
// SWSH specials / galleries
{"swsh35", "swsh3.5"},
{"swsh45", "swsh4.5"},
{"swsh45sv", "swsh4.5sv"},
{"cel25c", "cel25cc"},
{"swsh9tg", "swsh9.5tg"},
{"swsh10tg", "swsh10.5tg"},
{"pgo", "swsh10.5"},
{"swsh11tg", "swsh11.5tg"},
{"swsh12tg", "swsh12.5tg"},
{"swsh12pt5", "swsh12.5"},
{"swsh12pt5gg", "swsh12.5gg"},
// Scarlet & Violet (pokemontcg used unpadded / pt5 forms)
{"sv1", "sv01"},
{"sv2", "sv02"},
{"sv3", "sv03"},
{"sv3pt5", "sv03.5"},
{"sv4", "sv04"},
{"sv4pt5", "sv04.5"},
{"sv5", "sv05"},
{"sv6", "sv06"},
{"sv6pt5", "sv06.5"},
{"sv7", "sv07"},
{"sv8", "sv08"},
{"sv8pt5", "sv08.5"},
{"sv9", "sv09"},
{"zsv10pt5", "sv10.5b"},
{"rsv10pt5", "sv10.5w"},
// Mega Evolution era
{"me1", "me01"},
{"me2", "me02"},
{"me2pt5", "me02.5"},
{"me3", "me03"},
{"me4", "me04"},
{"me5", "me05"},
};
return kMap;
}
} // namespace
std::string canonicalizeWestSetId(std::string_view setId) {
if (setId.empty()) return {};
const auto& map = legacyAliases();
const auto it = map.find(std::string(setId));
if (it != map.end()) return it->second;
return std::string(setId);
}
} // namespace ccm
@@ -0,0 +1,442 @@
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <string>
#include <unordered_set>
namespace ccm {
namespace {
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
s.erase(s.begin());
}
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) {
s.pop_back();
}
return s;
}
std::string asciiLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(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<std::vector<JapanesePokemonCardPreviewSource::SetCardRow>, PreviewLookupError>
JapanesePokemonCardPreviewSource::parseSetCards(const std::string& body) {
using R = Result<std::vector<SetCardRow>, 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<SetCardRow> 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<std::string>();
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<std::string>();
}
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<std::string, PreviewLookupError>
JapanesePokemonCardPreviewSource::parseCardImageUrl(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
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<std::string>();
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<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::parsePrintVariants(
const std::string& body,
std::string_view setId,
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog) {
using R = Result<std::vector<AutoDetectedPrint>>;
auto rows = parseSetCards(body);
if (!rows) {
return R::err(rows.error().message);
}
const std::string wantedLower = asciiLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
std::unordered_set<std::string> 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<AutoDetectedPrint> withPreview;
std::vector<AutoDetectedPrint> 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<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectPrintVariantsFromCatalog(
std::string_view setId,
std::string_view wantedCardName,
const JapanesePokemonEnCatalog& catalog) {
using R = Result<std::vector<AutoDetectedPrint>>;
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<AutoDetectedPrint> out;
std::unordered_set<std::string> seen;
std::unordered_set<std::string> seenUrls;
if (wantedLower.empty()) {
return R::ok(std::move(out));
}
std::vector<AutoDetectedPrint> withPreview;
std::vector<AutoDetectedPrint> 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<std::string, PreviewLookupError>
JapanesePokemonCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
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<std::string, PreviewLookupError> {
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<AutoDetectedPrint>
JapanesePokemonCardPreviewSource::detectFirstPrint(std::string_view name,
std::string_view setId) {
auto variants = detectPrintVariants(name, setId);
if (!variants) return Result<AutoDetectedPrint>::err(variants.error());
if (variants.value().empty()) {
return Result<AutoDetectedPrint>::err(
"No matching Japanese Pokemon prints for that name in the set.");
}
return Result<AutoDetectedPrint>::ok(variants.value().front());
}
Result<std::vector<AutoDetectedPrint>>
JapanesePokemonCardPreviewSource::detectPrintVariants(std::string_view name,
std::string_view setId) {
if (setId.empty()) {
return Result<std::vector<AutoDetectedPrint>>::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<std::vector<AutoDetectedPrint>>::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
@@ -0,0 +1,173 @@
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <cstdint>
#include <string>
#include <string_view>
namespace ccm {
namespace {
std::string asciiLower(std::string s) {
for (char& ch : s) {
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(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<unsigned char>(hay[i - 1]));
const bool rightOk =
i + n == hay.size() ||
!std::isalnum(static_cast<unsigned char>(hay[i + n]));
if (leftOk && rightOk) return true;
}
return false;
}
} // namespace
Result<JapanesePokemonEnCatalog>
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<std::string>();
} else if (tp.is_number_integer()) {
info.tcgplayerId = std::to_string(tp.get<std::int64_t>());
} else if (tp.is_number_unsigned()) {
info.tcgplayerId = std::to_string(tp.get<std::uint64_t>());
}
}
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<JapanesePokemonEnCatalog>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<JapanesePokemonEnCatalog>::err(
std::string("Japanese Pokemon EN catalog JSON parse error: ") + e.what());
}
}
std::optional<JapanesePokemonSetEnInfo>
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<JapanesePokemonPrintEnInfo>
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<JapanesePokemonPrintEnInfo>
JapanesePokemonEnCatalog::findPrintsByName(std::string_view setId,
std::string_view cardName) const {
std::vector<JapanesePokemonPrintEnInfo> 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::vector<JapanesePokemonPrintEnInfo>
JapanesePokemonEnCatalog::printsForSet(std::string_view setId) const {
std::vector<JapanesePokemonPrintEnInfo> out;
const auto keysIt = printKeysBySet_.find(std::string(setId));
if (keysIt == printKeysBySet_.end()) return out;
out.reserve(keysIt->second.size());
for (const auto& key : keysIt->second) {
const auto pit = printsByKey_.find(key);
if (pit == printsByKey_.end()) continue;
out.push_back(pit->second);
}
return out;
}
std::string JapanesePokemonEnCatalog::tcgplayerImageUrl(std::string_view productId) {
if (productId.empty()) return {};
return std::string("https://product-images.tcgplayer.com/fit-in/437x437/") +
std::string(productId) + ".jpg";
}
std::string JapanesePokemonEnCatalog::previewImageUrlFromPrint(
const JapanesePokemonPrintEnInfo& print) {
if (!print.imageUrl.empty()) return print.imageUrl;
return tcgplayerImageUrl(print.tcgplayerId);
}
} // namespace ccm
@@ -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
@@ -0,0 +1,353 @@
#include "ccm/games/pokemonjp/JapanesePokemonSetSource.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/Rfc3986.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
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<ClassicMissingProduct, 11> 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<std::string, std::string>& setNameJaOverrides() {
// Field-level corrections for known TCGdex JA mislabels (never edit cache).
static const std::unordered_map<std::string, std::string> kOverrides{
{"SV4a", "シャイニートレジャーex"},
};
return kOverrides;
}
[[nodiscard]] bool containsCjk(std::string_view s) noexcept {
// Detect hiragana / katakana / CJK unified (UTF-8 lead bytes 0xE30xE9).
// 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;
}
void gapFillFromEnCatalog(PokemonSetCatalogPack& pack,
const JapanesePokemonEnCatalog& enCatalog) {
std::unordered_set<std::string> seen;
for (const auto& card : pack.cards) {
seen.insert(JapanesePokemonCardPreviewSource::normalizeLocalId(card.setNo));
}
for (const auto& print : enCatalog.printsForSet(pack.setId)) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name = print.nameEn;
if (name.empty()) name = print.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
}
void sortPackCards(PokemonSetCatalogPack& pack) {
std::sort(pack.cards.begin(), pack.cards.end(),
[](const PokemonCatalogCard& a, const PokemonCatalogCard& b) {
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
}
void applyEnglishSetName(Set& s, const JapanesePokemonEnCatalog& catalog) {
if (auto en = catalog.findSet(s.id)) {
if (!en->nameEn.empty()) s.name = en->nameEn;
if (!en->releaseDate.empty()) s.releaseDate = en->releaseDate;
}
if (s.name.empty() || containsCjk(s.name)) {
s.name = s.id;
}
}
} // namespace
JapanesePokemonSetSource::JapanesePokemonSetSource(
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<std::vector<Set>>
JapanesePokemonSetSource::parseListResponse(const std::string& body) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"TCGdex JA sets response is not a JSON array.");
}
std::vector<Set> 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<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("TCGdex JA sets JSON parse error: ") + e.what());
}
}
void JapanesePokemonSetSource::appendMissingClassicProducts(std::vector<Set>& 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<std::string>
JapanesePokemonSetSource::parseReleaseDate(const std::string& detailBody) {
try {
const auto j = nlohmann::json::parse(detailBody);
if (!j.is_object()) {
return Result<std::string>::err(
"TCGdex JA set detail response is not a JSON object.");
}
const std::string raw = j.value("releaseDate", "");
if (raw.empty()) {
return Result<std::string>::ok(std::string{});
}
return Result<std::string>::ok(rewriteReleaseDate(raw));
} catch (const std::exception& e) {
return Result<std::string>::err(
std::string("TCGdex JA set detail JSON parse error: ") + e.what());
}
}
PokemonSetCatalogPack JapanesePokemonSetSource::catalogPackFromEnCatalog(
const Set& set, const JapanesePokemonEnCatalog& enCatalog) {
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
for (const auto& print : enCatalog.printsForSet(set.id)) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(print.localId);
if (localId.empty()) continue;
std::string name = print.nameEn;
if (name.empty()) name = print.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
sortPackCards(pack);
return pack;
}
Result<PokemonSetCatalogPack> JapanesePokemonSetSource::parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog) {
auto rows = JapanesePokemonCardPreviewSource::parseSetCards(detailBody);
if (!rows) {
// Transient/NotFound from parse — treat empty cards as catalog-only.
if (rows.error().kind == PreviewLookupError::Kind::NotFound) {
auto pack = catalogPackFromEnCatalog(set, enCatalog);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err(
"No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
return Result<PokemonSetCatalogPack>::err(rows.error().message);
}
PokemonSetCatalogPack pack;
pack.setId = set.id;
pack.setName = set.name.empty() ? set.id : set.name;
std::unordered_set<std::string> seen;
for (const auto& row : rows.value()) {
const std::string localId =
JapanesePokemonCardPreviewSource::normalizeLocalId(row.localId);
if (localId.empty() || !seen.insert(localId).second) continue;
std::string name;
if (auto print = enCatalog.findPrint(set.id, localId)) {
name = print->nameEn;
if (name.empty()) name = print->nameJa;
}
if (name.empty()) name = row.nameJa;
if (name.empty()) name = localId;
pack.cards.push_back(PokemonCatalogCard{localId, std::move(name)});
}
gapFillFromEnCatalog(pack, enCatalog);
sortPackCards(pack);
if (pack.cards.empty()) {
return Result<PokemonSetCatalogPack>::err("No cards for set " + set.id);
}
return Result<PokemonSetCatalogPack>::ok(std::move(pack));
}
Result<std::vector<Set>> JapanesePokemonSetSource::fetchAll() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<std::vector<Set>>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return parsed;
std::vector<Set> 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).
applyEnglishSetName(s, catalog_);
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<std::vector<Set>>::ok(std::move(out));
}
Result<JapanesePokemonSetSource::FetchWithCatalog>
JapanesePokemonSetSource::fetchAllWithCatalog() {
auto listResp = http_.get(kListEndpoint);
if (!listResp) return Result<FetchWithCatalog>::err(listResp.error());
auto parsed = parseListResponse(listResp.value());
if (!parsed) return Result<FetchWithCatalog>::err(parsed.error());
std::vector<Set> sets = std::move(parsed).value();
PokemonSetCatalog catalog;
catalog.packs.reserve(sets.size());
for (auto& s : sets) {
applyEnglishSetName(s, catalog_);
auto detail = http_.get(buildSetDetailUrl(s.id));
if (!detail) {
// Classic / catalog-only products often have no TCGdex detail.
auto pack = catalogPackFromEnCatalog(s, catalog_);
if (!pack.cards.empty()) {
catalog.packs.push_back(std::move(pack));
}
continue;
}
if (s.releaseDate.empty()) {
auto date = parseReleaseDate(detail.value());
if (date && !date.value().empty()) {
s.releaseDate = std::move(date).value();
}
}
auto pack = parseCatalogPackFromSetDetail(detail.value(), s, catalog_);
if (pack) {
catalog.packs.push_back(std::move(pack).value());
} else {
auto fallback = catalogPackFromEnCatalog(s, catalog_);
if (!fallback.cards.empty()) {
catalog.packs.push_back(std::move(fallback));
}
}
}
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const PokemonSetCatalogPack& a, const PokemonSetCatalogPack& b) {
return a.setName < b.setName;
});
FetchWithCatalog out;
out.sets = std::move(sets);
out.catalog = std::move(catalog);
return Result<FetchWithCatalog>::ok(std::move(out));
}
void JapanesePokemonSetSource::augmentCachedSets(std::vector<Set>& sets) const {
// Stale caches may store set ids (or Japanese) as Set.name — re-apply the
// bundled EN catalog so names like "Pokémon Jungle" are searchable again.
for (auto& s : sets) {
applyEnglishSetName(s, catalog_);
}
appendMissingClassicProducts(sets);
std::sort(sets.begin(), sets.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
}
} // namespace ccm
@@ -1,10 +1,12 @@
#include "ccm/games/yugioh/YuGiOhCardPreviewSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include "ccm/util/Rfc3986.hpp"
#include <nlohmann/json.hpp>
#include <array>
#include <cctype>
#include <sstream>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -16,31 +18,6 @@ namespace ccm {
namespace {
// RFC 3986 percent-encoder. Same rules as the Magic implementation; private
// here so the YGO and Magic code paths can drift independently if the future
// requires it (Yugipedia's MediaWiki API is fine with %20 for spaces and %7C
// for the `|` separator inside `titles=`).
std::string urlEncode(std::string_view in) {
std::ostringstream out;
out.fill('0');
out << std::hex << std::uppercase;
for (unsigned char c : in) {
const bool unreserved =
(c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '.' || c == '_' || c == '~';
if (unreserved) {
out << static_cast<char>(c);
} else {
out << '%';
out.width(2);
out << static_cast<unsigned int>(c);
}
}
return out.str();
}
std::string trim(std::string s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.erase(s.begin());
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.pop_back();
@@ -54,6 +31,17 @@ std::string toLower(std::string s) {
return s;
}
std::string canonicalizeSetNameForAutoDetect(std::string_view setName) {
std::string canonical = trim(std::string(setName));
constexpr std::string_view k25thSuffix = " (25th Anniversary Edition)";
if (canonical.size() > k25thSuffix.size()
&& canonical.ends_with(k25thSuffix)) {
canonical.erase(canonical.size() - k25thSuffix.size());
canonical = trim(std::move(canonical));
}
return canonical;
}
// Pull the standard art URL out of a YGOPRODeck card object. We deliberately
// always return card_images[0]: when no `cardset=` filter is applied, that
// slot is the original/standard artwork (alt-art passcodes follow), which is
@@ -134,6 +122,10 @@ std::string YuGiOhCardPreviewSource::normalizeName(std::string_view name) {
}
std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName) {
if (const std::string canonical = ygoRarityShortCode(rarityName); !canonical.empty()) {
return canonical;
}
// Compare case-insensitively, ignoring whitespace, against a table of
// CCM3 dialog values (see ui_wx/src/YuGiOhCardEditDialog.cpp:kRarityOptions)
// plus a few extras occasionally seen in imported collections. The codes
@@ -171,7 +163,7 @@ std::string YuGiOhCardPreviewSource::rarityCodeFor(std::string_view rarityName)
{"ultraparallelrare", "UPR"},
{"holographicrare", "HGR"},
{"starlightrare", "StR"},
{"collectorsrare", "ColR"},
{"collectorsrare", "CR"},
{"prismaticcollectorsrare", "PColR"},
{"quartercenturysecretrare", "QCScR"},
{"prismaticultimaterare", "PUtR"},
@@ -266,7 +258,7 @@ std::string YuGiOhCardPreviewSource::buildYugipediaQueryUrl(
std::string url =
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=imageinfo&iiprop=url&titles=";
url += urlEncode(joined);
url += rfc3986PercentEncode(joined);
return url;
}
@@ -330,10 +322,10 @@ Result<std::string, PreviewLookupError> YuGiOhCardPreviewSource::parseYugipediaR
std::string YuGiOhCardPreviewSource::buildSearchUrl(std::string_view name,
std::string_view setName) {
std::string url =
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + urlEncode(name);
std::string("https://db.ygoprodeck.com/api/v7/cardinfo.php?fname=") + rfc3986PercentEncode(name);
if (!setName.empty()) {
url += "&cardset=";
url += urlEncode(setName);
url += rfc3986PercentEncode(setName);
}
return url;
}
@@ -385,7 +377,7 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::parsePrintVarian
if (!j.contains("data") || !j.at("data").is_array() || j.at("data").empty()) {
return R::err("YGOPRODeck returned no matching cards.");
}
const std::string wantedSet = trim(std::string(preferredSetName));
const std::string wantedSet = canonicalizeSetNameForAutoDetect(preferredSetName);
const std::string wantedNameLower = toLower(trim(std::string(wantedCardName)));
std::vector<AutoDetectedPrint> collected;
@@ -551,15 +543,16 @@ Result<std::vector<AutoDetectedPrint>> YuGiOhCardPreviewSource::detectPrintVaria
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string url = buildSearchUrl(name, setId);
const std::string canonicalSetName = canonicalizeSetNameForAutoDetect(setId);
const std::string url = buildSearchUrl(name, canonicalSetName);
auto resp = http_.get(url);
if (resp) {
return parsePrintVariants(resp.value(), setId, name);
return parsePrintVariants(resp.value(), canonicalSetName, name);
}
const std::string fallbackUrl = buildSearchUrl(name, "");
auto fallback = http_.get(fallbackUrl);
if (!fallback) return R::err(fallback.error());
return parsePrintVariants(fallback.value(), setId, name);
return parsePrintVariants(fallback.value(), canonicalSetName, name);
}
} // namespace ccm
+187
View File
@@ -1,11 +1,91 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <array>
#include <cctype>
#include <string>
#include <unordered_map>
#include <utility>
namespace ccm {
namespace {
struct YuGiOhSetAlias {
const char* code;
const char* name;
const char* releaseDate;
};
constexpr std::array<YuGiOhSetAlias, 6> kMissing25thAnniversaryReprints{{
// Keep this list in sync with docs/assets-and-info-apis.md (Info API section).
{"LOB-25TH", "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", "2023/04/20"},
{"MRD-25TH", "Metal Raiders (25th Anniversary Edition)", "2023/04/20"},
{"SRL-25TH", "Spell Ruler (25th Anniversary Edition)", "2023/04/20"},
{"PSV-25TH", "Pharaoh's Servant (25th Anniversary Edition)", "2023/04/20"},
{"DCR-25TH", "Dark Crisis (25th Anniversary Edition)", "2023/04/20"},
{"IOC-25TH", "Invasion of Chaos (25th Anniversary Edition)", "2023/06/08"},
}};
void appendMissingSetAliases(std::vector<Set>& sets) {
for (const auto& alias : kMissing25thAnniversaryReprints) {
const bool exists = std::any_of(
sets.begin(), sets.end(), [&](const Set& s) { return s.name == alias.name; });
if (exists) continue;
Set s;
s.id = alias.code;
s.name = alias.name;
s.releaseDate = alias.releaseDate;
sets.push_back(std::move(s));
}
}
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
const std::string digits = ygoCollectorDigitsOnly(setNo);
if (abbrev.empty() || digits.empty()) return {};
return abbrev + "|" + digits;
}
[[nodiscard]] bool ygoHasEnRegionInfix(std::string_view setCode) {
const std::string_view s = trimAsciiSpaces(setCode);
const auto dash = s.find('-');
if (dash == std::string_view::npos || dash + 3 > s.size()) return false;
const std::string_view tail = s.substr(dash + 1);
if (tail.size() < 3) return false;
return (tail[0] == 'E' || tail[0] == 'e') && (tail[1] == 'N' || tail[1] == 'n')
&& std::isdigit(static_cast<unsigned char>(tail[2])) != 0;
}
[[nodiscard]] std::string uppercaseAscii(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
return s;
}
[[nodiscard]] std::string resolvePackId(const std::unordered_map<std::string, std::string>& nameToId,
const std::string& setName,
const std::string& setCode) {
const auto it = nameToId.find(setName);
if (it != nameToId.end() && !it->second.empty()) return it->second;
const std::string abbrev = uppercaseAscii(ygoAbbrevBeforeDash(setCode));
return abbrev;
}
struct PackBuild {
std::string setId;
std::string setName;
// slotKey → index into cards (for EN preference upgrades).
std::unordered_map<std::string, std::size_t> slotIndex;
std::vector<YuGiOhCatalogCard> cards;
};
} // namespace
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
@@ -29,6 +109,7 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
s.releaseDate = std::move(release);
out.push_back(std::move(s));
}
appendMissingSetAliases(out);
std::sort(out.begin(), out.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
return Result<std::vector<Set>>::ok(std::move(out));
@@ -38,10 +119,116 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
}
}
Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object() || !j.contains("data") || !j.at("data").is_array()) {
return Result<YuGiOhSetCatalog>::err(
"YGOPRODeck cardinfo response missing data array.");
}
std::unordered_map<std::string, std::string> nameToId;
nameToId.reserve(sets.size());
for (const auto& set : sets) {
if (set.name.empty() || set.id.empty()) continue;
// First wins — aliases and upstream rows rarely collide by name.
nameToId.emplace(set.name, set.id);
}
// Keyed by pack setId.
std::unordered_map<std::string, PackBuild> byId;
for (const auto& cardJson : j.at("data")) {
const std::string cardName = cardJson.value("name", "");
if (cardName.empty()) continue;
if (!cardJson.contains("card_sets") || !cardJson.at("card_sets").is_array()) {
continue;
}
for (const auto& printing : cardJson.at("card_sets")) {
const std::string setName = printing.value("set_name", "");
const std::string setCode = printing.value("set_code", "");
if (setName.empty() || setCode.empty()) continue;
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
const std::string slot = ygoSlotKey(setCode);
if (slot.empty()) continue;
const std::string packId = resolvePackId(nameToId, setName, setCode);
if (packId.empty()) continue;
auto& build = byId[packId];
if (build.setId.empty()) {
build.setId = packId;
build.setName = setName;
}
const auto existing = build.slotIndex.find(slot);
if (existing == build.slotIndex.end()) {
build.slotIndex.emplace(slot, build.cards.size());
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
continue;
}
// Prefer an EN-embedded code over a bare / other-region equivalent.
auto& prev = build.cards[existing->second];
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
prev.setNo = setCode;
if (!cardName.empty()) prev.name = cardName;
}
}
}
YuGiOhSetCatalog catalog;
catalog.packs.reserve(byId.size());
for (auto& [_, build] : byId) {
if (build.setId.empty() || build.cards.empty()) continue;
std::sort(build.cards.begin(), build.cards.end(),
[](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
YuGiOhSetCatalogPack pack;
pack.setId = std::move(build.setId);
pack.setName = std::move(build.setName);
pack.cards = std::move(build.cards);
catalog.packs.push_back(std::move(pack));
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) {
return a.setName < b.setName;
});
return Result<YuGiOhSetCatalog>::ok(std::move(catalog));
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("YGOPRODeck catalog parse error: ") + e.what());
}
}
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
Result<YuGiOhSetSource::FetchWithCatalog> YuGiOhSetSource::fetchAllWithCatalog() {
auto setsResp = http_.get(kEndpoint);
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
auto sets = parseResponse(setsResp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
auto infoResp = http_.get(kCardInfoEndpoint);
if (!infoResp) return Result<FetchWithCatalog>::err(infoResp.error());
auto catalog = parseCatalog(infoResp.value(), sets.value());
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog).value();
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm
@@ -0,0 +1,426 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiCardPreviewSource.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <sstream>
namespace ccm {
namespace {
using K = PreviewLookupError::Kind;
std::string trimCopy(std::string_view s) {
while (!s.empty() &&
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
s.front() == '\r')) {
s.remove_prefix(1);
}
while (!s.empty() &&
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
s.back() == '\r')) {
s.remove_suffix(1);
}
return std::string(s);
}
std::string urlEncode(std::string_view s) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() * 3);
for (unsigned char c : s) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else if (c == ' ') {
out.push_back('+');
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
std::string wikiTitleEncode(std::string_view title) {
// MediaWiki titles use underscores for spaces in the titles= parameter.
std::string s;
s.reserve(title.size());
for (char c : title) {
s.push_back(c == ' ' ? '_' : c);
}
return urlEncode(s);
}
bool endsWith(std::string_view s, std::string_view suffix) {
return s.size() >= suffix.size() &&
s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
}
int askMatchRank(std::string_view pageTitle, std::string_view preferredSetId) {
// Lower is better.
if (preferredSetId == "bansealdass") {
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 0;
if (endsWith(pageTitle, " (Bandai)")) return 1;
return 5;
}
if (preferredSetId == "ban3") {
if (endsWith(pageTitle, " (Bandai)")) return 0;
if (endsWith(pageTitle, " (English Bandai)")) return 1;
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 4;
return 5;
}
if (endsWith(pageTitle, " (Bandai)")) return 0;
if (endsWith(pageTitle, " (English Bandai)")) return 1;
if (endsWith(pageTitle, " (Bandai Sealdass)")) return 3;
return 5;
}
} // namespace
YuGiOhBandaiCardPreviewSource::YuGiOhBandaiCardPreviewSource(IHttpClient& http)
: http_(http) {}
std::string YuGiOhBandaiCardPreviewSource::preferredPageTitle(
std::string_view name,
std::string_view setId,
std::string_view setNo) {
const std::string n = trimCopy(name);
if (n.empty()) return {};
const std::string num = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
if (setId == "bansealdass") {
return n + " (Bandai Sealdass)";
}
// Promo pages on Yugipedia often omit the "(Bandai)" disambiguator
// (e.g. Blue-Eyes White Dragon's 3-Body Connection for TA2).
if (setId == "banpromo-j" || setId == "banpromo-ta" ||
isAlphanumericPromoNumber(num)) {
return n;
}
if (num == "118" || setId == "ban3") {
// Prefer JP Bandai page for most ban3 cards; English #118 uses the
// English Bandai title when setNo is 118.
if (num == "118") return n + " (English Bandai)";
}
return n + " (Bandai)";
}
std::string YuGiOhBandaiCardPreviewSource::buildPageImagesUrl(
std::string_view pageTitle) {
return std::string(
"https://yugipedia.com/api.php?action=query&format=json"
"&prop=pageimages&piprop=original&titles=") +
wikiTitleEncode(pageTitle);
}
std::string YuGiOhBandaiCardPreviewSource::buildAskByNameUrl(
std::string_view englishName) {
// [[Category:Bandai cards]][[English name::<name>]]|?English name|?Bandai number|?Rarity|limit=20
std::ostringstream q;
q << "[[Category:Bandai cards]][[English name::" << englishName
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
urlEncode(q.str());
}
std::string YuGiOhBandaiCardPreviewSource::buildAskByNumberUrl(
std::string_view setNo) {
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
std::ostringstream q;
q << "[[Category:Bandai cards]][[Bandai number::" << n
<< "]]|?English name|?Bandai number|?Rarity|limit=20";
return std::string("https://yugipedia.com/api.php?action=ask&format=json&query=") +
urlEncode(q.str());
}
bool YuGiOhBandaiCardPreviewSource::isAlphanumericPromoNumber(
std::string_view setNo) {
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
for (unsigned char c : n) {
if (std::isalpha(c)) return true;
}
return false;
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::parsePromoGalleryResponse(
const std::string& body,
std::string_view wantedSetNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string want = YuGiOhBandaiSetSource::normalizeCardNumber(wantedSetNo);
if (want.empty()) return R::err("Card number is empty.");
std::string wikitext;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
return R::err("Yugipedia promo gallery response missing parse.wikitext");
}
wikitext = j.at("parse").at("wikitext").get<std::string>();
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia promo gallery JSON parse error: ") +
e.what());
}
auto cards = YuGiOhBandaiSetSource::parseGalleryWikitext(wikitext);
if (!cards) return R::err(cards.error());
std::vector<AutoDetectedPrint> out;
for (const auto& card : cards.value()) {
if (YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo) != want) continue;
AutoDetectedPrint print;
print.name = card.name;
print.setNo = card.setNo;
print.rarity = card.rarity;
print.setId = YuGiOhBandaiSetSource::setIdForNumber(card.setNo);
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
print.language = "Japanese";
out.push_back(std::move(print));
}
return R::ok(std::move(out));
}
AutoDetectedPrint YuGiOhBandaiCardPreviewSource::enrichPrint(
AutoDetectedPrint print,
std::string_view pageTitle) {
print.name = YuGiOhBandaiSetSource::englishNameFromGalleryTitle(pageTitle);
if (endsWith(pageTitle, " (Bandai Sealdass)")) {
print.setId = "bansealdass";
print.language = "Japanese";
} else if (endsWith(pageTitle, " (English Bandai)")) {
print.setId = "ban3";
print.language = "English";
} else {
if (print.setId.empty() && !print.setNo.empty()) {
print.setId = YuGiOhBandaiSetSource::setIdForNumber(print.setNo);
}
print.language = "Japanese";
}
if (!print.setId.empty()) {
print.setName = YuGiOhBandaiSetSource::setNameForId(print.setId);
}
return print;
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::parsePageImagesResponse(const std::string& body) {
using R = Result<std::string, PreviewLookupError>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").contains("pages")) {
return R::err({K::Transient, "Yugipedia pageimages: missing query.pages"});
}
const auto& pages = j.at("query").at("pages");
for (auto it = pages.begin(); it != pages.end(); ++it) {
const auto& page = it.value();
if (page.contains("missing") || page.contains("invalid")) continue;
if (page.contains("original") && page.at("original").contains("source")) {
const auto url = page.at("original").at("source").get<std::string>();
if (!url.empty()) return R::ok(url);
}
if (page.contains("thumbnail") && page.at("thumbnail").contains("original")) {
const auto url = page.at("thumbnail").at("original").get<std::string>();
if (!url.empty()) return R::ok(url);
}
}
return R::err({K::NotFound, "Yugipedia pageimages: no image for page"});
} catch (const std::exception& e) {
return R::err({K::Transient,
std::string("Yugipedia pageimages JSON parse error: ") + e.what()});
}
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::parseAskResponse(const std::string& body,
std::string_view preferredSetId) {
using R = Result<std::vector<AutoDetectedPrint>>;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("query") || !j.at("query").contains("results")) {
return R::err("Yugipedia ask: missing query.results");
}
const auto& results = j.at("query").at("results");
if (!results.is_object() || results.empty()) {
return R::ok({});
}
std::vector<std::pair<int, AutoDetectedPrint>> ranked;
for (auto it = results.begin(); it != results.end(); ++it) {
const std::string pageTitle = it.key();
const auto& printouts = it.value().value("printouts", nlohmann::json::object());
AutoDetectedPrint print;
if (printouts.contains("Bandai number") &&
printouts.at("Bandai number").is_array() &&
!printouts.at("Bandai number").empty()) {
const auto& num = printouts.at("Bandai number").at(0);
if (num.is_number_integer()) {
print.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(
std::to_string(num.get<int>()));
} else if (num.is_string()) {
print.setNo =
YuGiOhBandaiSetSource::normalizeCardNumber(num.get<std::string>());
}
}
if (printouts.contains("Rarity") && printouts.at("Rarity").is_array() &&
!printouts.at("Rarity").empty()) {
const auto& rar = printouts.at("Rarity").at(0);
if (rar.is_object() && rar.contains("fulltext")) {
print.rarity = rar.at("fulltext").get<std::string>();
} else if (rar.is_string()) {
print.rarity = rar.get<std::string>();
}
}
if (printouts.contains("English name") &&
printouts.at("English name").is_array() &&
!printouts.at("English name").empty()) {
print.name = printouts.at("English name").at(0).get<std::string>();
}
print = enrichPrint(std::move(print), pageTitle);
if (print.name.empty()) continue;
ranked.emplace_back(askMatchRank(pageTitle, preferredSetId), std::move(print));
}
std::sort(ranked.begin(), ranked.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
std::vector<AutoDetectedPrint> out;
out.reserve(ranked.size());
for (auto& [rank, print] : ranked) {
(void)rank;
out.push_back(std::move(print));
}
return R::ok(std::move(out));
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia ask JSON parse error: ") + e.what());
}
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::fetchPageImage(std::string_view pageTitle) {
using R = Result<std::string, PreviewLookupError>;
if (pageTitle.empty()) {
return R::err({K::NotFound, "Empty Bandai page title"});
}
const std::string url = buildPageImagesUrl(pageTitle);
auto resp = http_.get(url);
if (!resp) return R::err({K::Transient, resp.error()});
return parsePageImagesResponse(resp.value());
}
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByName(
std::string_view name,
std::string_view setId) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string n = trimCopy(name);
if (n.empty()) return R::err("Card name is empty.");
const std::string url = buildAskByNameUrl(n);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parseAskResponse(resp.value(), setId);
}
Result<std::vector<AutoDetectedPrint>> YuGiOhBandaiCardPreviewSource::askByNumber(
std::string_view setNo) {
using R = Result<std::vector<AutoDetectedPrint>>;
const std::string n = YuGiOhBandaiSetSource::normalizeCardNumber(setNo);
if (n.empty()) return R::err("Card number is empty.");
// Promo codes (J1, TA2, …) are not valid values for SMW's numeric
// `Bandai number` property — ask returns a type error. Resolve them from
// the promotional set gallery instead.
if (isAlphanumericPromoNumber(n)) {
static constexpr const char* kPromoGallery =
"Set Card Galleries:Promotional Cards (Bandai)";
const std::string url = YuGiOhBandaiSetSource::buildGalleryParseUrl(kPromoGallery);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parsePromoGalleryResponse(resp.value(), n);
}
const std::string url = buildAskByNumberUrl(n);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
return parseAskResponse(resp.value(), {});
}
Result<std::string, PreviewLookupError>
YuGiOhBandaiCardPreviewSource::fetchImageUrl(std::string_view name,
std::string_view setId,
std::string_view setNo) {
using R = Result<std::string, PreviewLookupError>;
const std::string title = preferredPageTitle(name, setId, setNo);
auto direct = fetchPageImage(title);
if (direct) return direct;
// Try English Bandai if JP page missed for #118.
if (YuGiOhBandaiSetSource::normalizeCardNumber(setNo) == "118") {
auto en = fetchPageImage(trimCopy(name) + " (English Bandai)");
if (en) return en;
}
// Fall back to SMW ask by name, then pageimages on the best hit.
auto variants = askByName(name, setId);
if (!variants) {
// Prefer the original NotFound if ask also failed transiently only
// after a clean miss; otherwise surface ask error as Transient.
if (direct.error().kind == K::NotFound) {
return R::err({K::Transient, variants.error()});
}
return direct;
}
if (variants.value().empty()) {
return R::err({K::NotFound, "No Bandai card matched the name"});
}
const auto& best = variants.value().front();
std::string askTitle = preferredPageTitle(best.name, best.setId, best.setNo);
if (best.language == "English") {
askTitle = best.name + " (English Bandai)";
} else if (best.setId == "bansealdass") {
askTitle = best.name + " (Bandai Sealdass)";
}
return fetchPageImage(askTitle);
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectFirstPrint(
std::string_view name,
std::string_view setId) {
auto list = detectPrintVariants(name, setId);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err("Could not auto-detect Bandai print metadata.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectPrintVariants(std::string_view name,
std::string_view setId) {
return askByName(name, setId);
}
Result<AutoDetectedPrint> YuGiOhBandaiCardPreviewSource::detectBySetNo(
std::string_view setNo) {
auto list = detectVariantsBySetNo(setNo);
if (!list) return Result<AutoDetectedPrint>::err(list.error());
if (list.value().empty()) {
return Result<AutoDetectedPrint>::err(
"Could not auto-detect Bandai card from number.");
}
return Result<AutoDetectedPrint>::ok(list.value().front());
}
Result<std::vector<AutoDetectedPrint>>
YuGiOhBandaiCardPreviewSource::detectVariantsBySetNo(std::string_view setNo) {
return askByNumber(setNo);
}
} // namespace ccm
@@ -0,0 +1,8 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiGameModule.hpp"
namespace ccm {
YuGiOhBandaiGameModule::YuGiOhBandaiGameModule(IHttpClient& http)
: setSource_(http), previewSource_(http) {}
} // namespace ccm
@@ -0,0 +1,267 @@
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.hpp"
#include <nlohmann/json.hpp>
#include <cctype>
#include <regex>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
std::string trimCopy(std::string_view s) {
while (!s.empty() &&
(s.front() == ' ' || s.front() == '\t' || s.front() == '\n' ||
s.front() == '\r')) {
s.remove_prefix(1);
}
while (!s.empty() &&
(s.back() == ' ' || s.back() == '\t' || s.back() == '\n' ||
s.back() == '\r')) {
s.remove_suffix(1);
}
return std::string(s);
}
std::string urlEncode(std::string_view s) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() * 3);
for (unsigned char c : s) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back(static_cast<char>(c));
} else if (c == ' ') {
out.push_back('+');
} else {
out.push_back('%');
out.push_back(hex[c >> 4]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
} // namespace
YuGiOhBandaiSetSource::YuGiOhBandaiSetSource(IHttpClient& http) : http_(http) {}
const std::vector<YuGiOhBandaiSetSource::SetManifestEntry>&
YuGiOhBandaiSetSource::setManifest() {
static const std::vector<SetManifestEntry> kManifest{
{"ban1", "1st Generation", "1998/09/01",
"Set Card Galleries:Yu-Gi-Oh! Bandai OCG: 1st Generation", ""},
{"ban2", "2nd Generation", "1998/11/01",
"Set Card Galleries:2nd Generation (Bandai)", ""},
{"ban3", "3rd Generation", "1999/03/06",
"Set Card Galleries:3rd Generation (Bandai)", ""},
{"banpromo-j", "Jump Promos", "1998/01/01",
"Set Card Galleries:Promotional Cards (Bandai)", "J"},
{"banpromo-ta", "Toei Promos", "1999/03/06",
"Set Card Galleries:Promotional Cards (Bandai)", "TA"},
{"bansealdass", "Sealdass", "1999/06/01",
"Set Card Galleries:Yu-Gi-Oh! Bandai Sealdass", ""},
};
return kManifest;
}
Result<std::vector<Set>> YuGiOhBandaiSetSource::parseResponse(
const std::string& /*unused*/) {
std::vector<Set> out;
for (const auto& e : setManifest()) {
out.push_back(Set{e.id, e.name, e.releaseDate});
}
return Result<std::vector<Set>>::ok(std::move(out));
}
Result<std::vector<Set>> YuGiOhBandaiSetSource::fetchAll() {
return parseResponse({});
}
std::string YuGiOhBandaiSetSource::buildGalleryParseUrl(std::string_view pageTitle) {
return std::string(
"https://yugipedia.com/api.php?action=parse&format=json&formatversion=2"
"&prop=wikitext&page=") +
urlEncode(pageTitle);
}
std::string YuGiOhBandaiSetSource::normalizeCardNumber(std::string_view setNo) {
std::string s = trimCopy(setNo);
if (s.empty()) return {};
// Strip a leading '#' if present.
if (s.front() == '#') s.erase(s.begin());
// Uppercase letter prefix forms: j1 / ta2.
bool hasAlpha = false;
for (char& c : s) {
if (std::isalpha(static_cast<unsigned char>(c))) {
hasAlpha = true;
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
}
}
if (hasAlpha) return s;
// Pure decimal: strip leading zeros but keep a single zero.
std::size_t i = 0;
while (i + 1 < s.size() && s[i] == '0') ++i;
return s.substr(i);
}
std::string YuGiOhBandaiSetSource::expandRarityCode(std::string_view code) {
const std::string c = trimCopy(code);
if (c == "C") return "Common";
if (c == "R") return "Rare";
if (c == "SR") return "Super Rare";
if (c == "UR") return "Ultra Rare";
if (c == "HFR" || c == "Holo Seal" || c == "HS") return "Holo Seal";
if (c.empty()) return {};
return c;
}
std::string YuGiOhBandaiSetSource::englishNameFromGalleryTitle(
std::string_view pageTitle) {
std::string name = trimCopy(pageTitle);
const auto stripSuffix = [&](std::string_view suffix) {
if (name.size() > suffix.size() &&
name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) {
name.resize(name.size() - suffix.size());
name = trimCopy(name);
}
};
stripSuffix(" (Bandai Sealdass)");
stripSuffix(" (English Bandai)");
stripSuffix(" (Bandai)");
return name;
}
std::string YuGiOhBandaiSetSource::setIdForNumber(std::string_view setNo) {
const std::string n = normalizeCardNumber(setNo);
if (n.empty()) return {};
if (!n.empty() && (n[0] == 'J' || n[0] == 'j')) return "banpromo-j";
if (n.size() >= 2 && (n[0] == 'T' || n[0] == 't') &&
(n[1] == 'A' || n[1] == 'a')) {
return "banpromo-ta";
}
// Pure decimal → generation by range. Callers that need Sealdass must
// pass set context; number alone cannot disambiguate 142 vs Sealdass.
bool pureDecimal = true;
for (char c : n) {
if (!std::isdigit(static_cast<unsigned char>(c))) {
pureDecimal = false;
break;
}
}
if (!pureDecimal) return {};
const int v = std::stoi(n);
if (v >= 1 && v <= 42) return "ban1";
if (v >= 43 && v <= 88) return "ban2";
if (v >= 89 && v <= 118) return "ban3";
return {};
}
std::string YuGiOhBandaiSetSource::setNameForId(std::string_view setId) {
for (const auto& e : setManifest()) {
if (e.id == setId) return e.name;
}
return {};
}
Result<std::vector<YuGiOhBandaiCatalogCard>>
YuGiOhBandaiSetSource::parseGalleryWikitext(const std::string& wikitext) {
using R = Result<std::vector<YuGiOhBandaiCatalogCard>>;
std::vector<YuGiOhBandaiCatalogCard> out;
// Generation galleries (raw):
// … | {{pound}}014 ([[R]]) {{Gallery card names|Dark Magician (Bandai)|ja}}
// Promo galleries (often expanded with <br />):
// … | [[TA2]] ([[SR]])<br />{{Gallery card names|Blue-Eyes White Dragon's 3-Body Connection|ja}}
static const std::regex kLine(
R"((?:\{\{pound\}\}|\[\[)([A-Za-z0-9]+)(?:\]\])?(?:\s*\(\[\[([A-Za-z0-9]+)\]\]\))?[^\n]*?\{\{Gallery card names\|([^}|]+))",
std::regex::ECMAScript);
std::unordered_set<std::string> seen;
for (std::sregex_iterator it(wikitext.begin(), wikitext.end(), kLine), end;
it != end; ++it) {
const std::smatch& m = *it;
YuGiOhBandaiCatalogCard card;
card.setNo = normalizeCardNumber(m[1].str());
if (card.setNo.empty()) continue;
if (m[2].matched) {
card.rarity = expandRarityCode(m[2].str());
}
card.name = englishNameFromGalleryTitle(m[3].str());
if (card.name.empty()) continue;
if (!seen.insert(card.setNo).second) continue;
out.push_back(std::move(card));
}
return R::ok(std::move(out));
}
Result<YuGiOhBandaiSetSource::FetchWithCatalog>
YuGiOhBandaiSetSource::fetchAllWithCatalog() {
using R = Result<FetchWithCatalog>;
auto sets = parseResponse({});
if (!sets) return R::err(sets.error());
YuGiOhBandaiSetCatalog catalog;
std::unordered_map<std::string, std::string> pageCache;
for (const auto& entry : setManifest()) {
const std::string page = entry.galleryPage;
std::string body;
auto cached = pageCache.find(page);
if (cached != pageCache.end()) {
body = cached->second;
} else {
const std::string url = buildGalleryParseUrl(page);
auto resp = http_.get(url);
if (!resp) return R::err(resp.error());
body = std::move(resp).value();
pageCache.emplace(page, body);
}
std::string wikitext;
try {
const auto j = nlohmann::json::parse(body);
if (!j.contains("parse") || !j.at("parse").contains("wikitext")) {
return R::err("Yugipedia gallery response missing parse.wikitext");
}
wikitext = j.at("parse").at("wikitext").get<std::string>();
} catch (const std::exception& e) {
return R::err(std::string("Yugipedia gallery JSON parse error: ") +
e.what());
}
auto cards = parseGalleryWikitext(wikitext);
if (!cards) return R::err(cards.error());
YuGiOhBandaiSetCatalogPack pack;
pack.setId = entry.id;
pack.setName = entry.name;
const std::string prefix = entry.setNoPrefix;
for (const auto& card : cards.value()) {
if (!prefix.empty()) {
if (card.setNo.size() < prefix.size() ||
card.setNo.compare(0, prefix.size(), prefix) != 0) {
continue;
}
}
pack.cards.push_back(card);
}
catalog.packs.push_back(std::move(pack));
}
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog);
return R::ok(std::move(out));
}
} // namespace ccm
+34 -10
View File
@@ -1,5 +1,7 @@
#include "ccm/infra/CprHttpClient.hpp"
#include "ccm/util/HttpGetMapping.hpp"
#include <cpr/cpr.h>
#include <string>
@@ -24,8 +26,30 @@ CprHttpClient::CprHttpClient(std::chrono::milliseconds timeout)
/*follow=*/true,
/*cont_send_cred=*/false,
cpr::PostRedirectFlags::POST_ALL});
rawExecutor_ = [this](std::string_view url) -> RawResponse {
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
return RawResponse{
.transportError = static_cast<bool>(r.error),
.transportMessage = r.error.message,
.statusCode = static_cast<int>(r.status_code),
.body = std::move(r.text),
};
};
}
CprHttpClient::CprHttpClient(GetExecutor executor,
std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(nullptr),
executor_(std::move(executor)) {}
CprHttpClient::CprHttpClient(RawGetExecutor rawExecutor,
std::chrono::milliseconds timeout)
: timeout_(timeout),
session_(nullptr),
rawExecutor_(std::move(rawExecutor)) {}
CprHttpClient::~CprHttpClient() = default;
Result<std::string> CprHttpClient::get(std::string_view url) {
@@ -34,18 +58,18 @@ Result<std::string> CprHttpClient::get(std::string_view url) {
// (one fetch per BaseSelectedCardPanel selection change), so contention
// is negligible.
std::lock_guard<std::mutex> lock(sessionMutex_);
session_->SetUrl(cpr::Url{std::string(url)});
cpr::Response r = session_->Get();
if (r.error) {
return Result<std::string>::err("HTTP error: " + r.error.message);
if (executor_) {
return executor_(url);
}
if (r.status_code < 200 || r.status_code >= 300) {
return Result<std::string>::err(
"HTTP " + std::to_string(r.status_code) + " from " + std::string(url));
if (rawExecutor_) {
RawResponse raw = rawExecutor_(url);
return mapHttpGetResponse(raw.transportError,
raw.transportMessage,
raw.statusCode,
std::move(raw.body),
url);
}
return Result<std::string>::ok(std::move(r.text));
return Result<std::string>::err("HTTP error: no executor configured");
}
} // namespace ccm
+43 -8
View File
@@ -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<std::vector<Set>> JsonSetRepository::load(Game game) {
const auto p = setsPath(game);
if (!fs_.exists(p)) {
return Result<std::vector<Set>>::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<std::vector<Set>>::err(text.error());
}
Result<std::vector<Set>> JsonSetRepository::parseSetsText(const std::string& text) const {
try {
auto j = nlohmann::json::parse(text.value());
auto j = nlohmann::json::parse(text);
return Result<std::vector<Set>>::ok(j.get<std::vector<Set>>());
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(std::string("sets.json parse error: ") + e.what());
}
}
Result<std::vector<Set>> JsonSetRepository::load(Game game) {
const auto p = setsPath(game);
if (fs_.exists(p)) {
auto text = fs_.readText(p);
if (!text) return Result<std::vector<Set>>::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<std::vector<Set>>::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<std::vector<Set>>::err("Set list not yet downloaded for this game.");
}
Result<void> JsonSetRepository::save(Game game, const std::vector<Set>& sets) {
const auto p = setsPath(game);
auto dir = fs_.ensureDirectory(p.parent_path());
+51 -15
View File
@@ -1,28 +1,15 @@
#include "ccm/services/CardFilter.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// Plain ASCII tolower, same approach as CardSorter::asciiLower. The old JS path used
// String.prototype.toLowerCase() which on the realistic ASCII-only data set
// (English/German set names, Scryfall-fed labels, integer amounts) behaves
// identically.
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
bool containsLower(std::string_view haystack, std::string_view needleLower) {
return asciiLower(haystack).find(needleLower) != std::string::npos;
}
@@ -60,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;
}
@@ -68,6 +56,38 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
const std::string needle = asciiLower(filter);
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
if (containsLower(card.rarity, needle)) return true;
if (containsLower(ygoRarityShortCode(card.rarity), needle)) return true;
if (containsLower(to_string(card.language), needle)) return true;
if (containsLower(to_string(card.condition), needle)) return true;
if (containsLower(std::to_string(card.amount), needle)) return true;
if (containsLower(card.note, needle)) return true;
return false;
}
bool matchesDigiBattle99Filter(const DigiBattle99Card& 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;
}
bool matchesYuGiOhBandaiFilter(const YuGiOhBandaiCard& card, std::string_view filter) {
if (filter.empty()) return true;
const std::string needle = asciiLower(filter);
if (containsLower(card.name, needle)) return true;
if (containsLower(card.set.name, needle)) return true;
if (containsLower(card.setNo, needle)) return true;
@@ -79,4 +99,20 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
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
+90 -13
View File
@@ -1,5 +1,6 @@
#include "ccm/services/CardPreviewService.hpp"
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
@@ -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<std::string> CardPreviewService::fetchAndCache(const std::string& cacheKe
return Result<std::string>::ok(std::move(payload));
}
Result<std::string, PreviewLookupError> CardPreviewService::fetchAssetAndCache(
const std::string& cacheKey,
std::string_view assetUrl) {
using R = Result<std::string, PreviewLookupError>;
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<std::string> CardPreviewService::fetchPreviewBytes(Game game,
std::string_view name,
std::string_view setId,
@@ -179,6 +218,18 @@ Result<std::string> CardPreviewService::fetchPreviewBytes(Game game,
}
return Result<std::string>::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<std::string>::err(err.message);
}
return Result<std::string>::ok(std::move(asset).value());
}
return fetchAndCache(key, url.value());
}
@@ -211,6 +262,33 @@ Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectPrintVariants(
return it->second->detectPrintVariants(name, setId);
}
Result<AutoDetectedPrint> CardPreviewService::detectBySetNo(Game game,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<AutoDetectedPrint>::err("No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<AutoDetectedPrint>::err("Auto-detect not enabled for this game.");
}
return it->second->detectBySetNo(setNo);
}
Result<std::vector<AutoDetectedPrint>> CardPreviewService::detectVariantsBySetNo(
Game game,
std::string_view setNo) {
auto it = sources_.find(game);
if (it == sources_.end() || it->second == nullptr) {
return Result<std::vector<AutoDetectedPrint>>::err(
"No preview source registered for this game.");
}
if (!it->second->supportsAutoDetectPrint()) {
return Result<std::vector<AutoDetectedPrint>>::err(
"Auto-detect not enabled for this game.");
}
return it->second->detectVariantsBySetNo(setNo);
}
Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view url) {
// The by-URL path is used for fixed per-game card-back fallback images.
// A failure there is always transient (the URL itself is constant), so
@@ -218,18 +296,12 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
// and, if needed, fetch+store.
const std::string key = makeUrlKey(url);
std::string cached;
switch (cacheLookup(key, cached)) {
case CacheLookupKind::Hit:
return Result<std::string>::ok(std::move(cached));
case CacheLookupKind::NegativeHit:
// Defensive: nothing in this code path ever stores a negative
// entry under a URL key, but if one ever ends up here (cache
// file tampering, future code paths) treat it as a miss so the
// fallback fetch can still run.
break;
case CacheLookupKind::Miss:
break;
const auto mem = cacheLookup(key, cached);
if (mem == CacheLookupKind::Hit) {
return Result<std::string>::ok(std::move(cached));
}
// Miss, or a spurious negative under a URL key (never written by normal
// code) — both continue to disk / network.
if (persistentCache_ != nullptr) {
const auto disk = persistentCache_->load(key);
if (disk.kind == IPreviewByteCache::HitKind::Hit) {
@@ -237,6 +309,11 @@ Result<std::string> CardPreviewService::fetchImageBytesByUrl(std::string_view ur
return Result<std::string>::ok(disk.payload);
}
}
if (url.starts_with(kAssetScheme)) {
auto asset = fetchAssetAndCache(key, url);
if (!asset) return Result<std::string>::err(asset.error().message);
return Result<std::string>::ok(std::move(asset).value());
}
return fetchAndCache(key, url);
}
+221 -15
View File
@@ -1,29 +1,16 @@
#include "ccm/services/CardSorter.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/util/AsciiUtils.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm>
#include <cctype>
#include <string>
#include <string_view>
namespace ccm {
namespace {
// The comparator lowercases strings before compare via String.toLowerCase()-style behavior.
// We use ASCII-only tolower; the original TS app processed the same fields and
// never special-cased Unicode either, so this stays byte-compatible for the
// realistic data set (English/German/etc. names already lowercase identically).
std::string asciiLower(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
out.push_back(static_cast<char>(
std::tolower(static_cast<unsigned char>(c))));
}
return out;
}
// Wrap a less-than predicate so that ascending=false flips its meaning,
// mirroring `byField(field, asc)` in TableTemplate.tsx.
template <typename Less>
@@ -203,6 +190,12 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhSortColumn::Rarity:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
return asciiLower(ygoRarityShortCode(a.rarity)) < asciiLower(ygoRarityShortCode(b.rarity));
}, ascending));
break;
case YuGiOhSortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhCard& a, const YuGiOhCard& b) {
@@ -230,4 +223,217 @@ void sortYuGiOhCards(std::vector<YuGiOhCard>& cards, YuGiOhSortColumn column,
}
}
void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
DigiBattle99SortColumn column,
bool ascending) {
switch (column) {
case DigiBattle99SortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case DigiBattle99SortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(a.set.releaseDate) <
asciiLower(b.set.releaseDate);
}, ascending));
break;
case DigiBattle99SortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(to_string(a.language)) <
asciiLower(to_string(b.language));
}, ascending));
break;
case DigiBattle99SortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(to_string(a.condition)) <
asciiLower(to_string(b.condition));
}, ascending));
break;
case DigiBattle99SortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.amount < b.amount;
}, ascending));
break;
case DigiBattle99SortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.holo < b.holo;
}, ascending));
break;
case DigiBattle99SortColumn::FirstEdition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.firstEdition < b.firstEdition;
}, ascending));
break;
case DigiBattle99SortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case DigiBattle99SortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return a.altered < b.altered;
}, ascending));
break;
case DigiBattle99SortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const DigiBattle99Card& a, const DigiBattle99Card& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
void sortYuGiOhBandaiCards(std::vector<YuGiOhBandaiCard>& cards,
YuGiOhBandaiSortColumn column,
bool ascending) {
switch (column) {
case YuGiOhBandaiSortColumn::Name:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.name) < asciiLower(b.name);
}, ascending));
break;
case YuGiOhBandaiSortColumn::SetReleaseDate:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.set.releaseDate) < asciiLower(b.set.releaseDate);
}, ascending));
break;
case YuGiOhBandaiSortColumn::SetNo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.setNo) < asciiLower(b.setNo);
}, ascending));
break;
case YuGiOhBandaiSortColumn::Rarity:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.rarity) < asciiLower(b.rarity);
}, ascending));
break;
case YuGiOhBandaiSortColumn::Language:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(to_string(a.language)) < asciiLower(to_string(b.language));
}, ascending));
break;
case YuGiOhBandaiSortColumn::Condition:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(to_string(a.condition)) < asciiLower(to_string(b.condition));
}, ascending));
break;
case YuGiOhBandaiSortColumn::Amount:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.amount < b.amount;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Holo:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.holo < b.holo;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Signed:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.signed_ < b.signed_;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Altered:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return a.altered < b.altered;
}, ascending));
break;
case YuGiOhBandaiSortColumn::Note:
std::stable_sort(cards.begin(), cards.end(), directional(
[](const YuGiOhBandaiCard& a, const YuGiOhBandaiCard& b) {
return asciiLower(a.note) < asciiLower(b.note);
}, ascending));
break;
}
}
void sortJapanesePokemonCards(std::vector<JapanesePokemonCard>& 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
@@ -0,0 +1,50 @@
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
DigiBattle99SetCatalogService::DigiBattle99SetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path DigiBattle99SetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::DigiBattle99) /
"set-catalog.json";
}
bool DigiBattle99SetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<DigiBattle99SetCatalog> DigiBattle99SetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<DigiBattle99SetCatalog>::err(
"Digimon Digi-Battle set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<DigiBattle99SetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<DigiBattle99SetCatalog>::ok(j.get<DigiBattle99SetCatalog>());
} catch (const std::exception& e) {
return Result<DigiBattle99SetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> DigiBattle99SetCatalogService::save(const DigiBattle99SetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
@@ -0,0 +1,129 @@
#include "ccm/services/DigiBattle99SetCompletion.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
bool passesLanguageFilter(const DigiBattle99Card& card,
std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSetNosBySetId(const std::vector<DigiBattle99Card>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
} // namespace
std::vector<Language>
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<DigiBattle99SetCompletionProgress>
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
std::vector<DigiBattle99SetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedNos] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
DigiBattle99SetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const DigiBattle99SetCompletionProgress& a,
const DigiBattle99SetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<DigiBattle99ChecklistEntry>
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string setNo =
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<DigiBattle99ChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
DigiBattle99ChecklistEntry entry;
entry.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
entry.name = card.name;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const DigiBattle99ChecklistEntry& a,
const DigiBattle99ChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
@@ -0,0 +1,54 @@
#include "ccm/services/PokemonSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
PokemonSetCatalogService::PokemonSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path PokemonSetCatalogService::catalogPath(PokemonRegion region) const {
const char* file = region == PokemonRegion::Asia ? "set-catalog-asia.json"
: "set-catalog-west.json";
return fs::path(config_.current().dataStorage) / dirName_(Game::Pokemon) / file;
}
bool PokemonSetCatalogService::exists(PokemonRegion region) const {
return fs_.exists(catalogPath(region));
}
Result<PokemonSetCatalog> PokemonSetCatalogService::load(PokemonRegion region) const {
const auto p = catalogPath(region);
if (!fs_.exists(p)) {
return Result<PokemonSetCatalog>::err(
region == PokemonRegion::Asia
? "Asia Pokemon set catalog not yet downloaded."
: "West Pokemon set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<PokemonSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<PokemonSetCatalog>::ok(j.get<PokemonSetCatalog>());
} catch (const std::exception& e) {
return Result<PokemonSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> PokemonSetCatalogService::save(PokemonRegion region,
const PokemonSetCatalog& catalog) {
const auto p = catalogPath(region);
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+217
View File
@@ -0,0 +1,217 @@
#include "ccm/services/PokemonSetCompletion.hpp"
#include "ccm/games/pokemon/PokemonCardPreviewSource.hpp"
#include "ccm/games/pokemon/PokemonWestSetId.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonCardPreviewSource.hpp"
#include "ccm/util/SetNoNatural.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
struct OwnedSetInfo {
std::unordered_set<std::string> nos;
std::string releaseDate;
};
using OwnedBySet = std::unordered_map<std::string, OwnedSetInfo>;
bool passesLanguageFilter(const PokemonCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
bool passesRegionFilter(const PokemonCard& card, std::optional<PokemonRegion> regionFilter) {
return !regionFilter.has_value() || card.region == *regionFilter;
}
std::string normalizeForRegion(PokemonRegion region, std::string_view setNo) {
if (region == PokemonRegion::Asia) {
return JapanesePokemonCardPreviewSource::normalizeLocalId(setNo);
}
return PokemonCardPreviewSource::normalizeCollectorNumber(setNo);
}
std::string westSetKey(std::string_view setId) {
return canonicalizeWestSetId(setId);
}
OwnedBySet ownedSetNosBySetId(const std::vector<PokemonCard>& collection,
PokemonRegion region,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (setNo.empty()) continue;
const std::string setKey =
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
auto& info = out[setKey];
info.nos.insert(setNo);
if (info.releaseDate.empty() && !card.set.releaseDate.empty()) {
info.releaseDate = card.set.releaseDate;
}
}
return out;
}
std::vector<PokemonSetCompletionProgress>
computeForCatalog(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& catalog,
PokemonRegion region,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, region, languageFilter);
std::vector<PokemonSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, info] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string catalogNo = normalizeForRegion(region, card.setNo);
if (!catalogNo.empty() && info.nos.count(catalogNo) != 0) ++matched;
}
PokemonSetCompletionProgress row;
row.region = region;
row.setId = pack->setId;
row.setName = pack->setName;
row.releaseDate = info.releaseDate;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
return out;
}
} // namespace
std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
if (!passesRegionFilter(card, regionFilter)) continue;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog) {
std::vector<PokemonRegion> out;
const auto westRows =
computeForCatalog(collection, westCatalog, PokemonRegion::West, std::nullopt);
if (!westRows.empty()) out.push_back(PokemonRegion::West);
const auto asiaRows =
computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia, std::nullopt);
if (!asiaRows.empty()) out.push_back(PokemonRegion::Asia);
return out;
}
std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter,
std::optional<Language> languageFilter) {
std::vector<PokemonSetCompletionProgress> out;
const bool includeWest =
!regionFilter.has_value() || *regionFilter == PokemonRegion::West;
const bool includeAsia =
!regionFilter.has_value() || *regionFilter == PokemonRegion::Asia;
if (includeWest) {
auto west = computeForCatalog(collection, westCatalog, PokemonRegion::West,
languageFilter);
out.insert(out.end(), std::make_move_iterator(west.begin()),
std::make_move_iterator(west.end()));
}
if (includeAsia) {
auto asia = computeForCatalog(collection, asiaCatalog, PokemonRegion::Asia,
languageFilter);
out.insert(out.end(), std::make_move_iterator(asia.begin()),
std::make_move_iterator(asia.end()));
}
std::sort(out.begin(), out.end(),
[](const PokemonSetCompletionProgress& a,
const PokemonSetCompletionProgress& b) {
// YYYY/MM/DD lex order is chronological (CardSorter parity).
if (a.releaseDate != b.releaseDate) {
return a.releaseDate < b.releaseDate;
}
if (a.setName != b.setName) return a.setName < b.setName;
return static_cast<int>(a.region) < static_cast<int>(b.region);
});
return out;
}
std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter) {
const PokemonSetCatalog& catalog =
region == PokemonRegion::Asia ? asiaCatalog : westCatalog;
const std::string wantSetId =
region == PokemonRegion::West ? westSetKey(setId) : std::string(setId);
const auto* pack = catalog.findPack(wantSetId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedNos;
for (const auto& card : collection) {
if (card.region != region) continue;
if (!passesLanguageFilter(card, languageFilter)) continue;
const std::string cardSetId =
region == PokemonRegion::West ? westSetKey(card.set.id) : card.set.id;
if (cardSetId != wantSetId) continue;
const std::string setNo = normalizeForRegion(region, card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<PokemonChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
PokemonChecklistEntry entry;
entry.setNo = normalizeForRegion(region, card.setNo);
entry.name = card.name;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const PokemonChecklistEntry& a, const PokemonChecklistEntry& b) {
const int cmp = compareSetNoNatural(a.setNo, b.setNo);
if (cmp != 0) return cmp < 0;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+11 -1
View File
@@ -20,8 +20,18 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
return fetched;
}
Result<void> SetService::saveSets(Game game, const std::vector<Set>& sets) {
return repo_.save(game, sets);
}
Result<std::vector<Set>> SetService::getSets(Game game) {
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

Some files were not shown because too many files have changed in this diff Show More