Minor: Set completion tracking (#19)

This commit is contained in:
Sebastian Dine
2026-07-23 10:29:56 +02:00
committed by GitHub
parent c9e6bc2b6b
commit 9917e364c1
66 changed files with 5786 additions and 224 deletions
@@ -0,0 +1,52 @@
#pragma once
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
// completion. Filled from digimoncard.io bulk search.php (same payload as the
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99CatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const DigiBattle99CatalogCard&,
const DigiBattle99CatalogCard&) = default;
};
struct DigiBattle99SetCatalogPack {
std::string setId;
std::string setName;
std::vector<DigiBattle99CatalogCard> cards;
friend bool operator==(const DigiBattle99SetCatalogPack&,
const DigiBattle99SetCatalogPack&) = default;
};
struct DigiBattle99SetCatalog {
std::vector<DigiBattle99SetCatalogPack> packs;
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const DigiBattle99SetCatalog&,
const DigiBattle99SetCatalog&) = default;
};
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
} // namespace ccm
@@ -0,0 +1,52 @@
#pragma once
// PokemonSetCatalog: offline pack → card checklist for Pokemon set
// completion. West and Asia each persist their own file under
// `<dataStorage>/pokemon/` (`set-catalog-west.json` / `set-catalog-asia.json`).
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const PokemonCatalogCard&,
const PokemonCatalogCard&) = default;
};
struct PokemonSetCatalogPack {
std::string setId;
std::string setName;
std::vector<PokemonCatalogCard> cards;
friend bool operator==(const PokemonSetCatalogPack&,
const PokemonSetCatalogPack&) = default;
};
struct PokemonSetCatalog {
std::vector<PokemonSetCatalogPack> packs;
[[nodiscard]] const PokemonSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const PokemonSetCatalog&,
const PokemonSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const PokemonCatalogCard& c);
void from_json(const nlohmann::json& j, PokemonCatalogCard& c);
void to_json(nlohmann::json& j, const PokemonSetCatalogPack& p);
void from_json(const nlohmann::json& j, PokemonSetCatalogPack& p);
void to_json(nlohmann::json& j, const PokemonSetCatalog& c);
void from_json(const nlohmann::json& j, PokemonSetCatalog& c);
} // namespace ccm
@@ -0,0 +1,52 @@
#pragma once
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const YuGiOhCatalogCard&,
const YuGiOhCatalogCard&) = default;
};
struct YuGiOhSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhCatalogCard> cards;
friend bool operator==(const YuGiOhSetCatalogPack&,
const YuGiOhSetCatalogPack&) = default;
};
struct YuGiOhSetCatalog {
std::vector<YuGiOhSetCatalogPack> packs;
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhSetCatalog&,
const YuGiOhSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
} // namespace ccm
@@ -3,12 +3,15 @@
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
// The same payload also builds the set-completion catalog (parseCatalog).
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
@@ -20,12 +23,21 @@ public:
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
struct FetchWithCatalog {
std::vector<Set> sets;
DigiBattle99SetCatalog catalog;
};
explicit DigiBattle99SetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Pure parser exposed for unit testing without a network round-trip.
// One HTTP round-trip producing both the set list and the pack catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parsers exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
static std::string slugifyPackName(std::string_view packName);
@@ -1,11 +1,11 @@
#pragma once
// PokemonCardPreviewSource: ICardPreviewSource implementation for the Pokemon
// TCG. Calls the Pokemon TCG search endpoint at
// https://api.pokemontcg.io/v2/cards?q=name:"<name>" set.id:<setId> number:<setNo>
// and returns `data[0].images.large` (with `images.small` as a graceful
// fallback). Mirrors the established `getImage` flow in
// `src/components/pokemon/SelectedPokemonPanel.tsx`.
// TCG. When set id + collector number are both known, prefers
// GET https://api.pokemontcg.io/v2/cards/{setId}-{number}
// then falls back to a name-less search `set.id:… number:…`. Name-based
// search is kept for lookups that lack a set number (or set id). Returns
// `images.large` (with `images.small` as a graceful fallback).
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/ports/IHttpClient.hpp"
@@ -32,24 +32,37 @@ public:
std::string_view setId) override;
// Build the fully URL-encoded Pokemon TCG search URL for the given card.
// When both setId and setNo are non-empty, omits the name: clause so the
// Lucene query cannot miss on name∩number intersections.
// Exposed for unit testing and to keep encoding rules in one place.
static std::string buildSearchUrl(std::string_view name,
std::string_view setId,
std::string_view setNo);
// Direct card endpoint: /v2/cards/{setId}-{normalizedNumber}.
static std::string buildCardByIdUrl(std::string_view setId, std::string_view setNo);
// Strip everything after the first '/' (e.g. "4/102" -> "4"). Used by
// preview lookups, auto-detect, and set-completion ownership matching.
static std::string normalizeCollectorNumber(std::string_view setNo);
// Slimmer search URL for auto-detect: omits the number clause and asks the
// API for only the fields the print-variant parser needs.
static std::string buildDetectSearchUrl(std::string_view name,
std::string_view setId);
// Parse a Pokemon TCG /v2/cards response body and pull out the image URL
// for the first matching card. Prefers `images.large`, falls back to
// `images.small`. Errors are classified:
// Parse a Pokemon TCG /v2/cards *search* response body (`data` array) and
// pull out the image URL for the first matching card. Prefers
// `images.large`, falls back to `images.small`. Errors are classified:
// - JSON parse failure or missing/non-array `data` => Transient.
// - Empty `data` array or missing image variants => NotFound.
static Result<std::string, PreviewLookupError>
parseResponse(const std::string& body);
// Parse a Pokemon TCG /v2/cards/{id} response (`data` object).
static Result<std::string, PreviewLookupError>
parseCardByIdResponse(const std::string& body);
// Enumerate distinct collector numbers (and rarities) for an exact card
// name inside the chosen set. Exposed for unit testing without HTTP.
static Result<std::vector<AutoDetectedPrint>>
@@ -6,23 +6,56 @@
// The Pokemon TCG API already returns `releaseDate` in `YYYY/MM/DD` format,
// so no rewriting is needed (unlike Scryfall's `released_at`).
// Behavior matches `pokemon/set_services.rs::update_sets`.
// Set-completion catalog is built from a paginated /v2/cards dump.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <vector>
namespace ccm {
class PokemonSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://api.pokemontcg.io/v2/sets";
static constexpr const char* kCardsEndpoint = "https://api.pokemontcg.io/v2/cards";
static constexpr int kCardsPageSize = 250;
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
explicit PokemonSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Sets endpoint + paginated cards dump for the offline checklist.
Result<FetchWithCatalog> fetchAllWithCatalog();
// Pure parser exposed for unit testing without a network round-trip.
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Build / merge checklist packs from one /v2/cards page body. Pass an
// accumulating catalog; returns page count metadata for pagination.
struct CardsPageMeta {
int page{1};
int pageSize{kCardsPageSize};
int count{0};
int totalCount{0};
};
static Result<CardsPageMeta> mergeCardsPage(const std::string& body,
PokemonSetCatalog& catalog,
const std::vector<Set>& sets);
static Result<PokemonSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
static std::string buildCardsPageUrl(int page, int pageSize = kCardsPageSize);
private:
IHttpClient& http_;
};
@@ -54,6 +54,10 @@ public:
[[nodiscard]] bool hasPrintsForSet(std::string_view setId) const noexcept;
// All prints for a set (catalog gap-fill / set-completion checklists).
[[nodiscard]] std::vector<JapanesePokemonPrintEnInfo>
printsForSet(std::string_view setId) const;
// TCGPlayer product-image CDN URL for classic JA gap-fill.
[[nodiscard]] static std::string tcgplayerImageUrl(std::string_view productId);
@@ -1,22 +1,37 @@
#pragma once
// JapanesePokemonSetSource: TCGdex ja set list + per-set detail for release
// dates. English display names come from JapanesePokemonEnCatalog when present.
// dates and set-completion checklists. English display names come from
// JapanesePokemonEnCatalog when present.
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/pokemonjp/JapanesePokemonEnCatalog.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
class JapanesePokemonSetSource final : public ISetSource {
public:
static constexpr const char* kListEndpoint = "https://api.tcgdex.net/v2/ja/sets";
struct FetchWithCatalog {
std::vector<Set> sets;
PokemonSetCatalog catalog;
};
JapanesePokemonSetSource(IHttpClient& http, const JapanesePokemonEnCatalog& catalog);
Result<std::vector<Set>> fetchAll() override;
// List + per-set detail (cards + release date) + EN catalog gap-fill.
Result<FetchWithCatalog> fetchAllWithCatalog();
void augmentCachedSets(std::vector<Set>& sets) const override;
// Pure parsers for hermetic tests.
@@ -28,6 +43,16 @@ public:
static std::string rewriteReleaseDate(std::string_view isoDate);
static std::string buildSetDetailUrl(std::string_view setId);
// Build one pack checklist from a set-detail body, then gap-fill from catalog.
static Result<PokemonSetCatalogPack> parseCatalogPackFromSetDetail(
const std::string& detailBody,
const Set& set,
const JapanesePokemonEnCatalog& enCatalog);
// Catalog-only pack (classic products with no TCGdex detail).
static PokemonSetCatalogPack catalogPackFromEnCatalog(
const Set& set, const JapanesePokemonEnCatalog& enCatalog);
// Original-era theme decks / sheets omitted by TCGdex JA. Idempotent by id.
static void appendMissingClassicProducts(std::vector<Set>& sets);
@@ -1,21 +1,45 @@
#pragma once
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
// Sets come from cardsets.php; the set-completion catalog is built from the
// unfiltered cardinfo.php dump (card_sets[] per card).
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <vector>
namespace ccm {
class YuGiOhSetSource final : public ISetSource {
public:
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
static constexpr const char* kCardInfoEndpoint =
"https://db.ygoprodeck.com/api/v7/cardinfo.php";
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhSetCatalog catalog;
};
explicit YuGiOhSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override;
// Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for
// the pack checklist catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
static Result<std::vector<Set>> parseResponse(const std::string& body);
// Build the offline checklist from a cardinfo.php body, resolving pack
// ids against the already-parsed sets list (by set_name → Set.id).
static Result<YuGiOhSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
private:
IHttpClient& http_;
};
@@ -0,0 +1,36 @@
#pragma once
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
// the configured dataStorage path.
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class DigiBattle99SetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<DigiBattle99SetCatalog> load() const;
Result<void> save(const DigiBattle99SetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,59 @@
#pragma once
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
// inflate the numerator. An optional languageFilter restricts ownership to
// cards of that language (packs with zero matches are omitted).
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/Enums.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct DigiBattle99SetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct DigiBattle99ChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
digiBattle99LanguagesInCollection(const std::vector<DigiBattle99Card>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
const DigiBattle99SetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
@@ -0,0 +1,36 @@
#pragma once
// PokemonSetCatalogService: load/save pokemon/set-catalog-west.json and
// pokemon/set-catalog-asia.json under the configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class PokemonSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
PokemonSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<PokemonSetCatalog> load(PokemonRegion region) const;
Result<void> save(PokemonRegion region, const PokemonSetCatalog& catalog);
[[nodiscard]] bool exists(PokemonRegion region) const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath(PokemonRegion region) const;
};
} // namespace ccm
@@ -0,0 +1,71 @@
#pragma once
// Pure helpers: Pokemon set-completion progress and per-set checklists.
// Ownership requires matching PokemonRegion for the pack (West vs Asia),
// matching set.id, and a normalized collector number / localId. Duplicates /
// amount / holo / firstEdition do not inflate the numerator. Optional
// regionFilter and languageFilter restrict which cards count (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/domain/PokemonSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct PokemonSetCompletionProgress {
PokemonRegion region{PokemonRegion::West};
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct PokemonChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection (optionally region-scoped),
// in allLanguages() order.
[[nodiscard]] std::vector<Language>
pokemonLanguagesInCollection(const std::vector<PokemonCard>& collection,
std::optional<PokemonRegion> regionFilter = std::nullopt);
// Distinct regions that have ≥1 owned card matching a catalog pack.
[[nodiscard]] std::vector<PokemonRegion>
pokemonRegionsInCollection(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog);
// Packs where the collection owns ≥1 matching card, ordered by setName then
// region. When regionFilter is set, only that region's catalog/cards count.
[[nodiscard]] std::vector<PokemonSetCompletionProgress>
computePokemonSetCompletion(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
std::optional<PokemonRegion> regionFilter = std::nullopt,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
[[nodiscard]] std::vector<PokemonChecklistEntry>
pokemonChecklistForSet(const std::vector<PokemonCard>& collection,
const PokemonSetCatalog& westCatalog,
const PokemonSetCatalog& asiaCatalog,
PokemonRegion region,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
+4
View File
@@ -27,6 +27,10 @@ public:
// repository, and return the new list.
Result<std::vector<Set>> updateSets(Game game);
// Persist an already-fetched set list (no HTTP). Used when a game-specific
// Update Sets path fetches sets + side payloads in one round-trip.
Result<void> saveSets(Game game, const std::vector<Set>& sets);
// Cached read; returns an error if no local data exists yet.
Result<std::vector<Set>> getSets(Game game);
@@ -0,0 +1,36 @@
#pragma once
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
// configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhSetCatalog> load() const;
Result<void> save(const YuGiOhSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,60 @@
#pragma once
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
// amount / rarity / firstEdition do not inflate the numerator. An optional
// languageFilter restricts ownership to cards of that language (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhSetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct YuGiOhChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm