Files
Card-Collection-Manager-3-s…/docs/adding-a-new-game.md
T
Sebastian Dine d3b4762b76 Minor: Additional functions (#25)
* several functions. fixes #1 and #2

* multi selection functionality
2026-08-03 08:35:38 +02:00

37 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::Url does not re-encode). The Magic/Pokemon implementations have a private urlEncode helper you can copy.
  • Collector-number normalization. Pokemon stores 4/102 but the API only accepts 4. Whichever convention your domain type uses, normalize it inside buildSearchUrl so 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.
  • Name-matching strictness. Some APIs reject strict exact-name parameters for real-world card spelling variants (e.g. hyphenation/punctuation differences). If your provider supports fuzzy-name search, prefer that for the first request, then disambiguate in parseResponse using set/print metadata.
  • 400 fallback strategy. If adding optional set filters can produce request validation errors (HTTP 400), add a second request path that retries without the risky filter and keeps disambiguation local in parseResponse.
  • Image variant priority. If the provider returns both cropped art and full-card images, prefer the full-card URL for selected-card preview. Use cropped variants only as fallback.

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 case to to_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>Card with id, 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_json and from_json for nlohmann::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_json and from_json using nlohmann::json. Do not use NLOHMANN_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:

  1. Constructs a <Name>Card with every field non-default (including set.id, set.name, set.releaseDate).
  2. Serializes it to nlohmann::json.
  3. Asserts the JSON contains the expected keys with the expected literal spellings (in particular, any C++/JSON aliases like "signed").
  4. Round-trips it back into a <Name>Card and CHECK(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.hpp
  • core/src/games/<name>/<Name>SetSource.cpp

The header should declare:

  • class <Name>SetSource final : public ISetSource
  • static 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:

  1. Parse the body with nlohmann::json::parse(body) inside a try/catch (const std::exception&) block. Throwing across the port boundary is forbidden; catch and return Result<std::vector<Set>>::err(...) with a useful message.
  2. Walk the response, mapping each entry to a Set { id, name, releaseDate }. Rewrite the release date to YYYY/MM/DD if the API uses a different format.
  3. std::sort ascending by release date.
  4. 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 ICardPreviewSource
  • explicit <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);

If your game benefits from edit-dialog metadata helpers (for example auto-detecting collector number / rarity), you can opt in to ICardPreviewSource::detectFirstPrint(...) and route it via CardPreviewService::detectFirstPrint(...). If you need to enumerate multiple upstream printings (for example Yu-Gi-Oh! “Next” cycling between alternate set_code or set_rarity values), also override ICardPreviewSource::detectPrintVariants(...) and expose it through CardPreviewService::detectPrintVariants(...). For bidirectional identify (set + name → setNo, or set + setNo → name), also override detectBySetNo(...) / detectVariantsBySetNo(setId, setNo) and wire them through CardPreviewService. In the edit dialog, bind Set # wxEVT_TEXT to markSetNoLookupEdited() and branch Auto detect with shouldDetectBySetNo(...) so the last typed field wins when both are filled (see existing Yu-Gi-Oh! / Pokémon / Digi-Battle / Bandai dialogs). AutoDetectedPrint carries setNo + rarity for every game; optional name / setId / setName / language fields stay empty when unused. Keep all of these optional per game — default behavior should remain an explicit unsupported error.

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 the set.id: clause).
  • An empty setNo, and a setNo that 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.hpp
  • core/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. Pokemon exception: a unified Game menu entry may keep one dirName (pokemon) for collection/images and disambiguate region set caches by filename (sets-west.json / sets-asia.json) instead of a second data subdirectory.
  • cardPreviewSource() defaults to nullptr in IGameModule. Only override it if you actually have a preview source. Returning nullptr makes CardPreviewService::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:

  • parseResponse happy path with two or three sets, including the date rewrite if your API uses a non-YYYY/MM/DD format.
  • parseResponse already-sorted output (input out of order, output ascending by release date).
  • parseResponse empty array → empty Result::ok(...).
  • parseResponse missing top-level container → Result::err(...).
  • parseResponse malformed JSON → Result::err(...).
  • fetchAll happy path through a FixedHttpClient fake (in-file, ~10 lines — see the existing tests). Assert the lastUrl equals kEndpoint.
  • fetchAll HTTP 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:

  • buildSearchUrl percent-encodes names with spaces and reserved characters.
  • buildSearchUrl includes / omits the setId clause based on whether setId is empty.
  • buildSearchUrl includes / omits / normalizes setNo according to your normalization rules.
  • parseResponse returns the preferred image variant.
  • parseResponse falls back to the secondary variant when the primary is absent.
  • parseResponse errors on empty data[], missing images, malformed JSON.
  • fetchImageUrl round-trips through FixedHttpClient and asserts the URL was percent-encoded as expected.
  • fetchImageUrl propagates 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.hpp
  • ui_wx/src/<Name>CardListPanel.cpp

The header declares a final class deriving from BaseCardListPanel<<Name>Card, <Name>SortColumn>, with overrides for:

  • declareTextColumns() — return a std::vector<TextColumnSpec> of {label, width, format, optional<sortColumn>}. The order is left-to-right on screen. The last entry must be the Note column; the base reserves it.
  • declareIconColumns() — return a std::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 the idx-th text column. The base passes index 0..textCols-1 for the leading rows and textCols-1 for the Note row, so you usually switch (idx).
  • isIconColumnSet(card, idx) — return whether the idx-th icon column should render its glyph for this row. Index 0 is the first icon column declared in declareIconColumns().
  • sortBy(column, ascending) — call sort<Name>Cards(mutableCards(), column, ascending). Use mutableCards(), not cards(), because sortBy writes through the underlying vector.
  • matchesFilter(card, filter) — call matches<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.hpp
  • ui_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 a std::vector<DetailRowSpec> of {label, key, emptyLabel}. The first row should typically be Name; its emptyLabel is what the panel shows when no card is selected.
  • declareFlagIcons() — return a std::vector<FlagIconSpec> of {svg, tooltip, key}.
  • detailValueFor(card, key)switch (key) and return the appropriate string. Also handle kNoteKey (defined in BaseSelectedCardPanel as -1); the base calls detailValueFor(card, kNoteKey) to populate the bottom Note row.
  • isFlagSet(card, key)switch (key) and return the matching boolean.
  • previewKey(card) — return std::tuple<std::string, std::string, std::string> of (name, setId, setNo). Use empty setNo for games whose preview API does not need a collector number.
  • gameId() — return Game::<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.hpp
  • ui_wx/src/<Name>CardEditDialog.cpp

Derive from BaseCardEditDialog<<Name>Card>. Override:

  • buildFlagsRow(wxBoxSizer* flagsBox) — create your wxCheckBoxes and flagsBox->Add(...) them. The base owns the surrounding Flags label and sizer.
  • appendExtraRows(wxFlexGridSizer* grid) — only if your game has fields beyond the standard set. Use the inherited appendRow(grid, label, ctrl) helper. (Pokemon adds a Set # text input here.)
  • readExtraFromCard() — copy fields from constCard() into your widgets.
  • writeExtraToCard() — copy values from your widgets back into mutableCard().
  • updateMenuName() — return "Update <Display>". This is what the dialog's "no sets cached" hint shows the user.
  • validateExtraFields() — optional; called from OK after name/set checks. Return false to block save (show your own themed dialog). Yu-Gi-Oh! (Bandai) requires a non-empty set number here.

Optional BaseCardEditDialog extension points (defaults keep a single read-only set combo in the Set row):

  • customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) — the base wraps the combo in a host panel and calls this so a game can add adjacent controls (Yu-Gi-Oh! adds a Set code toggle, a text field, and Auto detect beside the combo). The default implementation only does row.Add(combo, 1, wxEXPAND).
  • applySetSelectionByIndex(std::size_t index) (non-virtual helper on the base) — selects a row in the combo and assigns card_.set from availableSets()[index] when the combo is enabled.
  • onSetSelectionApplied() — called after applySetSelectionByIndex completes; default no-op. Yu-Gi-Oh! overrides it to clear cached print-variant metadata and reschedule the same follow-up as a manual wxEVT_COMBOBOX set change.

In the constructor:

  1. Pass through to the BaseCardEditDialog constructor with the dialog title (e.g. "Add <Display> Card" or "Edit <Display> Card" based on EditMode), imageService, setService, mode, std::move(initial), Game::<Name>, and the optional preloadedSets pointer.
  2. Call buildAndPopulate() (from the base) to build the form, populate the choices, and call readExtraFromCard().

The reference implementation is ui_wx/src/PokemonCardEditDialog.cpp.

When an extra field is from a controlled vocabulary (rarity tiers, print types, etc.), prefer a dropdown (wxChoice) over free text to keep list/filter values consistent and reduce user-input variants.

If the external API expects a full print code (e.g. LOB-001) but users mostly edit only the numeric suffix, expose a numeric input plus a read-only derived preview (for example (LOB-001)) and compose/decompose the stored full value in readExtraFromCard() / writeExtraToCard().

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.hpp
  • ui_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 by setsForDialog() so each Add/Edit open does not re-read from SetService.

Implement the virtuals:

  • gameId() returns Game::<Name>.
  • displayName() returns "<Display>".
  • listPanel(parent) — lazily allocates the list panel as a child of parent; on first allocation, also Bind(EVT_CARD_SELECTED, ...) to push listPanel_->selected() into selectedPanel_, and Bind(EVT_CARD_ACTIVATED, ...) so a double-click (or Enter on the focused row) calls onEditCard with wxGetTopLevelParent(listPanel_) as the modal owner when available. Activation is raised from BaseCardListPanel via CallAfter so Edit's ShowModal does not run inside the list notify path. The binding must live here, in the typed IGameView, not in MainFrameMainFrame only sees IGameView and never <Name>Card.
  • selectedPanel(parent) — lazily allocates the selected panel.
  • refreshCollection(selectId = nullopt) — calls collection_.list(Game::<Name>), handles errors with wxMessageBox, and pushes the new vector into listPanel_->setCards(..., selectId) (preserves the selected card by id when still present; when selectId is set — e.g. after Add — selects that card instead). 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 typed CollectionService to commit, and refresh on success. After a successful Add, call refreshCollection(added.value()) so the new card stays selected. For Add/Edit, follow the built-in game views: if cardEditModalIsActive() from ccm/ui/CardEditModalGuard.hpp, show a themed info dialog and return; otherwise wrap ShowModal() with CardEditModalGuard so a second Add/Edit cannot stack while one card dialog is already open.
  • onUpdateSets(parent) — calls sets_.updateSets(Game::<Name>), refreshes setsCache_, returns a status string.
  • setFilter(filter) — forwards to listPanel_->setFilter(filter).
  • nudgeSelection(delta) — forwards to listPanel_->nudgeSelection(delta) so Up/Down from the filter text box can move the table selection without stealing caret focus. Bind wxEVT_KEY_DOWN on the filter (MainFrame toolbar or in-game toolbar) for WXK_UP / WXK_DOWN accordingly.
  • applyTheme(palette) — forwards to both panels' applyTheme.
  • updateSetsMenuLabel() — returns "Update <Display>". This is what the Sets menu 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:

  1. 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 matching tests/domain_json_tests.cpp round-trip test is required (per core/AGENTS.md).
  2. 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.
  3. ui_wx/AGENTS.md — same: update only if you needed a new template hook or had to teach the Base* templates a new behaviour. Describing the new game's panel set is not required.
  4. 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.
  5. tests/AGENTS.md — add the new test file names to the file map and the "Required follow-ups" list.
  6. 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.

  1. 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.
  2. Build: cmake --build build --parallel. Must succeed cleanly. Pay close attention to template-instantiation errors — those usually indicate one of the Base* hooks is missing or wrongly-typed.
  3. Tests: ctest --test-dir build --output-on-failure. Every existing test plus the new <name>_set_source_tests, <name>_card_preview_source_tests, the extended domain_json_tests, the extended card_sorter_tests, the extended card_filter_tests, and the extended set_service_tests must pass.
  4. Smoke test: launch ./build/bin/ccm3 (or .\build\bin\ccm3.exe). Note: the CMake target is ccm but the executable is renamed to ccm3 via set_target_properties(... OUTPUT_NAME ccm3).
    • The Game menu 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).

9. Common traps

These do not match a single seam in this guide but are worth calling out explicitly.

  • Stale set caches. Each IGameView caches std::vector<Set> setsCache_. After onUpdateSets succeeds, refresh the cache (assign the new vector). The reference implementations do this.
  • Set ordering drift. Keep set lists sorted by release date not only in <Name>SetSource::parseResponse, but also at UI consumption points (preloaded/cached vectors passed to BaseCardEditDialog). Older on-disk cache data or future parser changes can otherwise surface unsorted set pickers.
  • Auto-detect feature scope. Treat print auto-detect as a per-game capability. Do not assume every game supports it; gate UI affordances behind game-specific dialog logic and source opt-in.
  • signed_ / signed. The C++ field is signed_; 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. BaseCardListPanel reserves index 0 for a hidden zero-width spacer column (MSW comctl32 image-list gutter workaround). Real columns start at index 1. If you ever need to call into wxListCtrl directly from a derived panel (you should not), remember this.
  • Preview-fetch threading. The async preview fetch in BaseSelectedCardPanel uses a shared_ptr<State> + std::atomic alive + std::atomic currentGen triple. Do not capture this raw in any background work you add to a new game's selected panel; copy that pattern verbatim.
  • First-paint perf. MainFrame defers initial collection load with CallAfter(...) and BaseCardListPanel defers the initial selection the same way. Don't move that work back into the constructor for "convenience" — it makes startup visibly slower.
  • previewKey for games without setNo. 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 what MagicCardPreviewSource is built to handle.
  • Filter exclusion. The filter intentionally ignores boolean-flag columns. If you find yourself wanting signed:true style filters, that is a future feature, not a fix; don't smuggle it into matches<Name>Filter without a design discussion.
  • Theming dialogs. Always applyThemeToWindowTree(&dlg, palette, theme) before ShowModal() for any dialog you open. The reference <Name>GameView::onAddCard / onEditCard show 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::gameViews in app/main.cpp.
  • The list panel is empty even after Sets > Update <Display> succeeds — check dirNameForGame. 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 that matches<Name>Filter actually 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 in onUpdateSets. The reference views assign out.value() into the cache.
  • Preview never resolves — first add a unit test that hits parseResponse with a real captured response body. If that passes, log the URL IHttpClient::get is called with and try it in a browser or curl. Most "broken preview" bugs are URL encoding or a wrong shape in buildSearchUrl.
  • Sort works but its arrow indicator is wrong — column 0 is 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 your TextColumnSpec/IconColumnSpec order matches renderTextCell / isIconColumnSet indexing exactly.
  • Tests pass but the app crashes on shutdown — destruction order in CcmApp is 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.