* initial development * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * pipeline * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * ci/cd * pokemon * pokemon * pokemon * pokemon * pokemon * pokemon * improvements * improvements * ci/cd * ci/cd * improvements * improvements * improvements * improvements * improvements * improvements * improvements * improvements * improvements --------- Co-authored-by: sdine <sdine@sdine.com>
32 KiB
#documentation #architecture #game-modules
Adding A New Game To Card Collection Manager
This is the canonical end-to-end walkthrough for adding support for a new TCG to Card Collection Manager. The guide covers external API selection, core and UI integration, composition-root wiring, and required tests so a full implementation can land cleanly on main.
Quick Setup: gather required external resources first, then follow sections in order and run the full verification checklist before opening a PR.
The guide is prescriptive about file locations and seam shapes but game-agnostic in naming. Replace <Name> with your game type name and <name> with the lowercase key used for on-disk directories and dirName(). Both Magic and Pokemon follow this structure; use their implementations as references when needed.
Read the root AGENTS.md, core/AGENTS.md, ui_wx/AGENTS.md, app/AGENTS.md, and tests/AGENTS.md before starting. They define the architecture rules this guide is built on top of.
1. Prerequisites: pick your external resources
Before you write any C++, gather the following. The further along you discover that something is missing, the more work you throw away.
1.1 Set list API (required)
A public HTTP endpoint that returns the canonical set / expansion list for the game, with at least:
- a stable identifier (
id) — used as the on-disk and JSON key. Must be stable across API revisions. - a human-readable name.
- a release date — used to sort the set picker chronologically.
The endpoint must be callable without authentication, or you must accept a hard-coded API key (we do not currently expose a way to ask the user for one). It must support HTTPS. Plan for the response body to be JSON; we do not have an XML or CSV path.
The release date may be in any format as long as you can rewrite it to YYYY/MM/DD during parsing, because the Set domain type stores it that way (see core/include/ccm/domain/Set.hpp) and the rest of the code assumes lexicographic comparison sorts chronologically.
1.2 Card preview API (optional)
A public HTTP endpoint that returns the URL of a card's preview image given some lookup key (typically name, set id, and possibly a printed collector number). If the game does not expose one, the UI will simply skip the remote preview and only show locally-stored images — the implementation is allowed to omit this seam entirely.
A few traps to plan around now, before you write code:
- Lookup precision. Some APIs return many ambiguous matches when you query by name only and require the set id (and sometimes the collector number) to disambiguate. Decide up front which fields make a search reliable enough to take the first result.
- URL encoding. All query strings must be RFC 3986 percent-encoded before they reach
IHttpClient::get(cpr::Urldoes not re-encode). The Magic/Pokemon implementations have a privateurlEncodehelper you can copy. - Collector-number normalization. Pokemon stores
4/102but the API only accepts4. Whichever convention your domain type uses, normalize it insidebuildSearchUrlso the wire format is whatever the API actually expects. Mismatches here produce empty result sets, which then look identical to "no preview available" and are very tedious to debug.
1.3 Flag icons
Identify any boolean flag columns the game needs (Magic: Foil, Signed, Altered; Pokemon: Holo, 1. Edition, Signed, Altered). For each one that doesn't already exist, plan an SVG glyph. SVG art with a single fillable path works best — see ui_wx/src/SvgIcons.cpp for the established style. Re-use existing glyphs across games where the meaning is identical (Signed and Altered are shared between Magic and Pokemon).
1.4 Domain shape decision
Decide whether the new game can re-use an existing card type or needs its own. Re-use is allowed when every field has identical semantics; in practice, every game we have shipped has needed its own type because at least one flag or extra column differs (e.g. Pokemon adds setNo, holo, firstEdition).
If you create a new type, freeze the JSON layout now. The root AGENTS.md rule is unambiguous: JSON layout must stay byte-for-byte stable once you ship. Pick names that match any pre-existing on-disk format (this app may inherit data from a previous tool), and decide which C++ field names need a JSON alias (the canonical example: the C++ field signed_ maps to the JSON key "signed" because signed is a C++ keyword).
2. Core: domain types and the Game enum
Everything below this point assumes you have already gathered the resources from §1.
2.1 Extend the Game enum
Edit core/include/ccm/domain/Enums.hpp:
- Add a new enumerator to
enum class Game. - Update the size of
allGames()(std::array<Game, N>).
Edit core/src/domain/Enums.cpp:
- Add a
casetoto_string(Game). - Add a branch to
gameFromString(std::string_view). - Add the new enumerator to the
allGames()constexpr array.
The Game enum is the only place in core/ that hardcodes which games exist. Adding a new entry here is what makes the rest of the registries (SetService, CardPreviewService, AppContext::gameViews, dirNameForGame) accept it.
2.2 Add the card domain type (only if needed)
If you decided in §1.4 that an existing card type fits, skip this section.
Otherwise, create core/include/ccm/domain/<Name>Card.hpp:
- A
struct <Name>Cardwithid,amount,name,set,note,images,language,condition(these seven fields are required — the UI templates assume them) and any game-specific extras. - Forward-declare
to_jsonandfrom_jsonfornlohmann::json. - Add a defaulted
friend bool operator==(const <Name>Card&, const <Name>Card&) = default;so the JSON round-trip test can compare values.
Then create core/src/domain/<Name>Card.cpp:
- Hand-roll
to_jsonandfrom_jsonusingnlohmann::json. Do not useNLOHMANN_DEFINE_TYPE_NON_INTRUSIVE— the explicit form keeps JSON aliases visible and makes future stability bugs easier to catch in code review.
A real example to cargo-cult from is core/src/domain/PokemonCard.cpp. Note how signed (the JSON key) maps to signed_ (the C++ field), and how every JSON key is spelled out. If your game uses a printed collector number, follow Pokemon's lead and store it as setNo (string) so the format "4/102" can survive a round-trip even when the API only consumes "4".
2.3 Update the JSON round-trip test
Open tests/domain_json_tests.cpp. Add a TEST_CASE that:
- Constructs a
<Name>Cardwith every field non-default (includingset.id,set.name,set.releaseDate). - Serializes it to
nlohmann::json. - Asserts the JSON contains the expected keys with the expected literal spellings (in particular, any C++/JSON aliases like
"signed"). - Round-trips it back into a
<Name>CardandCHECK(roundTripped == original).
This is the byte-for-byte stability gate. If you skip it, the alias bugs only surface in production after you ship and someone's collection.json fails to parse.
3. Core: set source and (optional) card preview source
3.1 <Name>SetSource
Mirrors core/include/ccm/games/pokemon/PokemonSetSource.hpp and the matching .cpp. Put your files at:
core/include/ccm/games/<name>/<Name>SetSource.hppcore/src/games/<name>/<Name>SetSource.cpp
The header should declare:
class <Name>SetSource final : public ISetSourcestatic constexpr const char* kEndpoint = "<your full HTTPS URL>";explicit <Name>SetSource(IHttpClient& http);Result<std::vector<Set>> fetchAll() override;static Result<std::vector<Set>> parseResponse(const std::string& body);
The static parseResponse is mandatory. It is the seam you unit-test (no HTTP, no fakes — just a string in, a Result out). fetchAll() is a thin wrapper that calls http_.get(kEndpoint) and forwards to parseResponse on success.
In parseResponse:
- Parse the body with
nlohmann::json::parse(body)inside atry/catch (const std::exception&)block. Throwing across the port boundary is forbidden; catch and returnResult<std::vector<Set>>::err(...)with a useful message. - Walk the response, mapping each entry to a
Set { id, name, releaseDate }. Rewrite the release date toYYYY/MM/DDif the API uses a different format. std::sortascending by release date.- Return
Result<std::vector<Set>>::ok(std::move(out)).
A note on quirky APIs: some endpoints return a top-level array, some wrap it in { "data": [...] }, and some put it under a different key. The two reference implementations diverge on exactly this: Magic walks the Scryfall-shaped response, Pokemon walks data[]. Do whatever your API requires; it is fine for parseResponse to be game-specific.
3.2 <Name>CardPreviewSource (optional)
Skip this section if the game has no remote preview API.
Mirror core/include/ccm/games/pokemon/PokemonCardPreviewSource.hpp. The header should declare:
class <Name>CardPreviewSource final : public ICardPreviewSourceexplicit <Name>CardPreviewSource(IHttpClient& http);Result<std::string> fetchImageUrl(std::string_view name, std::string_view setId, std::string_view setNo) override;static std::string buildSearchUrl(std::string_view name, std::string_view setId, std::string_view setNo);static Result<std::string> parseResponse(const std::string& body);
Both buildSearchUrl and parseResponse are static and pure on purpose: every URL-encoding and JSON-shape rule is testable without HTTP. Common edge cases your tests must cover:
- Names with spaces, punctuation, or non-ASCII characters (percent-encoding correctness).
- An empty
setId(don't append theset.id:clause). - An empty
setNo, and asetNothat needs normalization (strip everything after/, strip leading zeros, etc.). - Response with the preferred image variant present.
- Response with only the fallback image variant present.
- Empty
data[]array. - Malformed JSON (parse error path).
fetchImageUrl is a thin wrapper: build URL → http_.get(url) → parseResponse(body).
3.3 <Name>GameModule
Wire the two sources together. Create:
core/include/ccm/games/<name>/<Name>GameModule.hppcore/src/games/<name>/<Name>GameModule.cpp
Header:
#pragma once
#include "ccm/games/IGameModule.hpp"
#include "ccm/games/<name>/<Name>CardPreviewSource.hpp" // omit if no preview
#include "ccm/games/<name>/<Name>SetSource.hpp"
namespace ccm {
class <Name>GameModule final : public IGameModule {
public:
explicit <Name>GameModule(IHttpClient& http);
[[nodiscard]] Game id() const noexcept override { return Game::<Name>; }
[[nodiscard]] std::string dirName() const override { return "<name>"; }
[[nodiscard]] std::string displayName() const override { return "<Display>"; }
ISetSource& setSource() override { return setSource_; }
// Omit the override below if the game has no remote preview API.
ICardPreviewSource* cardPreviewSource() noexcept override { return &previewSource_; }
private:
<Name>SetSource setSource_;
<Name>CardPreviewSource previewSource_;
};
} // namespace ccm
Two subtle requirements:
dirName()returns the on-disk directory name. Once you ship, this is forever — changing it later orphans every existing user's data. Pick something lowercase, ASCII, and short.cardPreviewSource()defaults tonullptrinIGameModule. Only override it if you actually have a preview source. ReturningnullptrmakesCardPreviewService::registerModule(*module)a silent no-op for that game; the UI gracefully falls back to "no preview available".
The .cpp is one line of constructor body — see core/src/games/pokemon/PokemonGameModule.cpp.
3.4 Register the new sources in core/CMakeLists.txt
There is no glob. Add the new .cpp files (set source, card preview source, game module, and the card domain .cpp if you added one) to the add_library(ccm_core ...) argument list. Configure the build before moving on; this catches any missing headers immediately.
4. Core: tests for the new sources
Writing the tests now, before the UI work, makes the next sections noticeably faster — every later UI debugging session benefits from already knowing the parser and the URL builder are correct.
4.1 <name>_set_source_tests.cpp
Create tests/<name>_set_source_tests.cpp modeled on tests/pokemon_set_source_tests.cpp. The required cases are:
parseResponsehappy path with two or three sets, including the date rewrite if your API uses a non-YYYY/MM/DDformat.parseResponsealready-sorted output (input out of order, output ascending by release date).parseResponseempty array → emptyResult::ok(...).parseResponsemissing top-level container →Result::err(...).parseResponsemalformed JSON →Result::err(...).fetchAllhappy path through aFixedHttpClientfake (in-file, ~10 lines — see the existing tests). Assert thelastUrlequalskEndpoint.fetchAllHTTP error →Result::err(...)propagation.
4.2 <name>_card_preview_source_tests.cpp (if you have a preview source)
Create tests/<name>_card_preview_source_tests.cpp modeled on tests/pokemon_card_preview_source_tests.cpp. The required cases are:
buildSearchUrlpercent-encodes names with spaces and reserved characters.buildSearchUrlincludes / omits thesetIdclause based on whethersetIdis empty.buildSearchUrlincludes / omits / normalizessetNoaccording to your normalization rules.parseResponsereturns the preferred image variant.parseResponsefalls back to the secondary variant when the primary is absent.parseResponseerrors on emptydata[], missingimages, malformed JSON.fetchImageUrlround-trips throughFixedHttpClientand asserts the URL was percent-encoded as expected.fetchImageUrlpropagates HTTP errors.
4.3 Register the new test files
Add <name>_set_source_tests.cpp (and <name>_card_preview_source_tests.cpp if applicable) to tests/CMakeLists.txt add_executable(ccm_core_tests ...). Build and run ctest --test-dir build --output-on-failure. Do not continue until these pass.
4.4 Sorter / filter tests
If you introduced a new card type in §2.2, add cases to tests/card_sorter_tests.cpp and tests/card_filter_tests.cpp covering the new sort columns and filter columns introduced by your domain type. The boolean-flag exclusion rule (filter only checks string/number columns; flag columns are skipped) must be covered explicitly so future refactors don't quietly break it.
4.5 Set-service routing test
Append a case to tests/set_service_tests.cpp that registers your new module alongside Magic and verifies that updateSets(Game::<Name>) does not perturb cached data for the other game. Routing isolation is what SetService exists for; one test per game keeps it honest.
5. UI: derive from the three base templates
The UI layer is built on three header-only class templates that own all the wxWidgets-specific machinery. Each has a small, well-documented set of virtual hooks; deriving for a new game is a hook-implementation exercise, not a wxWidgets exercise. Read ui_wx/include/ccm/ui/Base*.hpp once before starting.
5.1 SVG icons
If your game introduces flag columns whose glyphs do not already exist in ui_wx/include/ccm/ui/SvgIcons.hpp, add them now:
- Declare each new icon as
extern const char* const kSvg<Name>;in the header. - Define them in
ui_wx/src/SvgIcons.cpp. Keep the@FILL@placeholder so the rasterizer can substitute the active palette text color at draw time. Do not bake a color into the SVG — that breaks dark mode. - Re-use existing glyphs (
kSvgSigned,kSvgAltered,kSvgFoil,kSvgHolo) when the meaning matches.
5.2 Sort and filter helpers
Add a <Name>SortColumn enum to core/include/ccm/services/CardSorter.hpp, plus the corresponding sort<Name>Cards(std::vector<<Name>Card>&, <Name>SortColumn, bool) declaration. Implement it in core/src/services/CardSorter.cpp mirroring the existing per-column dispatch (each enum entry maps to a comparator).
Add [[nodiscard]] bool matches<Name>Filter(const <Name>Card&, std::string_view) to core/include/ccm/services/CardFilter.hpp and implement it in core/src/services/CardFilter.cpp. Walk the same value-key columns that the list panel will display, lowercase both sides, and short-circuit on any substring hit. Boolean flag columns are intentionally excluded — only string/number columns participate in filtering.
These functions are also the targets of §4.4's tests; you'll have already written the tests if you followed the order.
5.3 <Name>CardListPanel
Create:
ui_wx/include/ccm/ui/<Name>CardListPanel.hppui_wx/src/<Name>CardListPanel.cpp
The header declares a final class deriving from BaseCardListPanel<<Name>Card, <Name>SortColumn>, with overrides for:
declareTextColumns()— return astd::vector<TextColumnSpec>of{label, width, format, optional<sortColumn>}. The order is left-to-right on screen. The last entry must be theNotecolumn; the base reserves it.declareIconColumns()— return astd::vector<IconColumnSpec>of{svg, width, optional<sortColumn>}. These render between the leading text columns and the Note column.renderTextCell(card, idx)— return the cell text for theidx-th text column. The base passes index0..textCols-1for the leading rows andtextCols-1for the Note row, so you usuallyswitch (idx).isIconColumnSet(card, idx)— return whether theidx-th icon column should render its glyph for this row. Index0is the first icon column declared indeclareIconColumns().sortBy(column, ascending)— callsort<Name>Cards(mutableCards(), column, ascending). UsemutableCards(), notcards(), becausesortBywrites through the underlying vector.matchesFilter(card, filter)— callmatches<Name>Filter(card, filter).
In the constructor body, call buildLayout() (from BaseCardListPanel) so the base wires up the wxListCtrl, the themed header row, and the image lists.
The reference implementation at ui_wx/src/PokemonCardListPanel.cpp is ~75 lines including the icon-column declaration and the switch-based renderer. Yours should land in the same ballpark.
5.4 <Name>SelectedCardPanel
Create:
ui_wx/include/ccm/ui/<Name>SelectedCardPanel.hppui_wx/src/<Name>SelectedCardPanel.cpp
Inside the .cpp, define an unnamed-namespace enum <Name>DetailKey : int { ... }; with one entry per detail row and one per flag column. Keep these names local — they're only used between this file's hook overrides.
Override:
declareDetailRows()— return astd::vector<DetailRowSpec>of{label, key, emptyLabel}. The first row should typically beName; itsemptyLabelis what the panel shows when no card is selected.declareFlagIcons()— return astd::vector<FlagIconSpec>of{svg, tooltip, key}.detailValueFor(card, key)—switch (key)and return the appropriate string. Also handlekNoteKey(defined inBaseSelectedCardPanelas-1); the base callsdetailValueFor(card, kNoteKey)to populate the bottom Note row.isFlagSet(card, key)—switch (key)and return the matching boolean.previewKey(card)— returnstd::tuple<std::string, std::string, std::string>of(name, setId, setNo). Use emptysetNofor games whose preview API does not need a collector number.gameId()— returnGame::<Name>.
In the constructor body, call buildLayout() so the base wires up the preview area, detail grid, flag strip, and image list.
The reference implementation is ui_wx/src/PokemonSelectedCardPanel.cpp.
5.5 <Name>CardEditDialog
Create:
ui_wx/include/ccm/ui/<Name>CardEditDialog.hppui_wx/src/<Name>CardEditDialog.cpp
Derive from BaseCardEditDialog<<Name>Card>. Override:
buildFlagsRow(wxBoxSizer* flagsBox)— create yourwxCheckBoxes andflagsBox->Add(...)them. The base owns the surroundingFlagslabel and sizer.appendExtraRows(wxFlexGridSizer* grid)— only if your game has fields beyond the standard set. Use the inheritedappendRow(grid, label, ctrl)helper. (Pokemon adds aSet #text input here.)readExtraFromCard()— copy fields fromconstCard()into your widgets.writeExtraToCard()— copy values from your widgets back intomutableCard().updateMenuName()— return"Update <Display>". This is what the dialog's "no sets cached" hint shows the user.
In the constructor:
- Pass through to the
BaseCardEditDialogconstructor with the dialog title (e.g."Add <Display> Card"or"Edit <Display> Card"based onEditMode),imageService,setService,mode,std::move(initial),Game::<Name>, and the optionalpreloadedSetspointer. - Call
buildAndPopulate()(from the base) to build the form, populate the choices, and callreadExtraFromCard().
The reference implementation is ui_wx/src/PokemonCardEditDialog.cpp.
5.6 <Name>GameView
This is the polymorphic glue between the new game's panels and the rest of the app. Create:
ui_wx/include/ccm/ui/<Name>GameView.hppui_wx/src/<Name>GameView.cpp
Derive from IGameView. The constructor takes references to the shared services (ConfigService, SetService, ImageService, CardPreviewService), the typed CollectionService<<Name>Card>&, and the IGameModule&. Members:
<Name>CardListPanel* listPanel_{nullptr};<Name>SelectedCardPanel* selectedPanel_{nullptr};std::vector<Set> setsCache_;— populated lazily bysetsForDialog()so each Add/Edit open does not re-read fromSetService.
Implement the virtuals:
gameId()returnsGame::<Name>.displayName()returns"<Display>".listPanel(parent)— lazily allocates the list panel as a child ofparent; on first allocation, alsoBind(EVT_CARD_SELECTED, ...)to pushlistPanel_->selected()intoselectedPanel_. The binding must live here, in the typedIGameView, not inMainFrame—MainFrameonly seesIGameViewand never<Name>Card.selectedPanel(parent)— lazily allocates the selected panel.refreshCollection()— callscollection_.list(Game::<Name>), handles errors withwxMessageBox, and pushes the new vector intolistPanel_->setCards(...). Also re-syncs the selected panel.onAddCard(parent),onEditCard(parent),onDeleteCard(parent)— open the typed<Name>CardEditDialog(or pop a confirm dialog for delete), call the typedCollectionServiceto commit, and refresh on success.onUpdateSets(parent)— callssets_.updateSets(Game::<Name>), refreshessetsCache_, returns a status string.setFilter(filter)— forwards tolistPanel_->setFilter(filter).applyTheme(palette)— forwards to both panels'applyTheme.updateSetsMenuLabel()— returns"Update <Display>". This is what theSetsmenu entry shows.
The reference implementation is ui_wx/src/PokemonGameView.cpp. It's about 160 lines and is the same shape for every game.
5.7 Register the new UI sources
Add all new UI .cpp files to ui_wx/CMakeLists.txt:
<Name>CardListPanel.cpp<Name>SelectedCardPanel.cpp<Name>CardEditDialog.cpp<Name>GameView.cpp
There is no glob.
6. Composition root
Edit app/main.cpp to wire the new game in. Read app/AGENTS.md first — destruction-order rules apply.
6.1 New members
Add std::unique_ptr<...> members to CcmApp. Order matters (destruction is reverse — deps before dependents):
std::unique_ptr<ccm::<Name>GameModule> <name>Mod_;
std::unique_ptr<ccm::JsonCollectionRepository<ccm::<Name>Card>> <name>Repo_;
std::unique_ptr<ccm::CollectionService<ccm::<Name>Card>> <name>CollSvc_;
std::unique_ptr<ccm::ui::<Name>GameView> <name>View_;
Place them next to the existing Magic/Pokemon members in the matching position — game module after http_, repo after the module, collection service after the repo and the image store, view at the end before ctx_.
6.2 New constructions in OnInit()
<name>Mod_ = std::make_unique<ccm::<Name>GameModule>(*http_);
<name>Repo_ = std::make_unique<ccm::JsonCollectionRepository<ccm::<Name>Card>>(
*fs_, *config_, &dirNameForGame);
<name>CollSvc_ = std::make_unique<ccm::CollectionService<ccm::<Name>Card>>(
*<name>Repo_, *imgStore_);
setSvc_->registerModule(<name>Mod_.get());
previewSvc_->registerModule(*<name>Mod_); // no-op when the module has no preview source
<name>View_ = std::make_unique<ccm::ui::<Name>GameView>(
*config_, *<name>CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *<name>Mod_);
6.3 dirNameForGame
Add a case ccm::Game::<Name>: return "<name>"; arm. The string must match <Name>GameModule::dirName().
6.4 AppContext
AppContext (ui_wx/include/ccm/ui/AppContext.hpp) currently holds explicit references to magicModule and pokemonModule. Add an <Name>Module reference field — keep the alphabetical / canonical order — and pass *<name>Mod_ for it in the AppContext{...} brace-init in OnInit(). Also append <name>View_.get() to the gameViews vector.
The Game and Sets menus in MainFrame are built dynamically from gameViews, so once the new view is in the vector its menu entries (the Game > <Display> radio item and the Sets > Update <Display> action) appear automatically.
7. AGENTS.md and tests housekeeping
After all the above compiles and tests pass:
AGENTS.md(root) — the "After adding a new game module you must" required follow-up should already cover your work; read it and confirm. If you added a new domain type, the matchingtests/domain_json_tests.cppround-trip test is required (percore/AGENTS.md).core/AGENTS.md— update only if your game required a new core seam shape (a new port, a new service, a new shared helper). Describing the new game itself is not required; the doc is meant to stay game-agnostic.ui_wx/AGENTS.md— same: update only if you needed a new template hook or had to teach theBase*templates a new behaviour. Describing the new game's panel set is not required.app/AGENTS.md— confirm the "Composition root is the only place" allowlist still mentions the concrete adapter types. If you added new ones (a new<Name>GameView,<Name>GameModule), append them.tests/AGENTS.md— add the new test file names to the file map and the "Required follow-ups" list.- This file (
docs/adding-a-new-game.md) — only edit when the procedure itself changes (new template hook, new service registration, new composition-root step). Do not insert your specific game's quirks here; capture those in code comments next to the relevant overrides.
8. Verification checklist
Run, in order, from the workspace root. Do not skip any step.
- Configure:
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release(or your usual generator). Configuration must succeed without warnings about a missing source file. - Build:
cmake --build build --parallel. Must succeed cleanly. Pay close attention to template-instantiation errors — those usually indicate one of theBase*hooks is missing or wrongly-typed. - Tests:
ctest --test-dir build --output-on-failure. Every existing test plus the new<name>_set_source_tests,<name>_card_preview_source_tests, the extendeddomain_json_tests, the extendedcard_sorter_tests, the extendedcard_filter_tests, and the extendedset_service_testsmust pass. - Smoke test: launch
./build/bin/ccm3(or.\build\bin\ccm3.exe). Note: the CMake target isccmbut the executable is renamed toccm3viaset_target_properties(... OUTPUT_NAME ccm3).- The
Gamemenu shows your new game alongside Magic and Pokemon and switching is instantaneous (no panel re-creation cost on subsequent switches). - The
Sets > Update <Display>action fetches sets and reports a count. - With sets cached, opening the new game's Add dialog populates the set picker and the dialog can be dismissed with
OK. - Adding, editing, and deleting a card all round-trip through disk: close and re-open the app and the card persists.
- Selecting a card kicks off a preview fetch (if the game has a preview source) and renders the image; the status line returns to
"Ready"when the preview lands. - The flag-icon strip shows / hides per card depending on which flags are set.
- Theme switching applies to all of the new game's panels (light → dark → light).
- The
9. Common traps
These do not match a single seam in this guide but are worth calling out explicitly.
- Stale set caches. Each
IGameViewcachesstd::vector<Set> setsCache_. AfteronUpdateSetssucceeds, refresh the cache (assign the new vector). The reference implementations do this. signed_/signed. The C++ field issigned_; the JSON key is"signed". This is intentional and must not be changed. The same convention applies to any new field where the natural name collides with a C++ keyword — pick a trailing-underscore C++ name and an unaliased JSON key.- Spacer column index.
BaseCardListPanelreserves index0for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index1. If you ever need to call intowxListCtrldirectly from a derived panel (you should not), remember this. - Preview-fetch threading. The async preview fetch in
BaseSelectedCardPaneluses ashared_ptr<State>+std::atomic alive+std::atomic currentGentriple. Do not capturethisraw in any background work you add to a new game's selected panel; copy that pattern verbatim. - First-paint perf.
MainFramedefers initial collection load withCallAfter(...)andBaseCardListPaneldefers the initial selection the same way. Don't move that work back into the constructor for "convenience" — it makes startup visibly slower. previewKeyfor games withoutsetNo. If your preview API only needs(name, setId), return an empty string for the third tuple element. The base will pass""through to the source, which is exactly whatMagicCardPreviewSourceis built to handle.- Filter exclusion. The filter intentionally ignores boolean-flag columns. If you find yourself wanting
signed:truestyle filters, that is a future feature, not a fix; don't smuggle it intomatches<Name>Filterwithout a design discussion. - Theming dialogs. Always
applyThemeToWindowTree(&dlg, palette, theme)beforeShowModal()for any dialog you open. The reference<Name>GameView::onAddCard/onEditCardshow the canonical pattern.
10. Where to read first when something does not work
- The new game compiles but its menu entries do not appear — check that the view was appended to
AppContext::gameViewsinapp/main.cpp. - The list panel is empty even after
Sets > Update <Display>succeeds — checkdirNameForGame. The repository writes to<dataStorage>/<dirName>/collection.json, and a typo here makes the load silently return an empty list on next launch. - The filter input does nothing on the new game — check that
<Name>GameView::setFilter(...)forwards to the list panel and thatmatches<Name>Filteractually evaluates the active filter substring (the empty filter must match every row). - The Add dialog shows
(no sets cached - use Sets > Update <Display>)even after a successful update —setsCache_was not refreshed inonUpdateSets. The reference views assignout.value()into the cache. - Preview never resolves — first add a unit test that hits
parseResponsewith a real captured response body. If that passes, log the URLIHttpClient::getis called with and try it in a browser orcurl. Most "broken preview" bugs are URL encoding or a wrong shape inbuildSearchUrl. - Sort works but its arrow indicator is wrong — column
0is the spacer, so the visual column index sort key cares about is one higher than you might expect. The base handles this; if it goes wrong, check that yourTextColumnSpec/IconColumnSpecorder matchesrenderTextCell/isIconColumnSetindexing exactly. - Tests pass but the app crashes on shutdown — destruction order in
CcmAppis wrong. Move<name>View_so it is declared after<name>CollSvc_,setSvc_,imgSvc_,previewSvc_,<name>Mod_— the view must be torn down before any of its referenced services.