mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 17:01:02 +00:00
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:
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user