mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 00:01:07 +00:00
Minor: New Game Yu-Gi-Oh! Bandai (#22)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 1–42 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user