Minor: New Game Yu-Gi-Oh! Bandai (#22)

This commit is contained in:
Sebastian Dine
2026-07-30 14:51:53 +02:00
committed by GitHub
parent 7cf25d671f
commit 2eb7c59f78
65 changed files with 4142 additions and 75 deletions
+6 -3
View File
@@ -17,6 +17,7 @@ std::string_view to_string(Game g) noexcept {
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";
}
CCM_UNREACHABLE();
@@ -72,6 +73,7 @@ std::optional<Game> gameFromString(std::string_view s) noexcept {
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;
}
@@ -115,9 +117,10 @@ std::optional<Theme> themeFromString(std::string_view s) noexcept {
return std::nullopt;
}
const std::array<Game, 4>& allGames() noexcept {
static constexpr std::array<Game, 4> v{
Game::Magic, Game::Pokemon, Game::YuGiOh, Game::DigiBattle99};
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;
}
+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
@@ -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
+16
View File
@@ -83,6 +83,22 @@ bool matchesDigiBattle99Filter(const DigiBattle99Card& card, std::string_view fi
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;
if (containsLower(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 matchesJapanesePokemonFilter(const JapanesePokemonCard& card,
std::string_view filter) {
if (filter.empty()) return true;
+27
View File
@@ -262,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
+73
View File
@@ -293,6 +293,79 @@ void sortDigiBattle99Cards(std::vector<DigiBattle99Card>& cards,
}
}
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) {
@@ -0,0 +1,50 @@
#include "ccm/services/YuGiOhBandaiSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
YuGiOhBandaiSetCatalogService::YuGiOhBandaiSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path YuGiOhBandaiSetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOhBandai) /
"set-catalog.json";
}
bool YuGiOhBandaiSetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<YuGiOhBandaiSetCatalog> YuGiOhBandaiSetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<YuGiOhBandaiSetCatalog>::err(
"Yu-Gi-Oh! (Bandai) set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<YuGiOhBandaiSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<YuGiOhBandaiSetCatalog>::ok(j.get<YuGiOhBandaiSetCatalog>());
} catch (const std::exception& e) {
return Result<YuGiOhBandaiSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> YuGiOhBandaiSetCatalogService::save(const YuGiOhBandaiSetCatalog& 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,128 @@
#include "ccm/services/YuGiOhBandaiSetCompletion.hpp"
#include "ccm/games/yugiohbandai/YuGiOhBandaiSetSource.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 YuGiOhBandaiCard& card,
std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSetNosBySetId(const std::vector<YuGiOhBandaiCard>& 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 = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (setNo.empty()) continue;
out[card.set.id].insert(setNo);
}
return out;
}
} // namespace
std::vector<Language>
yuGiOhBandaiLanguagesInCollection(const std::vector<YuGiOhBandaiCard>& 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<YuGiOhBandaiSetCompletionProgress>
computeYuGiOhBandaiSetCompletion(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSetNosBySetId(collection, languageFilter);
std::vector<YuGiOhBandaiSetCompletionProgress> 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 =
YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
}
YuGiOhBandaiSetCompletionProgress 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 YuGiOhBandaiSetCompletionProgress& a,
const YuGiOhBandaiSetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<YuGiOhBandaiChecklistEntry>
yuGiOhBandaiChecklistForSet(const std::vector<YuGiOhBandaiCard>& collection,
const YuGiOhBandaiSetCatalog& 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 = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
if (!setNo.empty()) ownedNos.insert(setNo);
}
std::vector<YuGiOhBandaiChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
YuGiOhBandaiChecklistEntry entry;
entry.setNo = YuGiOhBandaiSetSource::normalizeCardNumber(card.setNo);
entry.name = card.name;
entry.rarity = card.rarity;
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhBandaiChecklistEntry& a,
const YuGiOhBandaiChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm