Files
2026-07-30 14:51:53 +02:00

14 KiB

core/AGENTS.md

ccm_core static library — domain types, ports, services, infra adapters. Hard rule: no UI dependencies, ever. Read the root AGENTS.md first.

Layer pointers

  • include/ccm/domain/ — POD value types: Enums (includes PokemonRegion), Set, MagicCard, PokemonCard (unified West/Asia via region), YuGiOhCard, YuGiOhSetCatalog (Yu-Gi-Oh! pack checklists for set completion), YuGiOhBandaiCard, YuGiOhBandaiSetCatalog (Bandai pack checklists for set completion), DigiBattle99Card, DigiBattle99SetCatalog (Digi-Battle pack checklists for set completion), PokemonSetCatalog (Pokemon West/Asia pack checklists for set completion), JapanesePokemonCard (legacy type retained for tests/serde; app collection uses PokemonCard), Configuration. Each has to_json / from_json defined in the matching src/domain/*.cpp.
  • include/ccm/ports/ — interfaces (IHttpClient, IFileSystem, ICollectionRepository<T>, ISetRepository, IImageStore, ICardPreviewSource, IPreviewByteCache). All seams the services depend on. Add new ports here when adding new external concerns.
  • include/ccm/infra/ — concrete adapters: CprHttpClient, StdFileSystem, JsonCollectionRepository<T> (header-only template), JsonSetRepository, LocalImageStore, LocalPreviewByteCache.
  • include/ccm/services/ — high-level operations: ConfigService, CollectionService<TCard> (header-only template), SetService, ImageService, CardPreviewService, CardSorter (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), CardFilter (free functions; case-insensitive substring row matcher restricted to each game's tableFields valueKey list), YuGiOhSetCompletion / YuGiOhBandaiSetCompletion / DigiBattle99SetCompletion / PokemonSetCompletion (pure set-completion / checklist helpers), YuGiOhSetCatalogService (yugioh/set-catalog.json), YuGiOhBandaiSetCatalogService (yugiohbandai/set-catalog.json), DigiBattle99SetCatalogService (digibattle99/set-catalog.json), PokemonSetCatalogService (pokemon/set-catalog-west.json / set-catalog-asia.json). They depend only on ports / domain.
  • include/ccm/games/IGameModule + per-game modules. IGameModule consolidates the per-game seams: every module owns an ISetSource (required) and may own an ICardPreviewSource (optional, default nullptr). magic/, pokemon/, yugioh/, yugiohbandai/, digibattle99/, and pokemonjp/ are the reference implementations — all expose a fully working set source + card preview source. YuGiOhSetSource, YuGiOhBandaiSetSource, DigiBattle99SetSource, PokemonSetSource, and JapanesePokemonSetSource also expose fetchAllWithCatalog (and related catalog parsers) for set-completion checklists. pokemonjp/ is the Asia region backend for the unified Pokemon UI (set cache at pokemon/sets-asia.json, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (JapanesePokemonEnCatalog) for display/auto-detect / Asia set-completion gap-fill.
  • include/ccm/util/Result.hpp (the sum type), FsNames.hpp (filename munging ported from util/fs.rs), YuGiOhPrintingSlot.hpp / YuGiOhSetLookup.hpp (Yu-Gi-Oh! print-slot helpers and cached-set set code lookup for the edit dialog; both header-only, unit-tested).
  • src/ mirrors include/ccm/ for non-template implementations.

Conventions

  1. No throw across ports. Return ccm::Result<T>::ok(...) / Result<T>::err("msg"). The caller propagates with if (!r) return Result<T>::err(r.error());.
  2. JSON serde stays byte-for-byte stable. When the C++ field name differs from the JSON key (signed_ vs "signed", releaseDate, setNo, firstEdition, dataStorage, defaultGame), write hand-rolled to_json / from_json instead of NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE so the alias is explicit. Round-trip tests in tests/domain_json_tests.cpp enforce this — extend them whenever you touch a domain type.
  3. Filename rule for images lives in services/ImageService.hpp and matches the Rust source exactly:
    • new entry -> "{set}+{name}+{idx}.{ext}"
    • existing -> "{id}+{set}+{name}+{idx}.{ext}"
      ImageService::buildTargetName is the single source of truth. Don't duplicate the rule elsewhere.
  4. Templates stay header-only (CollectionService<T>, JsonCollectionRepository<T>). Don't add .cpp files for them; explicit instantiation is not used.
  5. Path strings that get persisted (e.g. Configuration::dataStorage) use std::filesystem::path::generic_string(), never string() — keeps / separators on Windows so JSON round-trips and tests stay portable.
  6. Compiler warnings: every target in this package links ccm_warnings PRIVATE. Treat warnings as errors locally during dev (-Werror is opt-in but encouraged).
  7. No wx/... includes in headers or sources here. Verify with rg "wx/" core/ — must be empty.
  8. HTTP query strings must be percent-encoded before they reach IHttpClient::get. cpr::Url does not encode the URL string we hand it. See MagicCardPreviewSource::buildSearchUrl for the canonical pattern (RFC 3986 unreserved-set encoder). IHttpClient::get accepts arbitrary bytes back — Result<std::string> is a binary buffer, not text, so callers can use it for image payloads directly.
  9. Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck. YuGiOhCardPreviewSource::fetchImageUrl queries Yugipedia's MediaWiki API with a batched list of deterministic file names (<Slug>-<SET>-<REGION>-<RARITY>-<EDITION>.<png|jpg>) so per-printing reprints with shared passcodes (LOB Blue-Eyes vs SDK Blue-Eyes, …) resolve to genuinely different scans. Region candidates are always English (EN/NA/EU/AU) regardless of card.language; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (see parseFallbackImageUrl) for cards Yugipedia hasn't scanned yet, and as the source for detectFirstPrint / detectPrintVariants (parsePrintVariants enumerates distinct printings for the edit dialog). Do not restore a YGOPRODeck-only image path: that endpoint's card_images array is keyed by art-treatment passcode, not by physical printing, and adding cardset= only reorders the same passcode list (alt-art often gets promoted) without ever surfacing the per-printing scan. The YGO source therefore needs the printed edition flag to be plumbed through; YuGiOhSelectedCardPanel::previewKey() packs it into the third tuple slot as <setNo>||<rarity>||<1E|UE> so the candidate list can prioritize the correct edition without changing the generic ICardPreviewSource interface.
  10. Preview byte cache (CardPreviewService) is by (game, name, setId, setNo) across two tiers, with classified failure caching and a single update mechanic. Successful fetchPreviewBytes results and successful fetchImageBytesByUrl results are stored first in a bounded in-memory LRU (kCacheCapacity entries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (IPreviewByteCache, normally LocalPreviewByteCache rooted at <exeDir>/.cache/preview-cache/ — next to the executable, not under dataStorage, so previews don't follow the user's collection when the data-storage path is reconfigured). fetchAndCache rejects empty response bodies (returns error, no tier write) so a degenerate HTTP 200 cannot fill the LRU with unusable entries. Lookup order is memory → disk → source/HTTP, and a disk hit (positive or negative) is promoted into the in-memory tier on its way to the caller so the next selection of the same row stays decode-only. Failures are split by PreviewLookupError::Kind: NotFound is negative-cached in both tiers (memory CacheEntry::negative=true, disk <hash>.neg marker) so the user gets an instant card-back on every subsequent click for cards whose printing genuinely has no upstream image; Transient (HTTP/network/parse failures) is never cached so a brief outage cannot permanently disable previews. Per-game ICardPreviewSource::fetchImageUrl implementations must classify their errors honestly — NotFound only when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation is Transient. The cache update mechanic is entirely key-driven and has no side-channel API: (a) the user editing any lookup-relevant field of a card record changes the cache key, so the next selection misses both tiers and re-runs the source — this is how a stale negative entry gets dislodged after the user fixes the record, with no manual invalidation call needed; (b) a same-key resolution that flips between positive and negative outcomes overwrites the existing entry in both tiers (store removes any .neg for that hash; storeNegative removes any .bin) so .bin and .neg for the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime .bin files on the disk tier; .neg markers don't count against the size cap and are not actively evicted). Do not add a clearCache(...) / invalidate(...) method to CardPreviewService: the cache invariants depend on memory and disk staying aligned through the same write paths, and any side-channel API would just be a new way for future code to forget the disk tier. If you add a new lookup disambiguator (for example a future editionTag slot), pack it into one of the existing key fields (see YuGiOhSelectedCardPanel::previewKey()'s ||-separated trailing fields) so editing the field continues to invalidate cached entries automatically. The persistent tier is fire-and-forget: the adapter swallows I/O errors so a flaky or full disk degrades the experience to a fresh-install warm-up, never to a broken preview path.
  11. CprHttpClient keeps one persistent cpr::Session for the app's lifetime. All callers (set sources, preview sources, fallback URL fetch, auto-detect) share the same libcurl easy handle so connections to repeat hosts (api.scryfall.com, api.tcgdex.net, assets.tcgdex.net, product-images.tcgplayer.com, db.ygoprodeck.com, yugipedia.com, ms.yugipedia.com, digimoncard.io, images.digimoncard.io) are reused with TLS keep-alive. Default request headers use Accept: */* so JSON endpoints and binary image downloads share one session without pinning every GET to application/json. The session is not thread-safe — every get(...) is serialized through an internal mutex. Do not construct a new cpr::Session (or cpr::Get(...)) per call: that throws away the connection cache and re-pays the TLS handshake every time. If you need richer behavior on the port (POST, headers per call, …) extend IHttpClient and the adapter while keeping the single-session ownership intact.

Adding a new game

The end-to-end procedure (core + UI + composition root + docs) lives in docs/adding-a-new-game.md. The core-side checklist is:

  1. Add Game::<Name> plus to_string / <Name>FromString / allGames() entries in include/ccm/domain/Enums.hpp and src/domain/Enums.cpp.
  2. Create include/ccm/games/<name>/<Name>SetSource.hpp + .cpp implementing ISetSource. Mirror MagicSetSource / PokemonSetSource: expose a static parseResponse(std::string) helper so it's unit-testable without HTTP.
  3. (Optional) Create include/ccm/games/<name>/<Name>CardPreviewSource.hpp + .cpp implementing ICardPreviewSource. Mirror MagicCardPreviewSource / PokemonCardPreviewSource: expose static buildSearchUrl + parseResponse helpers for unit testing without HTTP.
  4. Create include/ccm/games/<name>/<Name>GameModule.hpp + .cpp implementing IGameModule. Pick a stable lowercase dirName() — it becomes the on-disk subdirectory and must never change. The module owns its set source and (optionally) its card preview source: override cardPreviewSource() to return &previewSource_ when present (default returns nullptr).
  5. If the game has a card type with different fields, add a <Name>Card domain type with hand-rolled JSON aliases. Otherwise reuse an existing one.
  6. Add the new .cpp files to core/CMakeLists.txt (no glob).
  7. Add tests under tests/<name>_set_source_tests.cpp and tests/<name>_card_preview_source_tests.cpp modeled on the Magic / Pokemon versions.
  8. The composition root in app/main.cpp and the directory mapping in app/main.cpp::dirNameForGame must be updated too — see app/AGENTS.md. CardPreviewService::registerModule(*module) is the single registration call; modules whose cardPreviewSource() returns nullptr are silently skipped.

Adding / changing a card-table column

When you add or rename a tableFields entry on a list panel (Magic or Pokemon), keep core/'s sort/filter helpers and their tests in lockstep:

  1. Extend MagicSortColumn / PokemonSortColumn and add a case branch in sortMagicCards / sortPokemonCards (core/include/ccm/services/CardSorter.hpp + .cpp).
  2. Add the new value-key column to the matching matchesMagicFilter / matchesPokemonFilter (core/include/ccm/services/CardFilter.hpp + .cpp) — boolean-flag columns are excluded (the filtering rule only checks values equivalent to JS typeof === "string" | "number").
  3. Add tests under tests/card_sorter_tests.cpp and tests/card_filter_tests.cpp.

Adding a new port

  1. Add the interface header under include/ccm/ports/ with virtual ~IFoo() = default;.
  2. Implement the adapter under include/ccm/infra/ + src/infra/. Mark it final.
  3. Update core/CMakeLists.txt. Wire it into the relevant service's constructor.
  4. Add a fake under tests/fakes/ modeled on InMemoryFileSystem and write service-level tests against it.

Commands

Build core only: cmake --build build --target ccm_core