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