mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-28 23:01:09 +00:00
14 KiB
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(includesPokemonRegion),Set,MagicCard,PokemonCard(unified West/Asia viaregion),YuGiOhCard,YuGiOhSetCatalog(Yu-Gi-Oh! 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 usesPokemonCard),Configuration. Each hasto_json/from_jsondefined in the matchingsrc/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'stableFieldsvalueKey list),YuGiOhSetCompletion/DigiBattle99SetCompletion/PokemonSetCompletion(pure set-completion / checklist helpers),YuGiOhSetCatalogService(yugioh/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.IGameModuleconsolidates the per-game seams: every module owns anISetSource(required) and may own anICardPreviewSource(optional, defaultnullptr).magic/,pokemon/,yugioh/,digibattle99/, andpokemonjp/are the reference implementations — all five expose a fully working set source + card preview source.YuGiOhSetSource,DigiBattle99SetSource,PokemonSetSource, andJapanesePokemonSetSourcealso exposefetchAllWithCatalog(and related catalog parsers) for set-completion checklists.pokemonjp/is the Asia region backend for the unified Pokemon UI (set cache atpokemon/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 fromutil/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/mirrorsinclude/ccm/for non-template implementations.
Conventions
- No throw across ports. Return
ccm::Result<T>::ok(...)/Result<T>::err("msg"). The caller propagates withif (!r) return Result<T>::err(r.error());. - 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-rolledto_json/from_jsoninstead ofNLOHMANN_DEFINE_TYPE_NON_INTRUSIVEso the alias is explicit. Round-trip tests intests/domain_json_tests.cppenforce this — extend them whenever you touch a domain type. - Filename rule for images lives in
services/ImageService.hppand matches the Rust source exactly:- new entry ->
"{set}+{name}+{idx}.{ext}" - existing ->
"{id}+{set}+{name}+{idx}.{ext}"
ImageService::buildTargetNameis the single source of truth. Don't duplicate the rule elsewhere.
- new entry ->
- Templates stay header-only (
CollectionService<T>,JsonCollectionRepository<T>). Don't add.cppfiles for them; explicit instantiation is not used. - Path strings that get persisted (e.g.
Configuration::dataStorage) usestd::filesystem::path::generic_string(), neverstring()— keeps/separators on Windows so JSON round-trips and tests stay portable. - Compiler warnings: every target in this package links
ccm_warningsPRIVATE. Treat warnings as errors locally during dev (-Werroris opt-in but encouraged). - No
wx/...includes in headers or sources here. Verify withrg "wx/" core/— must be empty. - HTTP query strings must be percent-encoded before they reach
IHttpClient::get.cpr::Urldoes not encode the URL string we hand it. SeeMagicCardPreviewSource::buildSearchUrlfor the canonical pattern (RFC 3986 unreserved-set encoder).IHttpClient::getaccepts arbitrary bytes back —Result<std::string>is a binary buffer, not text, so callers can use it for image payloads directly. - Yu-Gi-Oh! preview uses Yugipedia, not YGOPRODeck.
YuGiOhCardPreviewSource::fetchImageUrlqueries 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 ofcard.language; localized scans are not queried. YGOPRODeck remains as a last-resort fallback (seeparseFallbackImageUrl) for cards Yugipedia hasn't scanned yet, and as the source fordetectFirstPrint/detectPrintVariants(parsePrintVariantsenumerates distinct printings for the edit dialog). Do not restore a YGOPRODeck-only image path: that endpoint'scard_imagesarray is keyed by art-treatment passcode, not by physical printing, and addingcardset=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 genericICardPreviewSourceinterface. - Preview byte cache (
CardPreviewService) is by(game, name, setId, setNo)across two tiers, with classified failure caching and a single update mechanic. SuccessfulfetchPreviewBytesresults and successfulfetchImageBytesByUrlresults are stored first in a bounded in-memory LRU (kCacheCapacityentries, mutex-protected — the panel calls into the service from a worker thread) and then in an optional persistent byte cache (IPreviewByteCache, normallyLocalPreviewByteCacherooted at<exeDir>/.cache/preview-cache/— next to the executable, not underdataStorage, so previews don't follow the user's collection when the data-storage path is reconfigured).fetchAndCacherejects 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 byPreviewLookupError::Kind:NotFoundis negative-cached in both tiers (memoryCacheEntry::negative=true, disk<hash>.negmarker) 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-gameICardPreviewSource::fetchImageUrlimplementations must classify their errors honestly —NotFoundonly when the upstream answered cleanly with no match / no image variants; anything that could be the network or a schema deviation isTransient. 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 (storeremoves any.negfor that hash;storeNegativeremoves any.bin) so.binand.negfor the same hash are never co-resident; (c) eviction handles passive aging (LRU on the in-memory tier; oldest-by-mtime.binfiles on the disk tier;.negmarkers don't count against the size cap and are not actively evicted). Do not add aclearCache(...)/invalidate(...)method toCardPreviewService: 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 futureeditionTagslot), pack it into one of the existing key fields (seeYuGiOhSelectedCardPanel::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. CprHttpClientkeeps one persistentcpr::Sessionfor 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 useAccept: */*so JSON endpoints and binary image downloads share one session without pinning every GET toapplication/json. The session is not thread-safe — everyget(...)is serialized through an internal mutex. Do not construct a newcpr::Session(orcpr::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, …) extendIHttpClientand 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:
- Add
Game::<Name>plusto_string/<Name>FromString/allGames()entries ininclude/ccm/domain/Enums.hppandsrc/domain/Enums.cpp. - Create
include/ccm/games/<name>/<Name>SetSource.hpp+.cppimplementingISetSource. MirrorMagicSetSource/PokemonSetSource: expose a staticparseResponse(std::string)helper so it's unit-testable without HTTP. - (Optional) Create
include/ccm/games/<name>/<Name>CardPreviewSource.hpp+.cppimplementingICardPreviewSource. MirrorMagicCardPreviewSource/PokemonCardPreviewSource: expose staticbuildSearchUrl+parseResponsehelpers for unit testing without HTTP. - Create
include/ccm/games/<name>/<Name>GameModule.hpp+.cppimplementingIGameModule. Pick a stable lowercasedirName()— it becomes the on-disk subdirectory and must never change. The module owns its set source and (optionally) its card preview source: overridecardPreviewSource()to return&previewSource_when present (default returnsnullptr). - If the game has a card type with different fields, add a
<Name>Carddomain type with hand-rolled JSON aliases. Otherwise reuse an existing one. - Add the new
.cppfiles tocore/CMakeLists.txt(no glob). - Add tests under
tests/<name>_set_source_tests.cppandtests/<name>_card_preview_source_tests.cppmodeled on the Magic / Pokemon versions. - The composition root in
app/main.cppand the directory mapping inapp/main.cpp::dirNameForGamemust be updated too — seeapp/AGENTS.md.CardPreviewService::registerModule(*module)is the single registration call; modules whosecardPreviewSource()returnsnullptrare 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:
- Extend
MagicSortColumn/PokemonSortColumnand add acasebranch insortMagicCards/sortPokemonCards(core/include/ccm/services/CardSorter.hpp+.cpp). - 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 JStypeof === "string" | "number"). - Add tests under
tests/card_sorter_tests.cppandtests/card_filter_tests.cpp.
Adding a new port
- Add the interface header under
include/ccm/ports/withvirtual ~IFoo() = default;. - Implement the adapter under
include/ccm/infra/+src/infra/. Mark itfinal. - Update
core/CMakeLists.txt. Wire it into the relevant service's constructor. - Add a fake under
tests/fakes/modeled onInMemoryFileSystemand write service-level tests against it.
Commands
Build core only: cmake --build build --target ccm_core