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>
This commit is contained in:
Sebastian Dine
2026-07-19 12:06:57 +02:00
committed by GitHub
parent 42926f2fb5
commit e5c830e945
53 changed files with 2170 additions and 49 deletions
+3 -3
View File
@@ -4,11 +4,11 @@
## 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`, `Set`, `MagicCard`, `PokemonCard`, `YuGiOhCard`, `DigiBattle99Card`, `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/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, and `digibattle99/` are the reference implementations — all four expose a fully working set source + card preview source.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
- `src/` mirrors `include/ccm/` for non-template implementations.
@@ -27,7 +27,7 @@
8. **HTTP query strings must be percent-encoded** before they reach `IHttpClient::get`. `cpr::Url` does **not** encode the URL string we hand it. See `MagicCardPreviewSource::buildSearchUrl` for the canonical pattern (RFC 3986 unreserved-set encoder). `IHttpClient::get` accepts arbitrary bytes back — `Result<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.pokemontcg.io`, `db.ygoprodeck.com`, `yugipedia.com`, `ms.yugipedia.com`, `digimoncard.io`, `images.digimoncard.io`) are reused with TLS keep-alive. Default request headers use **`Accept: */*`** so JSON endpoints and binary image downloads share one session without pinning every GET to `application/json`. The session is not thread-safe — every `get(...)` is serialized through an internal mutex. **Do not** construct a new `cpr::Session` (or `cpr::Get(...)`) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend `IHttpClient` and the adapter while keeping the single-session ownership intact.
## Adding a new game
+4
View File
@@ -7,6 +7,7 @@ add_library(ccm_core STATIC
src/domain/MagicCard.cpp
src/domain/PokemonCard.cpp
src/domain/YuGiOhCard.cpp
src/domain/DigiBattle99Card.cpp
src/domain/Configuration.cpp
src/services/ConfigService.cpp
@@ -31,6 +32,9 @@ add_library(ccm_core STATIC
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/util/FsNames.cpp
)
@@ -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
+2 -1
View File
@@ -19,6 +19,7 @@ enum class Game {
Magic,
Pokemon,
YuGiOh,
DigiBattle99,
};
enum class Language {
@@ -57,7 +58,7 @@ 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<Game, 4>& allGames() noexcept;
const std::array<Language, 8>& allLanguages() noexcept;
const std::array<Condition, 7>& allConditions() noexcept;
const std::array<Theme, 2>& allThemes() noexcept;
@@ -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,37 @@
#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.
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
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";
explicit DigiBattle99SetSource(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);
// 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
+5
View File
@@ -19,6 +19,7 @@
// * An empty filter matches every row, exactly as in JS where every string
// `.includes("")` returns true.
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -41,4 +42,8 @@ namespace ccm {
[[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);
} // namespace ccm
+18
View File
@@ -16,6 +16,7 @@
// 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/MagicCard.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
@@ -66,6 +67,20 @@ enum class YuGiOhSortColumn {
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,
};
// Stable in-place sort. `ascending=false` runs the same comparator with
// inverted sign, matching `byField(field, asc)` semantics.
void sortMagicCards(std::vector<MagicCard>& cards, MagicSortColumn column,
@@ -74,5 +89,8 @@ 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);
} // namespace ccm
+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
+11 -8
View File
@@ -13,9 +13,10 @@ 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";
}
CCM_UNREACHABLE();
}
@@ -56,9 +57,10 @@ std::string_view to_string(Theme t) noexcept {
}
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;
return std::nullopt;
}
@@ -91,8 +93,9 @@ 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, 4>& allGames() noexcept {
static constexpr std::array<Game, 4> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99};
return v;
}
@@ -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,119 @@
#include "ccm/games/digibattle99/DigiBattle99SetSource.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 {};
}
} // 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) {
try {
const auto j = nlohmann::json::parse(body);
if (j.is_object() && j.contains("error")) {
return Result<std::vector<Set>>::err(
j.value("error", std::string{"digimoncard.io set search error"}));
}
if (!j.is_array()) {
return Result<std::vector<Set>>::err(
"digimoncard.io Digi-Battle response is not a JSON array.");
}
// Preserve first-seen order of pack names, then sort by release date.
std::unordered_set<std::string> seen;
std::vector<std::string> packNames;
packNames.reserve(16);
for (const auto& entry : j) {
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
for (const auto& pack : entry.at("set_name")) {
if (!pack.is_string()) continue;
const std::string name = pack.get<std::string>();
if (name.empty()) continue;
if (seen.insert(name).second) packNames.push_back(name);
}
}
std::vector<Set> out;
out.reserve(packNames.size());
for (const auto& name : packNames) {
Set s;
s.id = slugifyPackName(name);
s.name = name;
s.releaseDate = releaseDateForPack(name);
if (s.id.empty()) continue;
out.push_back(std::move(s));
}
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
return a.name < b.name;
});
return Result<std::vector<Set>>::ok(std::move(out));
} catch (const std::exception& e) {
return Result<std::vector<Set>>::err(
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
}
}
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value());
}
} // namespace ccm
+15
View File
@@ -67,4 +67,19 @@ bool matchesYuGiOhFilter(const YuGiOhCard& card, std::string_view filter) {
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;
}
} // namespace ccm
+70
View File
@@ -223,4 +223,74 @@ 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;
}
}
} // namespace ccm