Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4c29ae9..8def5fc 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -30,7 +30,8 @@ add_executable(
src/email_address.cpp
src/email_sender_file.cpp
src/email_sender_mailjet.cpp
- src/game_registry.cpp
+ src/game_field.cpp
+ src/game_service.cpp
src/image_processor.cpp
src/markdown_renderer.cpp
src/main.cpp
@@ -149,6 +150,61 @@ if(CARD_COLLECTION_BUILD_TESTS)
)
gtest_discover_tests(mvp_primitives_test)
+ add_executable(
+ game_field_test
+ src/game_field.cpp
+ tests/game_field_test.cpp
+ )
+ target_compile_features(game_field_test PRIVATE cxx_std_23)
+ set_target_properties(game_field_test PROPERTIES CXX_EXTENSIONS OFF)
+ target_include_directories(
+ game_field_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ game_field_test
+ PRIVATE
+ GTest::gtest_main
+ mw::mw
+ )
+ gtest_discover_tests(game_field_test)
+
+ add_executable(
+ game_service_test
+ src/authorization.cpp
+ src/data.cpp
+ src/data_sqlite.cpp
+ src/game_field.cpp
+ src/game_service.cpp
+ src/markdown_renderer.cpp
+ src/startup.cpp
+ tests/game_service_test.cpp
+ )
+ target_compile_features(game_service_test PRIVATE cxx_std_23)
+ set_target_properties(
+ game_service_test
+ PROPERTIES CXX_EXTENSIONS OFF
+ )
+ target_include_directories(
+ game_service_test
+ PRIVATE
+ ${libmw_SOURCE_DIR}/includes
+ src
+ )
+ target_link_libraries(
+ game_service_test
+ PRIVATE
+ GTest::gtest_main
+ MacroDown::MacroDown
+ mw::mw
+ mw::sqlite
+ mw::url
+ spdlog::spdlog
+ )
+ gtest_discover_tests(game_service_test)
+
add_executable(
user_collection_test
src/card_pool.cpp
@@ -156,7 +212,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/collection.cpp
src/data.cpp
src/data_sqlite.cpp
- src/game_registry.cpp
+ src/game_field.cpp
src/startup.cpp
src/username.cpp
src/user_service.cpp
@@ -192,7 +248,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/data.cpp
src/data_sqlite.cpp
src/email_address.cpp
- src/game_registry.cpp
+ src/game_field.cpp
src/secret_token.cpp
src/startup.cpp
tests/authentication_test.cpp
@@ -313,7 +369,8 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/email_address.cpp
src/email_sender_file.cpp
src/email_sender_mailjet.cpp
- src/game_registry.cpp
+ src/game_field.cpp
+ src/game_service.cpp
src/non_secret_random.cpp
src/data_fake.cpp
src/image_processor.cpp
@@ -375,7 +432,8 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/email_address.cpp
src/email_sender_file.cpp
src/email_sender_mailjet.cpp
- src/game_registry.cpp
+ src/game_field.cpp
+ src/game_service.cpp
src/image_processor.cpp
src/markdown_renderer.cpp
src/multipart_reader.cpp
@@ -451,7 +509,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
data_sqlite_test
src/data.cpp
src/data_sqlite.cpp
- src/game_registry.cpp
+ src/game_field.cpp
src/startup.cpp
tests/data_sqlite_test.cpp
)
@@ -480,7 +538,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
src/card_service.cpp
src/data.cpp
src/data_sqlite.cpp
- src/game_registry.cpp
+ src/game_field.cpp
src/image_processor.cpp
src/markdown_renderer.cpp
src/multipart_reader.cpp
@@ -528,5 +586,10 @@ if(CARD_COLLECTION_BUILD_TESTS)
COMMAND ${NODE_EXECUTABLE} --test
${CMAKE_CURRENT_SOURCE_DIR}/tests/card_preview_math_test.js
)
+ add_test(
+ NAME GameFieldFormLogicTest
+ COMMAND ${NODE_EXECUTABLE} --test
+ ${CMAKE_CURRENT_SOURCE_DIR}/tests/game_field_form_logic_test.js
+ )
endif()
endif()
diff --git a/designs/design-2-games.md b/designs/design-2-games.md
index 2159e7f..3df5e15 100644
--- a/designs/design-2-games.md
+++ b/designs/design-2-games.md
@@ -1,6 +1,6 @@
# Database-defined games and custom card fields
-Status: proposed implementation design.
+Status: implemented.
This document replaces the compiled-game architecture in
[design-0-prototype.md](design-0-prototype.md) and the game-registration
@@ -42,14 +42,17 @@ These decisions keep old cards readable and prevent definition edits from
silently deleting or reinterpreting their values. Bulk value conversion,
option retirement, and changing field types are separate future features.
-Going Home becomes an ordinary database game with short name `gh`, display
-name `Going Home`, an empty description, and no custom fields. It receives
-no runtime special treatment.
+Fresh databases contain no games. Going Home may be created through the same
+administrator interface as any other game, using short name `gh` if desired;
+it receives no runtime special treatment.
-Schema migration and existing-data migration are explicitly out of scope.
-Implementation targets a fresh database. No legacy-game adapters, data
-conversion, or compatibility path are required. Definition-editing rules
-still apply to cards created after this feature is installed.
+Migrating existing application data is explicitly out of scope because this
+change is still in development. Implementation targets a fresh database and
+folds the new schema into version 1, so no version-1-to-version-2 migration is
+required. The migration framework remains in place for future released schema
+changes. No legacy-game adapters, data conversion, or compatibility path are
+required. Definition-editing rules still apply to cards created after this
+feature is installed.
## 2. Current implementation and change boundary
@@ -415,7 +418,7 @@ field exactly once. Missing, foreign, or duplicate IDs are invalid.
Card custom controls are named `game.<key>`, preserving the existing namespace.
For example, a hypothetical game may submit `game.score=-2`,
`game.note=Near the station`, `game.route=Forest`, and `game_revision=4`.
-These are examples of the mechanism, not extra fields for Going Home.
+These are examples of the mechanism, not fields for a built-in game.
Add `game_revision` to multipart allowed fields. Keep duplicate detection
before maps are populated. When switching games on the create form, disable
@@ -488,40 +491,37 @@ unbounded submitted value.
## 11. Initialization and startup
-Create the complete new schema directly in an empty database within one
-transaction. Set `DB_SCHEMA_VERSION` and `user_version` to 2 so an older
-database cannot accidentally be treated as compatible. This is a format
-identifier, not a requirement to implement a version-1 conversion.
+Create the complete dynamic-game schema directly in an empty database within
+one transaction. Keep `DB_SCHEMA_VERSION` and `user_version` at 1. This work
+does not introduce a schema-version bump or a version-conversion path.
-Expose `initializeSchema()` without a `GameRegistry` argument. Replace the
-old migration dispatch with initialization for an empty database, acceptance
-of version 2, and a clear error for any other nonempty schema. Never erase
-or reset an existing database automatically. Using a fresh development
-database is a deployment step, not data conversion performed by the server.
+Keep `migrateToLatest()` but remove its `GameRegistry` argument. Replace the
+unreleased version-1 schema and `migrateSchema0To1()` implementation directly;
+do not add a version-2 schema or a version-1-to-2 migration. Preserve ordered
+migration dispatch so future released schema changes can add later migration
+steps. Never erase or reset an existing database automatically.
-Initialization creates `gh` with display name Going Home, empty description,
-revision 1, zero custom fields, and a sequence at zero. Seed only on fresh
-initialization. Later startups do not recreate a deleted unused game or
-overwrite edits. All numbering thereafter follows the normal transaction
-path; deleting cards never reduces a sequence.
+Initialization creates no games. Creating a game through the normal
+transaction path creates its sequence at zero. Later startups do not create
+or alter games. Deleting cards never reduces a sequence.
Keep foreign keys enabled throughout initialization and normal operation.
-If initialization fails, roll back all schema and seed changes and refuse
+If initialization fails, roll back all schema changes and refuse
startup. On reopening, perform ordinary integrity checks and administrator
reconciliation.
Remove runtime registry creation, compiled-schema calls, and checks requiring
stored games to have a matching C++ class. Remove `GoingHome` from runtime
-code; its only built-in trace is initial seed data. Replace compiled
-`TestGame` fixtures with database definitions created by test setup. Tests
-need no historical extension tables or legacy conversion mappings.
+code and do not replace it with seed data. Replace compiled `TestGame`
+fixtures with database definitions created by test setup. Tests need no
+historical extension tables or legacy conversion mappings.
## 12. Implementation sequence
1. Add generic models and decoding with integer/string/choice semantics.
Verify edge cases independently of HTTP and SQLite.
-2. Add the fresh schema and initialization fixtures. Verify Going Home seed
- data and reopening before replacing the runtime registration path.
+2. Add the fresh schema and initialization fixtures. Verify that fresh and
+ reopened databases contain no games before replacing the runtime path.
3. Add game reads, transaction mutations, usage checks, and revisions to the
persistence boundary, fake, and mock implementations.
4. Implement `GameService`, including permissions and edit/delete conflicts.
@@ -563,7 +563,7 @@ need no historical extension tables or legacy conversion mappings.
### Initialization
-- A fresh database has the full schema and Going Home with zero fields.
+- A fresh database has the full schema and zero games.
- Failed initialization rolls back schema and seed data together.
- Reopening never reseeds or overwrites an administrator's edits.
- Unsupported nonempty databases are rejected without modification.
@@ -579,12 +579,12 @@ need no historical extension tables or legacy conversion mappings.
- Unset values remain absent, choices round-trip exactly, and integer values
above JavaScript's safe-integer range retain precision.
- Game switching disables inactive controls and sends the correct revision.
-- Going Home remains available with `gh-*` numbering and zero extra fields.
+- An administrator-created zero-field game supports ordinary card numbering.
- Card pulls, rarity probabilities, assets, login, and role-aware links pass
existing regression tests.
- In Firefox and a Chromium browser, inspect desktop/mobile forms, keyboard
navigation, error focus, choice reordering, and shared clay button styles.
The feature is complete when a newly created database game can use all three
-field types through the normal administrator and card workflows, and Going
-Home works as an ordinary seeded game with zero custom fields.
+field types through the normal administrator and card workflows, while a
+fresh database starts with no game definitions.
diff --git a/src/app.cpp b/src/app.cpp
index ca7e4b5..746ffa0 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -22,9 +22,10 @@
#include "email_sender_file.h"
#include "email_sender_mailjet.h"
+#include "form_limits.h"
+#include "game_field.h"
#include "public_id.h"
#include "multipart_reader.h"
-#include "game_definition.h"
#include "secret_token.h"
namespace
@@ -173,6 +174,46 @@ const std::unordered_map<std::string, RouteDefinition> ROUTES = {
{RouteKind::DYNAMIC, {literal("admin"), literal("users")}}},
{"admin-games",
{RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
+ {"game-new",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), literal("new")}}},
+ {"games",
+ {RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
+ {"game-edit",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("edit")}}},
+ {"game-update",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short")}}},
+ {"game-delete",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("delete")}}},
+ {"game-field-new",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("fields"), literal("new")}}},
+ {"game-fields",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("fields")}}},
+ {"game-field-edit",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("fields"), placeholder("integer"), literal("edit")}}},
+ {"game-field-update",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("fields"), placeholder("integer")}}},
+ {"game-field-delete",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("fields"), placeholder("integer"), literal("delete")}}},
+ {"game-field-order",
+ {RouteKind::DYNAMIC,
+ {literal("admin"), literal("games"), placeholder("short"),
+ literal("field-order")}}},
{"admin-promote",
{RouteKind::DYNAMIC,
{literal("admin"), literal("users"), placeholder("integer"),
@@ -272,6 +313,18 @@ bool indexCardLess(const IndexCard& left, const IndexCard& right)
return left.card->id < right.card->id;
}
+bool hasFieldKey(
+ const GameDefinitionSnapshot& definition,
+ const std::string& key)
+{
+ return std::ranges::any_of(
+ definition.fields,
+ [&key](const GameField& field)
+ {
+ return field.key == key;
+ });
+}
+
void sortIndexCards(std::vector<IndexCard>& cards, bool descending)
{
if(descending)
@@ -323,6 +376,34 @@ void respondBadRequest(
response.set_content(message + "\n", "text/plain; charset=utf-8");
}
+bool enforceTextBudget(
+ const App::Request& request, App::Response& response)
+{
+ std::size_t total_size = 0;
+ for(const auto& [name, value] : request.params)
+ {
+ [[maybe_unused]] const std::string& ignored_name = name;
+ if(value.size() > MAX_FORM_TEXT_FIELD_SIZE)
+ {
+ response.status = 413;
+ response.set_content(
+ "A form field is too large\n",
+ "text/plain; charset=utf-8");
+ return false;
+ }
+ if(value.size() > MAX_FORM_TOTAL_TEXT_SIZE - total_size)
+ {
+ response.status = 413;
+ response.set_content(
+ "Form metadata is too large\n",
+ "text/plain; charset=utf-8");
+ return false;
+ }
+ total_size += value.size();
+ }
+ return true;
+}
+
mw::E<std::string> renderWithHtml(
inja::Environment& environment,
const inja::Template& page_template,
@@ -424,6 +505,52 @@ std::optional<std::string> optionalText(
return value;
}
+std::string submittedText(
+ const std::unordered_map<std::string, std::string>& fields,
+ const std::string& name,
+ std::string fallback = {})
+{
+ const auto position = fields.find(name);
+ return position == fields.end()
+ ? std::move(fallback)
+ : position->second;
+}
+
+std::string renderableGameText(const std::string& value)
+{
+ return validGameText(value) ? value : std::string();
+}
+
+std::string gameControlId(std::string_view field_name)
+{
+ if(field_name == "short_name")
+ {
+ return "GameShortName";
+ }
+ if(field_name == "description")
+ {
+ return "GameDescription";
+ }
+ return "GameDisplayName";
+}
+
+std::string fieldControlId(std::string_view field_name)
+{
+ if(field_name == "key")
+ {
+ return "FieldKey";
+ }
+ if(field_name == "type")
+ {
+ return "FieldType";
+ }
+ if(field_name == "choice")
+ {
+ return "ChoiceEditor";
+ }
+ return "FieldLabel";
+}
+
mw::E<std::int64_t> parseRarity(
const std::unordered_map<std::string, std::string>& fields)
{
@@ -466,6 +593,28 @@ mw::E<std::int64_t> parseRevision(
return revision;
}
+mw::E<std::int64_t> parseGameRevision(
+ const std::unordered_map<std::string, std::string>& fields)
+{
+ const auto position = fields.find("game_revision");
+ if(position == fields.end())
+ {
+ return std::unexpected(mw::httpError(
+ 400, "Game revision is required"));
+ }
+ std::int64_t revision = 0;
+ const std::string& text = position->second;
+ const auto result = std::from_chars(
+ text.data(), text.data() + text.size(), revision);
+ if(text.empty() || result.ec != std::errc{} ||
+ result.ptr != text.data() + text.size() || revision < 1)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "Game revision must be a positive integer"));
+ }
+ return revision;
+}
+
std::optional<std::int64_t> parsePositiveId(const std::string& value)
{
std::int64_t id = 0;
@@ -479,10 +628,61 @@ std::optional<std::int64_t> parsePositiveId(const std::string& value)
return id;
}
-FormFields gameFields(
+mw::E<std::string> scalarParameter(
+ const App::Request& request,
+ const std::string& name,
+ bool required = true)
+{
+ const auto values = request.get_param_values(name);
+ if(values.size() > 1)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "The form field '" + name + "' was supplied more than once"));
+ }
+ if(values.empty())
+ {
+ if(required)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "The form field '" + name + "' is required"));
+ }
+ return std::string();
+ }
+ return values.front();
+}
+
+mw::E<std::int64_t> positiveParameter(
+ const App::Request& request, const std::string& name)
+{
+ auto text = scalarParameter(request, name);
+ if(!text)
+ {
+ return std::unexpected(std::move(text.error()));
+ }
+ auto value = parsePositiveId(*text);
+ if(!value)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "The form field '" + name + "' must be a positive integer"));
+ }
+ return *value;
+}
+
+std::optional<std::string> pathParameter(
+ const App::Request& request, const std::string& name)
+{
+ const auto position = request.path_params.find(name);
+ if(position == request.path_params.end() || position->second.empty())
+ {
+ return std::nullopt;
+ }
+ return position->second;
+}
+
+SubmittedGameFields gameFields(
const std::unordered_map<std::string, std::string>& fields)
{
- FormFields result;
+ SubmittedGameFields result;
for(const auto& [name, value] : fields)
{
if(name.starts_with("game.") && name.size() > 5)
@@ -494,9 +694,7 @@ FormFields gameFields(
}
mw::E<std::vector<std::int64_t>> seriesMemberships(
- const std::vector<std::string>& values,
- const std::string& game_short_name,
- DataSourceInterface& data_source)
+ const std::vector<std::string>& values)
{
std::vector<std::int64_t> result;
result.reserve(values.size());
@@ -508,16 +706,6 @@ mw::E<std::vector<std::int64_t>> seriesMemberships(
return std::unexpected(mw::httpError(
422, "Series IDs must be positive integers"));
}
- auto series = data_source.getSeries(*id);
- if(!series)
- {
- return std::unexpected(std::move(series.error()));
- }
- if(!*series || (**series).game_short_name != game_short_name)
- {
- return std::unexpected(mw::httpError(
- 422, "A selected series does not belong to the card game"));
- }
if(std::ranges::find(result, *id) != result.end())
{
return std::unexpected(mw::httpError(
@@ -528,6 +716,103 @@ mw::E<std::vector<std::int64_t>> seriesMemberships(
return result;
}
+std::string fieldValue(
+ const std::vector<GameFieldValue>& values, std::int64_t field_id)
+{
+ const auto position = std::ranges::find_if(
+ values,
+ [field_id](const GameFieldValue& value)
+ {
+ return value.field_id == field_id;
+ });
+ if(position == values.end())
+ {
+ return {};
+ }
+ if(const auto integer = std::get_if<std::int64_t>(&position->value))
+ {
+ return std::to_string(*integer);
+ }
+ return std::get<std::string>(position->value);
+}
+
+inja::json gameFormJson(
+ const GameDefinitionSnapshot& definition,
+ const std::vector<GameFieldValue>& values,
+ const SubmittedGameFields* submitted = nullptr,
+ const GameFieldValidationError* error = nullptr)
+{
+ inja::json fields = inja::json::array();
+ for(const GameField& field : definition.fields)
+ {
+ inja::json choices = inja::json::array();
+ for(const GameChoice& choice : field.choices)
+ {
+ choices.push_back(choice.value);
+ }
+ std::string displayed_value = fieldValue(values, field.id);
+ if(submitted != nullptr)
+ {
+ const auto submitted_value = submitted->find(field.key);
+ if(submitted_value != submitted->end() &&
+ validGameText(submitted_value->second))
+ {
+ displayed_value = submitted_value->second;
+ }
+ }
+ const bool has_error = error != nullptr &&
+ error->field_key == field.key;
+ const std::string control_id =
+ "GameField-" + definition.game.short_name + "-" + field.key;
+ fields.push_back({
+ {"choices", std::move(choices)},
+ {"control_id", control_id},
+ {"error_id", control_id + "-error"},
+ {"error_message", has_error ? error->msg : std::string()},
+ {"has_error", has_error},
+ {"is_choice", field.type == GameFieldType::CHOICE},
+ {"is_integer", field.type == GameFieldType::INTEGER},
+ {"is_string", field.type == GameFieldType::STRING},
+ {"key", field.key},
+ {"label", field.label},
+ {"value", std::move(displayed_value)},
+ });
+ }
+ return {
+ {"display_name", definition.game.display_name},
+ {"fields", std::move(fields)},
+ {"revision", definition.game.revision},
+ {"short_name", definition.game.short_name},
+ };
+}
+
+mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
+ DataSourceInterface& data_source)
+{
+ auto games = data_source.getGames();
+ if(!games)
+ {
+ return std::unexpected(std::move(games.error()));
+ }
+ std::vector<GameDefinitionSnapshot> definitions;
+ definitions.reserve(games->size());
+ for(const Game& game : *games)
+ {
+ auto definition = data_source.getGameDefinition(game.short_name);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ if(!*definition)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A listed game disappeared while rendering a form"));
+ }
+ definitions.push_back(std::move(**definition));
+ }
+ return definitions;
+}
+
bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
{
std::error_code filesystem_error;
@@ -549,20 +834,18 @@ bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
App::App(
const Config& config,
std::unique_ptr<DataSourceInterface> data_source,
- std::unique_ptr<GameRegistry> games,
std::unique_ptr<NonSecretRandom> random)
: mw::HTTPServer(config.listen_address),
config_(config),
data_source_(std::move(data_source)),
- games_(std::move(games)),
random_(std::move(random)),
url_builder_(config.base_url),
templates_(config.static_root.parent_path() / "templates")
{
- if(!data_source_ || !games_ || !random_)
+ if(!data_source_ || !random_)
{
throw std::invalid_argument(
- "App requires data, games, and a random generator");
+ "App requires data and a random generator");
}
clock_ = std::make_unique<SystemClock>();
@@ -588,7 +871,8 @@ App::App(
ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
AssetStore(config_.card_storage_root));
series_service_ = std::make_unique<SeriesService>(
- *data_source_, *games_);
+ *data_source_);
+ game_service_ = std::make_unique<GameService>(*data_source_);
templates_.set_html_autoescape(true);
templates_.add_callback(
@@ -624,6 +908,7 @@ App::App(
card_delete_template_ = templates_.parse_template("card_delete.html");
card_index_template_ = templates_.parse_template("card_index.html");
card_view_template_ = templates_.parse_template("card_view.html");
+ game_admin_template_ = templates_.parse_template("game_admin.html");
series_delete_template_ = templates_.parse_template(
"series_delete.html");
series_form_template_ = templates_.parse_template("series_form.html");
@@ -743,122 +1028,506 @@ void App::addNavigationData(inja::json& data, const User& user) const
};
}
-void App::handleWelcome(const Request& request, Response& response)
-{
- auto token = cookieValue(request, "card_collection_session");
- if(token)
- {
- auto session = authentication_service_->session(*token);
- if(!session)
- {
- respondInternalError(response);
- return;
- }
- if(*session)
- {
- response.status = 303;
- response.set_header(
- "Location",
- (**session).user.username
- ? urlFor("collection")
- : urlFor("onboarding"));
- return;
- }
- setCookie(response, "card_collection_session", "", 0, false);
- }
- respondTemplate(templates_, "welcome.html", {
- {"authentication_url", urlFor("authentication")},
- {"example_url", urlFor("static", {"card_placeholder.svg"})},
- {"title", "Card Collection"},
- }, response);
-}
-
-void App::handleAuthentication(
- [[maybe_unused]] const Request& request,
- Response& response)
+void App::respondMutationError(
+ Response& response,
+ const mw::Error& error,
+ std::string_view operation,
+ const std::string& reload_url,
+ const User& user,
+ bool files_need_reselection)
{
- auto nonce = generateSecretToken(*crypto_);
- if(!nonce)
- {
+ const mw::HTTPError* http_error = error.as<mw::HTTPError>();
+ const GameFieldValidationError* field_error =
+ error.as<GameFieldValidationError>();
+ const GameDefinitionValidationError* definition_error =
+ error.as<GameDefinitionValidationError>();
+ if(field_error == nullptr && definition_error == nullptr &&
+ (http_error == nullptr || http_error->code >= 500))
+ {
+ spdlog::error("{}: {}", operation, error.msg());
respondInternalError(response);
return;
}
- setCookie(
- response, "card_collection_auth_form", nonce->value, 600, true);
- response.set_header("Cache-Control", "no-store");
- respondTemplate(templates_, "authentication.html", {
- {"action_url", urlFor("authentication-email")},
- {"form_nonce", nonce->value},
- {"title", "Sign in · Card Collection"},
- }, response);
+ const int status = field_error == nullptr && definition_error == nullptr
+ ? http_error->code : 422;
+ const bool conflict = status == 409;
+ inja::json data = {
+ {"conflict", conflict},
+ {"files_need_reselection", files_need_reselection},
+ {"message", error.msg()},
+ {"reload_url", reload_url},
+ {"title", conflict
+ ? "Changes conflicted · Card Collection"
+ : "Check your changes · Card Collection"},
+ };
+ addNavigationData(data, user);
+ response.status = status;
+ respondTemplate(templates_, "mutation_error.html", data, response);
}
-void App::handleAuthenticationEmail(
- const Request& request, Response& response)
+void App::respondCardFieldError(
+ Response& response,
+ const GameFieldValidationError& error,
+ const CardUpload& upload,
+ const RequestIdentity& identity,
+ const Card* card)
{
- const auto nonce = cookieValue(request, "card_collection_auth_form");
- if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
- !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
- {
- response.status = 403;
- response.set_content("Invalid form token\n", "text/plain");
- return;
- }
- if(request.get_param_value_count("email") != 1)
+ auto definitions = loadGameDefinitions(*data_source_);
+ auto all_series = data_source_->getSeries();
+ if(!definitions || !all_series)
{
- respondBadRequest(response, "Email is required");
+ respondInternalError(response);
return;
}
- auto sent = authentication_service_->requestEmail(
- request.get_param_value("email"));
- if(!sent)
+ const std::string selected_game = card != nullptr
+ ? card->identity.game_short_name.value_or("")
+ : submittedText(upload.fields, "game");
+ const SubmittedGameFields submitted = gameFields(upload.fields);
+ inja::json games = inja::json::array();
+ std::string error_control_id;
+ for(const GameDefinitionSnapshot& definition : *definitions)
{
- const auto* limited = sent.error().as<AuthenticationRateLimitError>();
- if(limited != nullptr)
+ const bool selected =
+ definition.game.short_name == selected_game;
+ if(selected && hasFieldKey(definition, error.field_key))
{
- response.status = 429;
- response.set_header(
- "Retry-After", std::to_string(limited->retry_after));
- response.set_content(limited->msg + "\n", "text/plain");
- return;
+ error_control_id = "GameField-" + selected_game + "-" +
+ error.field_key;
}
- respondOperationError(
- response, sent.error(), "Failed to send authentication email");
- return;
+ games.push_back(gameFormJson(
+ definition,
+ {},
+ selected ? &submitted : nullptr,
+ selected ? &error : nullptr));
}
- setCookie(response, "card_collection_auth_form", "", 0, true);
- response.status = 303;
- response.set_header("Location", urlFor("authentication-sent"));
-}
-
-void App::handleAuthenticationSent(
- [[maybe_unused]] const Request& request,
- Response& response)
-{
- response.set_header("Cache-Control", "no-store");
- respondTemplate(templates_, "authentication_sent.html", {
- {"title", "Check your email · Card Collection"},
- }, response);
-}
-void App::handleAuthenticationConfirm(
- const Request& request, Response& response)
-{
- const auto parameter = request.path_params.find("token");
- if(parameter == request.path_params.end())
+ inja::json series = inja::json::array();
+ for(const Series& item : *all_series)
{
- respondBadRequest(response, "Invalid authentication link");
- return;
+ const std::string id = std::to_string(item.id);
+ series.push_back({
+ {"game", item.game_short_name},
+ {"id", item.id},
+ {"name", item.name},
+ {"selected", std::ranges::find(
+ upload.series_ids, id) != upload.series_ids.end()},
+ });
}
- auto challenge = authentication_service_->validate(parameter->second);
- if(!challenge)
+
+ const bool editing = card != nullptr;
+ std::string public_id;
+ std::string front_url = urlFor(
+ "static", {"card_placeholder.svg"});
+ std::string foil_url;
+ if(editing)
{
- const auto* http_error = challenge.error().as<mw::HTTPError>();
- if(http_error != nullptr && http_error->code == 400)
+ auto formatted = formatPublicId(card->identity);
+ if(!formatted)
{
- respondBadRequest(response, "Invalid authentication link");
+ respondInternalError(response);
+ return;
}
- else
+ public_id = std::move(*formatted);
+ front_url = urlFor(
+ "card-asset",
+ {public_id + "/front-art." + card->front_extension},
+ {{"v", std::to_string(card->revision)}});
+ if(card->foil_extension)
+ {
+ foil_url = urlFor(
+ "card-asset",
+ {public_id + "/foil." + *card->foil_extension},
+ {{"v", std::to_string(card->revision)}});
+ }
+ }
+
+ inja::json data = {
+ {"action_url", editing
+ ? urlFor("card-edit", {public_id})
+ : urlFor("cards")},
+ {"back_url", editing
+ ? urlFor("card", {public_id})
+ : urlFor(identity.session.user.role == UserRole::ADMINISTRATOR
+ ? "card-index" : "creator-cards")},
+ {"csrf_token", identity.session.csrf_token},
+ {"display_id", editing ? uppercaseAscii(public_id) : "New addition"},
+ {"error_control_id", error_control_id},
+ {"error_message", error.msg},
+ {"files_need_reselection", true},
+ {"foil_action", "keep"},
+ {"foil_url", foil_url},
+ {"front_url", front_url},
+ {"games", std::move(games)},
+ {"has_errors", true},
+ {"has_foil", editing && card->foil_extension.has_value()},
+ {"heading", editing ? "Edit card" : "Create card"},
+ {"long_description", submittedText(
+ upload.fields,
+ "long_description",
+ editing ? card->long_description.value_or("") : "")},
+ {"mode", editing ? "edit" : "create"},
+ {"model_url", urlFor("static", {"foil/model/card.obj"})},
+ {"name", submittedText(
+ upload.fields, "name", editing ? card->name : "")},
+ {"preview_script_url",
+ urlFor("static", {"foil/card_preview.js"})},
+ {"rarity", submittedText(
+ upload.fields,
+ "rarity",
+ editing ? std::to_string(card->rarity) : "0")},
+ {"revision", editing ? card->revision : 0},
+ {"selected_game", selected_game},
+ {"series", std::move(series)},
+ {"shader_fragment_url",
+ urlFor("static", {"foil/frag-shader.glsl"})},
+ {"shader_vertex_url",
+ urlFor("static", {"foil/vert-shader.glsl"})},
+ {"short_description", submittedText(
+ upload.fields,
+ "short_description",
+ editing ? card->short_description.value_or("") : "")},
+ {"show_rarity",
+ identity.session.user.role == UserRole::ADMINISTRATOR},
+ {"spectral_lut_url",
+ urlFor("static", {"foil/spectral_xyz.bin"})},
+ {"submit_label", editing ? "Save changes" : "Create card"},
+ {"thumbnail_long_side", config_.thumbnail_long_side},
+ {"title", editing
+ ? "Edit " + card->name + " · Card Collection"
+ : "Create card · Card Collection"},
+ };
+ addNavigationData(data, identity.session.user);
+ try
+ {
+ response.status = 422;
+ response.set_content(
+ templates_.render(card_form_template_, data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& render_error)
+ {
+ spdlog::error(
+ "Failed to re-render card validation: {}",
+ render_error.what());
+ respondInternalError(response);
+ }
+}
+
+void App::respondGameDefinitionError(
+ Response& response,
+ const GameDefinitionValidationError& error,
+ const RequestIdentity& identity,
+ const std::string& short_name,
+ const std::string& display_name,
+ const std::string& description,
+ bool editing)
+{
+ std::optional<GameDefinitionSnapshot> definition;
+ if(editing)
+ {
+ auto loaded = data_source_->getGameDefinition(short_name);
+ if(!loaded)
+ {
+ respondInternalError(response);
+ return;
+ }
+ if(!*loaded)
+ {
+ respondNotFound(response);
+ return;
+ }
+ definition = std::move(**loaded);
+ }
+
+ inja::json fields = inja::json::array();
+ if(definition)
+ {
+ for(std::size_t index = 0;
+ index < definition->fields.size();
+ ++index)
+ {
+ const GameField& field = definition->fields[index];
+ std::vector<std::int64_t> up_order;
+ std::vector<std::int64_t> down_order;
+ for(const GameField& candidate : definition->fields)
+ {
+ up_order.push_back(candidate.id);
+ down_order.push_back(candidate.id);
+ }
+ if(index > 0)
+ {
+ std::swap(up_order[index], up_order[index - 1]);
+ }
+ if(index + 1 < down_order.size())
+ {
+ std::swap(down_order[index], down_order[index + 1]);
+ }
+ fields.push_back({
+ {"choice_count", field.choices.size()},
+ {"delete_url", urlFor(
+ "game-field-delete",
+ {short_name, std::to_string(field.id)})},
+ {"down_order", down_order},
+ {"edit_url", urlFor(
+ "game-field-edit",
+ {short_name, std::to_string(field.id)})},
+ {"id", field.id},
+ {"is_first", index == 0},
+ {"is_last", index + 1 == definition->fields.size()},
+ {"key", field.key},
+ {"label", field.label},
+ {"type", std::string(gameFieldTypeName(field.type))},
+ {"up_order", up_order},
+ });
+ }
+ }
+
+ const std::int64_t revision = definition
+ ? definition->game.revision : 0;
+ inja::json data = {
+ {"action_url", editing
+ ? urlFor("game-update", {short_name})
+ : urlFor("games")},
+ {"add_field_url", editing
+ ? urlFor("game-field-new", {short_name}) : ""},
+ {"back_url", urlFor("admin-games")},
+ {"csrf_token", identity.session.csrf_token},
+ {"delete_url", editing
+ ? urlFor("game-delete", {short_name}) : ""},
+ {"description", renderableGameText(description)},
+ {"display_name", renderableGameText(display_name)},
+ {"error_control_id", gameControlId(error.field_name)},
+ {"error_field", error.field_name},
+ {"error_message", error.msg},
+ {"field_order_url", editing
+ ? urlFor("game-field-order", {short_name}) : ""},
+ {"fields", std::move(fields)},
+ {"game_revision", revision},
+ {"has_errors", true},
+ {"heading", editing
+ ? "Edit " + definition->game.display_name
+ : "Create game"},
+ {"mode", editing ? "edit" : "create"},
+ {"short_name", renderableGameText(short_name)},
+ {"submit_label", editing ? "Save game" : "Create game"},
+ {"title", editing
+ ? "Edit " + definition->game.display_name +
+ " · Card Collection"
+ : "Create game · Card Collection"},
+ };
+ addNavigationData(data, identity.session.user);
+ response.status = 422;
+ respondTemplate(templates_, "game_form.html", data, response);
+}
+
+void App::respondGameFieldDefinitionError(
+ Response& response,
+ const GameDefinitionValidationError& error,
+ const RequestIdentity& identity,
+ const std::string& short_name,
+ std::optional<std::int64_t> field_id,
+ const std::string& key,
+ const std::string& label,
+ const std::string& type,
+ const std::vector<std::string>& choices)
+{
+ auto loaded = data_source_->getGameDefinition(short_name);
+ if(!loaded)
+ {
+ respondInternalError(response);
+ return;
+ }
+ if(!*loaded)
+ {
+ respondNotFound(response);
+ return;
+ }
+ const GameDefinitionSnapshot& definition = **loaded;
+ const GameField* stored_field = nullptr;
+ if(field_id)
+ {
+ const auto position = std::ranges::find_if(
+ definition.fields,
+ [field_id](const GameField& field)
+ {
+ return field.id == *field_id;
+ });
+ if(position == definition.fields.end())
+ {
+ respondNotFound(response);
+ return;
+ }
+ stored_field = &*position;
+ }
+
+ inja::json choice_data = inja::json::array();
+ for(const std::string& value : choices)
+ {
+ const bool existing = stored_field != nullptr &&
+ std::ranges::any_of(
+ stored_field->choices,
+ [&value](const GameChoice& choice)
+ {
+ return choice.value == value;
+ });
+ choice_data.push_back({
+ {"existing", existing},
+ {"value", renderableGameText(value)},
+ });
+ }
+ const bool editing = field_id.has_value();
+ const std::string id = editing ? std::to_string(*field_id) : "";
+ const std::string displayed_key = stored_field == nullptr
+ ? renderableGameText(key) : stored_field->key;
+ const std::string displayed_type = stored_field == nullptr
+ ? type : std::string(gameFieldTypeName(stored_field->type));
+ inja::json data = {
+ {"action_url", editing
+ ? urlFor("game-field-update", {short_name, id})
+ : urlFor("game-fields", {short_name})},
+ {"back_url", urlFor("game-edit", {short_name})},
+ {"choices", std::move(choice_data)},
+ {"csrf_token", identity.session.csrf_token},
+ {"delete_url", editing
+ ? urlFor("game-field-delete", {short_name, id}) : ""},
+ {"error_control_id", fieldControlId(error.field_name)},
+ {"error_field", error.field_name},
+ {"error_message", error.msg},
+ {"field_type", displayed_type},
+ {"game_name", definition.game.display_name},
+ {"game_revision", definition.game.revision},
+ {"has_errors", true},
+ {"heading", editing
+ ? "Edit " + stored_field->label : "Add custom field"},
+ {"key", displayed_key},
+ {"label", renderableGameText(label)},
+ {"mode", editing ? "edit" : "create"},
+ {"submit_label", editing ? "Save field" : "Add field"},
+ {"title", editing
+ ? "Edit field · Card Collection"
+ : "Add field · Card Collection"},
+ };
+ addNavigationData(data, identity.session.user);
+ response.status = 422;
+ respondTemplate(templates_, "game_field_form.html", data, response);
+}
+
+void App::handleWelcome(const Request& request, Response& response)
+{
+ auto token = cookieValue(request, "card_collection_session");
+ if(token)
+ {
+ auto session = authentication_service_->session(*token);
+ if(!session)
+ {
+ respondInternalError(response);
+ return;
+ }
+ if(*session)
+ {
+ response.status = 303;
+ response.set_header(
+ "Location",
+ (**session).user.username
+ ? urlFor("collection")
+ : urlFor("onboarding"));
+ return;
+ }
+ setCookie(response, "card_collection_session", "", 0, false);
+ }
+ respondTemplate(templates_, "welcome.html", {
+ {"authentication_url", urlFor("authentication")},
+ {"example_url", urlFor("static", {"card_placeholder.svg"})},
+ {"title", "Card Collection"},
+ }, response);
+}
+
+void App::handleAuthentication(
+ [[maybe_unused]] const Request& request,
+ Response& response)
+{
+ auto nonce = generateSecretToken(*crypto_);
+ if(!nonce)
+ {
+ respondInternalError(response);
+ return;
+ }
+ setCookie(
+ response, "card_collection_auth_form", nonce->value, 600, true);
+ response.set_header("Cache-Control", "no-store");
+ respondTemplate(templates_, "authentication.html", {
+ {"action_url", urlFor("authentication-email")},
+ {"form_nonce", nonce->value},
+ {"title", "Sign in · Card Collection"},
+ }, response);
+}
+
+void App::handleAuthenticationEmail(
+ const Request& request, Response& response)
+{
+ const auto nonce = cookieValue(request, "card_collection_auth_form");
+ if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
+ !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
+ {
+ response.status = 403;
+ response.set_content("Invalid form token\n", "text/plain");
+ return;
+ }
+ if(request.get_param_value_count("email") != 1)
+ {
+ respondBadRequest(response, "Email is required");
+ return;
+ }
+ auto sent = authentication_service_->requestEmail(
+ request.get_param_value("email"));
+ if(!sent)
+ {
+ const auto* limited = sent.error().as<AuthenticationRateLimitError>();
+ if(limited != nullptr)
+ {
+ response.status = 429;
+ response.set_header(
+ "Retry-After", std::to_string(limited->retry_after));
+ response.set_content(limited->msg + "\n", "text/plain");
+ return;
+ }
+ respondOperationError(
+ response, sent.error(), "Failed to send authentication email");
+ return;
+ }
+ setCookie(response, "card_collection_auth_form", "", 0, true);
+ response.status = 303;
+ response.set_header("Location", urlFor("authentication-sent"));
+}
+
+void App::handleAuthenticationSent(
+ [[maybe_unused]] const Request& request,
+ Response& response)
+{
+ response.set_header("Cache-Control", "no-store");
+ respondTemplate(templates_, "authentication_sent.html", {
+ {"title", "Check your email · Card Collection"},
+ }, response);
+}
+
+void App::handleAuthenticationConfirm(
+ const Request& request, Response& response)
+{
+ const auto parameter = request.path_params.find("token");
+ if(parameter == request.path_params.end())
+ {
+ respondBadRequest(response, "Invalid authentication link");
+ return;
+ }
+ auto challenge = authentication_service_->validate(parameter->second);
+ if(!challenge)
+ {
+ const auto* http_error = challenge.error().as<mw::HTTPError>();
+ if(http_error != nullptr && http_error->code == 400)
+ {
+ respondBadRequest(response, "Invalid authentication link");
+ }
+ else
{
spdlog::error(
"Failed to validate authentication link: {}",
@@ -1102,131 +1771,944 @@ void App::handleCollection(const Request& request, Response& response)
{"url", urlFor("card", {public_id})},
});
}
- const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
- inja::json template_data = {
- {"available_pulls", user->stored_pulls},
+ const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
+ inja::json template_data = {
+ {"available_pulls", user->stored_pulls},
+ {"csrf_token", identity->session.csrf_token},
+ {"entries", std::move(entries)},
+ {"pool_empty", pool->empty()},
+ {"pull_disabled", pull_disabled},
+ {"pull_url", urlFor("collection-pull")},
+ {"title", "Collection · Card Collection"},
+ };
+ addNavigationData(template_data, identity->session.user);
+ respondTemplate(
+ templates_, "collection.html", template_data, response);
+}
+
+void App::handleCollectionPull(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ auto pulled = collection_service_->pull(identity->session.user.id);
+ if(!pulled)
+ {
+ respondOperationError(response, pulled.error(), "Failed card pull");
+ return;
+ }
+ response.status = 303;
+ auto public_id = formatPublicId(pulled->card.identity);
+ if(!public_id)
+ {
+ respondInternalError(response);
+ return;
+ }
+ response.set_header(
+ "Location", urlFor("card", {*public_id}));
+}
+
+void App::handleAdminUsers(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ if(!identity)
+ {
+ return;
+ }
+ AuthorizationService authorization;
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+ auto users = data_source_->getUsers();
+ if(!users)
+ {
+ respondInternalError(response);
+ return;
+ }
+ inja::json template_users = inja::json::array();
+ for(const User& user : *users)
+ {
+ template_users.push_back({
+ {"can_promote", user.role == UserRole::PLAYER},
+ {"email", user.email},
+ {"promote_url", urlFor(
+ "admin-promote", {std::to_string(user.id)})},
+ {"role", roleName(user.role)},
+ {"username", user.username.value_or("Onboarding")},
+ });
+ }
+ inja::json template_data = {
+ {"csrf_token", identity->session.csrf_token},
+ {"title", "Users · Card Collection"},
+ {"users", std::move(template_users)},
+ };
+ addNavigationData(template_data, identity->session.user);
+ respondTemplate(
+ templates_, "user_admin.html", template_data, response);
+}
+
+void App::handleAdminGames(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ if(!identity)
+ {
+ return;
+ }
+ AuthorizationService authorization;
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+
+ auto games = data_source_->getGames();
+ if(!games)
+ {
+ respondInternalError(response);
+ return;
+ }
+ inja::json template_games = inja::json::array();
+ std::vector<HtmlSubstitution> html_substitutions;
+ for(const Game& game : *games)
+ {
+ auto definition = data_source_->getGameDefinition(game.short_name);
+ if(!definition || !*definition)
+ {
+ respondInternalError(response);
+ return;
+ }
+ inja::json fields = inja::json::array();
+ for(const GameField& field : (**definition).fields)
+ {
+ fields.push_back({
+ {"input_type", std::string(gameFieldTypeName(field.type))},
+ {"key", field.key},
+ {"label", field.label},
+ });
+ }
+ const Game& current_game = (**definition).game;
+ std::string description_marker;
+ if(!current_game.description.empty())
+ {
+ auto rendered = MarkdownRenderer().render(
+ current_game.description);
+ if(!rendered)
+ {
+ respondInternalError(response);
+ return;
+ }
+ description_marker =
+ "CARD_COLLECTION_HTML_" + random_->hex(16);
+ html_substitutions.push_back({
+ description_marker, std::move(*rendered)});
+ }
+ template_games.push_back({
+ {"description", description_marker},
+ {"display_name", current_game.display_name},
+ {"edit_url", urlFor(
+ "game-edit", {current_game.short_name})},
+ {"field_count", (**definition).fields.size()},
+ {"fields", std::move(fields)},
+ {"has_description", !current_game.description.empty()},
+ {"initial", current_game.short_name.substr(0, 1)},
+ {"short_name", current_game.short_name},
+ });
+ }
+ inja::json template_data = {
+ {"create_card_url", urlFor("card-new")},
+ {"create_url", urlFor("game-new")},
+ {"games", std::move(template_games)},
+ {"series_url", urlFor("series-index")},
+ {"title", "Games · Card Collection"},
+ };
+ addNavigationData(template_data, identity->session.user);
+ auto content = renderWithHtml(
+ templates_,
+ game_admin_template_,
+ template_data,
+ html_substitutions);
+ if(!content)
+ {
+ spdlog::error(
+ "Failed to render game administration: {}",
+ content.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ response.status = 200;
+ response.set_content(*content, "text/html; charset=utf-8");
+}
+
+void App::handleGameNew(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!identity)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+ inja::json data = {
+ {"action_url", urlFor("games")},
+ {"back_url", urlFor("admin-games")},
+ {"csrf_token", identity->session.csrf_token},
+ {"description", ""},
+ {"display_name", ""},
+ {"heading", "Create game"},
+ {"error_control_id", ""},
+ {"error_field", ""},
+ {"error_message", ""},
+ {"has_errors", false},
+ {"mode", "create"},
+ {"short_name", ""},
+ {"submit_label", "Create game"},
+ {"title", "Create game · Card Collection"},
+ };
+ addNavigationData(data, identity->session.user);
+ respondTemplate(templates_, "game_form.html", data, response);
+}
+
+void App::handleGameCreate(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ if(!enforceTextBudget(request, response))
+ {
+ return;
+ }
+ auto short_name = scalarParameter(request, "short_name");
+ auto display_name = scalarParameter(request, "display_name");
+ auto description = scalarParameter(request, "description", false);
+ if(!short_name || !display_name || !description)
+ {
+ const mw::Error& error = !short_name ? short_name.error()
+ : !display_name ? display_name.error() : description.error();
+ respondOperationError(response, error, "Invalid game form");
+ return;
+ }
+ auto created = game_service_->createGame(
+ identity->session.user.id, *short_name,
+ *display_name, *description);
+ if(!created)
+ {
+ const auto* validation =
+ created.error().as<GameDefinitionValidationError>();
+ if(validation != nullptr)
+ {
+ respondGameDefinitionError(
+ response,
+ *validation,
+ *identity,
+ *short_name,
+ *display_name,
+ *description,
+ false);
+ return;
+ }
+ respondMutationError(
+ response,
+ created.error(),
+ "Failed game create",
+ urlFor("game-new"),
+ identity->session.user);
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("game-edit", {*created}));
+}
+
+void App::handleGameEdit(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!identity)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ if(!short_name)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto definition = data_source_->getGameDefinition(*short_name);
+ if(!definition)
+ {
+ respondInternalError(response);
+ return;
+ }
+ if(!*definition)
+ {
+ respondNotFound(response);
+ return;
+ }
+ const GameDefinitionSnapshot& snapshot = **definition;
+ inja::json fields = inja::json::array();
+ for(std::size_t index = 0; index < snapshot.fields.size(); ++index)
+ {
+ const GameField& field = snapshot.fields[index];
+ std::vector<std::int64_t> up_order;
+ std::vector<std::int64_t> down_order;
+ for(const GameField& candidate : snapshot.fields)
+ {
+ up_order.push_back(candidate.id);
+ down_order.push_back(candidate.id);
+ }
+ if(index > 0)
+ {
+ std::swap(up_order[index], up_order[index - 1]);
+ }
+ if(index + 1 < down_order.size())
+ {
+ std::swap(down_order[index], down_order[index + 1]);
+ }
+ fields.push_back({
+ {"choice_count", field.choices.size()},
+ {"delete_url", urlFor(
+ "game-field-delete",
+ {*short_name, std::to_string(field.id)})},
+ {"down_order", down_order},
+ {"edit_url", urlFor(
+ "game-field-edit",
+ {*short_name, std::to_string(field.id)})},
+ {"id", field.id},
+ {"is_first", index == 0},
+ {"is_last", index + 1 == snapshot.fields.size()},
+ {"key", field.key},
+ {"label", field.label},
+ {"type", std::string(gameFieldTypeName(field.type))},
+ {"up_order", up_order},
+ });
+ }
+ inja::json data = {
+ {"action_url", urlFor("game-update", {*short_name})},
+ {"add_field_url", urlFor("game-field-new", {*short_name})},
+ {"back_url", urlFor("admin-games")},
+ {"csrf_token", identity->session.csrf_token},
+ {"delete_url", urlFor("game-delete", {*short_name})},
+ {"description", snapshot.game.description},
+ {"display_name", snapshot.game.display_name},
+ {"field_order_url", urlFor("game-field-order", {*short_name})},
+ {"fields", std::move(fields)},
+ {"game_revision", snapshot.game.revision},
+ {"heading", "Edit " + snapshot.game.display_name},
+ {"error_control_id", ""},
+ {"error_field", ""},
+ {"error_message", ""},
+ {"has_errors", false},
+ {"mode", "edit"},
+ {"short_name", snapshot.game.short_name},
+ {"submit_label", "Save game"},
+ {"title", "Edit " + snapshot.game.display_name +
+ " · Card Collection"},
+ };
+ addNavigationData(data, identity->session.user);
+ respondTemplate(templates_, "game_form.html", data, response);
+}
+
+void App::handleGameUpdate(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ if(!enforceTextBudget(request, response))
+ {
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ auto revision = positiveParameter(request, "game_revision");
+ auto display_name = scalarParameter(request, "display_name");
+ auto description = scalarParameter(request, "description", false);
+ if(!short_name || !revision || !display_name || !description)
+ {
+ if(!short_name)
+ {
+ respondNotFound(response);
+ }
+ else
+ {
+ const mw::Error& error = !revision ? revision.error()
+ : !display_name ? display_name.error()
+ : description.error();
+ respondOperationError(response, error, "Invalid game form");
+ }
+ return;
+ }
+ auto updated = game_service_->updateGame(
+ identity->session.user.id, *short_name, *revision,
+ *display_name, *description);
+ if(!updated)
+ {
+ const auto* validation =
+ updated.error().as<GameDefinitionValidationError>();
+ if(validation != nullptr)
+ {
+ respondGameDefinitionError(
+ response,
+ *validation,
+ *identity,
+ *short_name,
+ *display_name,
+ *description,
+ true);
+ return;
+ }
+ respondMutationError(
+ response,
+ updated.error(),
+ "Failed game update",
+ urlFor("game-edit", {*short_name}),
+ identity->session.user);
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("game-edit", {*short_name}));
+}
+
+void App::handleGameDeleteConfirm(
+ const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!identity)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ auto definition = short_name
+ ? data_source_->getGameDefinition(*short_name)
+ : mw::E<std::optional<GameDefinitionSnapshot>>(
+ std::optional<GameDefinitionSnapshot>{});
+ if(!definition || !*definition)
+ {
+ if(!definition)
+ {
+ respondInternalError(response);
+ }
+ else
+ {
+ respondNotFound(response);
+ }
+ return;
+ }
+ inja::json data = {
+ {"action_url", urlFor("game-delete", {*short_name})},
+ {"back_url", urlFor("game-edit", {*short_name})},
+ {"csrf_token", identity->session.csrf_token},
+ {"display_name", (**definition).game.display_name},
+ {"game_revision", (**definition).game.revision},
+ {"title", "Delete " + (**definition).game.display_name +
+ " · Card Collection"},
+ };
+ addNavigationData(data, identity->session.user);
+ respondTemplate(templates_, "game_delete.html", data, response);
+}
+
+void App::handleGameDelete(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ if(!enforceTextBudget(request, response))
+ {
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ auto revision = positiveParameter(request, "game_revision");
+ if(!short_name || !revision)
+ {
+ if(!short_name)
+ {
+ respondNotFound(response);
+ }
+ else
+ {
+ respondOperationError(
+ response, revision.error(), "Invalid game deletion");
+ }
+ return;
+ }
+ auto deleted = game_service_->removeGame(
+ identity->session.user.id, *short_name, *revision);
+ if(!deleted)
+ {
+ respondMutationError(
+ response,
+ deleted.error(),
+ "Failed game delete",
+ urlFor("game-edit", {*short_name}),
+ identity->session.user);
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("admin-games"));
+}
+
+void App::handleGameFieldNew(const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!identity)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ auto definition = short_name
+ ? data_source_->getGameDefinition(*short_name)
+ : mw::E<std::optional<GameDefinitionSnapshot>>(
+ std::optional<GameDefinitionSnapshot>{});
+ if(!definition || !*definition)
+ {
+ if(!definition)
+ {
+ respondInternalError(response);
+ }
+ else
+ {
+ respondNotFound(response);
+ }
+ return;
+ }
+ inja::json data = {
+ {"action_url", urlFor("game-fields", {*short_name})},
+ {"back_url", urlFor("game-edit", {*short_name})},
+ {"choices", inja::json::array()},
+ {"csrf_token", identity->session.csrf_token},
+ {"field_type", "STRING"},
+ {"game_name", (**definition).game.display_name},
+ {"game_revision", (**definition).game.revision},
+ {"heading", "Add custom field"},
+ {"error_control_id", ""},
+ {"error_field", ""},
+ {"error_message", ""},
+ {"has_errors", false},
+ {"key", ""},
+ {"label", ""},
+ {"mode", "create"},
+ {"submit_label", "Add field"},
+ {"title", "Add field · Card Collection"},
+ };
+ addNavigationData(data, identity->session.user);
+ respondTemplate(templates_, "game_field_form.html", data, response);
+}
+
+void App::handleGameFieldCreate(
+ const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
+ {
+ return;
+ }
+ if(!enforceTextBudget(request, response))
+ {
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ auto revision = positiveParameter(request, "game_revision");
+ auto key = scalarParameter(request, "key");
+ auto label = scalarParameter(request, "label");
+ auto type_text = scalarParameter(request, "type");
+ if(!short_name || !revision || !key || !label || !type_text)
+ {
+ if(!short_name)
+ {
+ respondNotFound(response);
+ }
+ else
+ {
+ const mw::Error& error = !revision ? revision.error()
+ : !key ? key.error() : !label ? label.error()
+ : type_text.error();
+ respondOperationError(response, error, "Invalid field form");
+ }
+ return;
+ }
+ auto type = parseGameFieldType(*type_text);
+ if(!type)
+ {
+ respondGameFieldDefinitionError(
+ response,
+ {"type", "Unknown field type"},
+ *identity,
+ *short_name,
+ std::nullopt,
+ *key,
+ *label,
+ *type_text,
+ request.get_param_values("choice"));
+ return;
+ }
+ const std::vector<std::string> choices =
+ request.get_param_values("choice");
+ auto created = game_service_->createField(
+ identity->session.user.id, *short_name, *revision,
+ *key, *label, *type, choices);
+ if(!created)
+ {
+ const auto* validation =
+ created.error().as<GameDefinitionValidationError>();
+ if(validation != nullptr)
+ {
+ respondGameFieldDefinitionError(
+ response,
+ *validation,
+ *identity,
+ *short_name,
+ std::nullopt,
+ *key,
+ *label,
+ *type_text,
+ choices);
+ return;
+ }
+ respondMutationError(
+ response,
+ created.error(),
+ "Failed field create",
+ urlFor("game-field-new", {*short_name}),
+ identity->session.user);
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("game-edit", {*short_name}));
+}
+
+void App::handleGameFieldEdit(
+ const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, true);
+ AuthorizationService authorization;
+ if(!identity)
+ {
+ return;
+ }
+ if(!authorization.canAdminister(identity->session.user))
+ {
+ response.status = 403;
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ auto id_text = pathParameter(request, "id");
+ auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+ if(!short_name || !field_id)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto definition = data_source_->getGameDefinition(*short_name);
+ if(!definition || !*definition)
+ {
+ if(!definition)
+ {
+ respondInternalError(response);
+ }
+ else
+ {
+ respondNotFound(response);
+ }
+ return;
+ }
+ const auto field = std::ranges::find_if(
+ (**definition).fields,
+ [field_id](const GameField& candidate)
+ {
+ return candidate.id == *field_id;
+ });
+ if(field == (**definition).fields.end())
+ {
+ respondNotFound(response);
+ return;
+ }
+ inja::json choices = inja::json::array();
+ for(const GameChoice& choice : field->choices)
+ {
+ choices.push_back({{"existing", true}, {"value", choice.value}});
+ }
+ inja::json data = {
+ {"action_url", urlFor(
+ "game-field-update", {*short_name, std::to_string(*field_id)})},
+ {"back_url", urlFor("game-edit", {*short_name})},
+ {"choices", std::move(choices)},
{"csrf_token", identity->session.csrf_token},
- {"entries", std::move(entries)},
- {"pool_empty", pool->empty()},
- {"pull_disabled", pull_disabled},
- {"pull_url", urlFor("collection-pull")},
- {"title", "Collection · Card Collection"},
+ {"delete_url", urlFor(
+ "game-field-delete", {*short_name, std::to_string(*field_id)})},
+ {"field_type", std::string(gameFieldTypeName(field->type))},
+ {"game_name", (**definition).game.display_name},
+ {"game_revision", (**definition).game.revision},
+ {"heading", "Edit " + field->label},
+ {"error_control_id", ""},
+ {"error_field", ""},
+ {"error_message", ""},
+ {"has_errors", false},
+ {"key", field->key},
+ {"label", field->label},
+ {"mode", "edit"},
+ {"submit_label", "Save field"},
+ {"title", "Edit field · Card Collection"},
};
- addNavigationData(template_data, identity->session.user);
- respondTemplate(
- templates_, "collection.html", template_data, response);
+ addNavigationData(data, identity->session.user);
+ respondTemplate(templates_, "game_field_form.html", data, response);
}
-void App::handleCollectionPull(const Request& request, Response& response)
+void App::handleGameFieldUpdate(
+ const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, false);
if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
- auto pulled = collection_service_->pull(identity->session.user.id);
- if(!pulled)
+ if(!enforceTextBudget(request, response))
{
- respondOperationError(response, pulled.error(), "Failed card pull");
return;
}
- response.status = 303;
- auto public_id = formatPublicId(pulled->card.identity);
- if(!public_id)
+ auto short_name = pathParameter(request, "short");
+ auto id_text = pathParameter(request, "id");
+ auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+ auto revision = positiveParameter(request, "game_revision");
+ auto label = scalarParameter(request, "label");
+ if(!short_name || !field_id || !revision || !label)
{
- respondInternalError(response);
+ if(!short_name || !field_id)
+ {
+ respondNotFound(response);
+ }
+ else
+ {
+ respondOperationError(
+ response, !revision ? revision.error() : label.error(),
+ "Invalid field form");
+ }
return;
}
- response.set_header(
- "Location", urlFor("card", {*public_id}));
+ const std::vector<std::string> choices =
+ request.get_param_values("choice");
+ auto updated = game_service_->updateField(
+ identity->session.user.id, *short_name, *field_id, *revision,
+ *label, choices);
+ if(!updated)
+ {
+ const auto* validation =
+ updated.error().as<GameDefinitionValidationError>();
+ if(validation != nullptr)
+ {
+ respondGameFieldDefinitionError(
+ response,
+ *validation,
+ *identity,
+ *short_name,
+ *field_id,
+ "",
+ *label,
+ "",
+ choices);
+ return;
+ }
+ respondMutationError(
+ response,
+ updated.error(),
+ "Failed field update",
+ urlFor(
+ "game-field-edit",
+ {*short_name, std::to_string(*field_id)}),
+ identity->session.user);
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("game-edit", {*short_name}));
}
-void App::handleAdminUsers(const Request& request, Response& response)
+void App::handleGameFieldDeleteConfirm(
+ const Request& request, Response& response)
{
auto identity = requireIdentity(request, response, true);
+ AuthorizationService authorization;
if(!identity)
{
return;
}
- AuthorizationService authorization;
if(!authorization.canAdminister(identity->session.user))
{
response.status = 403;
return;
}
- auto users = data_source_->getUsers();
- if(!users)
+ auto short_name = pathParameter(request, "short");
+ auto id_text = pathParameter(request, "id");
+ auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+ if(!short_name || !field_id)
{
- respondInternalError(response);
+ respondNotFound(response);
return;
}
- inja::json template_users = inja::json::array();
- for(const User& user : *users)
+ auto definition = data_source_->getGameDefinition(*short_name);
+ if(!definition || !*definition)
{
- template_users.push_back({
- {"can_promote", user.role == UserRole::PLAYER},
- {"email", user.email},
- {"promote_url", urlFor(
- "admin-promote", {std::to_string(user.id)})},
- {"role", roleName(user.role)},
- {"username", user.username.value_or("Onboarding")},
+ if(!definition)
+ {
+ respondInternalError(response);
+ }
+ else
+ {
+ respondNotFound(response);
+ }
+ return;
+ }
+ const auto field = std::ranges::find_if(
+ (**definition).fields,
+ [field_id](const GameField& candidate)
+ {
+ return candidate.id == *field_id;
});
+ if(field == (**definition).fields.end())
+ {
+ respondNotFound(response);
+ return;
}
- inja::json template_data = {
+ inja::json data = {
+ {"action_url", urlFor(
+ "game-field-delete", {*short_name, std::to_string(*field_id)})},
+ {"back_url", urlFor(
+ "game-field-edit", {*short_name, std::to_string(*field_id)})},
{"csrf_token", identity->session.csrf_token},
- {"title", "Users · Card Collection"},
- {"users", std::move(template_users)},
+ {"field_label", field->label},
+ {"game_revision", (**definition).game.revision},
+ {"title", "Delete field · Card Collection"},
};
- addNavigationData(template_data, identity->session.user);
- respondTemplate(
- templates_, "user_admin.html", template_data, response);
+ addNavigationData(data, identity->session.user);
+ respondTemplate(templates_, "game_field_delete.html", data, response);
}
-void App::handleAdminGames(const Request& request, Response& response)
+void App::handleGameFieldDelete(
+ const Request& request, Response& response)
{
- auto identity = requireIdentity(request, response, true);
- if(!identity)
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
{
return;
}
- AuthorizationService authorization;
- if(!authorization.canAdminister(identity->session.user))
+ if(!enforceTextBudget(request, response))
{
- response.status = 403;
return;
}
+ auto short_name = pathParameter(request, "short");
+ auto id_text = pathParameter(request, "id");
+ auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+ auto revision = positiveParameter(request, "game_revision");
+ if(!short_name || !field_id || !revision)
+ {
+ if(!short_name || !field_id)
+ {
+ respondNotFound(response);
+ }
+ else
+ {
+ respondOperationError(
+ response, revision.error(), "Invalid field deletion");
+ }
+ return;
+ }
+ auto deleted = game_service_->removeField(
+ identity->session.user.id, *short_name, *field_id, *revision);
+ if(!deleted)
+ {
+ respondMutationError(
+ response,
+ deleted.error(),
+ "Failed field delete",
+ urlFor(
+ "game-field-edit",
+ {*short_name, std::to_string(*field_id)}),
+ identity->session.user);
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("game-edit", {*short_name}));
+}
- inja::json template_games = inja::json::array();
- for(const GameDefinition* game : games_->games())
+void App::handleGameFieldOrder(
+ const Request& request, Response& response)
+{
+ auto identity = requireIdentity(request, response, false);
+ if(!identity || !verifyCsrf(request, *identity, response))
{
- inja::json fields = inja::json::array();
- const std::vector<GameFormField> form_fields = game->formFields();
- for(const GameFormField& field : form_fields)
+ return;
+ }
+ if(!enforceTextBudget(request, response))
+ {
+ return;
+ }
+ auto short_name = pathParameter(request, "short");
+ auto revision = positiveParameter(request, "game_revision");
+ std::vector<std::int64_t> field_ids;
+ for(const std::string& text : request.get_param_values("field_id"))
+ {
+ auto field_id = parsePositiveId(text);
+ if(!field_id)
{
- fields.push_back({
- {"input_type", field.input_type},
- {"label", field.label},
- {"name", field.name},
- });
+ response.status = 422;
+ response.set_content("Invalid field order\n", "text/plain");
+ return;
}
- template_games.push_back({
- {"description", std::string(game->description())},
- {"display_name", std::string(game->displayName())},
- {"field_count", form_fields.size()},
- {"fields", std::move(fields)},
- {"initial", std::string(game->shortName().substr(0, 1))},
- {"short_name", std::string(game->shortName())},
- });
+ field_ids.push_back(*field_id);
}
- inja::json template_data = {
- {"create_card_url", urlFor("card-new")},
- {"games", std::move(template_games)},
- {"series_url", urlFor("series-index")},
- {"title", "Games · Card Collection"},
- };
- addNavigationData(template_data, identity->session.user);
- respondTemplate(
- templates_, "game_admin.html", template_data, response);
+ if(!short_name || !revision)
+ {
+ if(!short_name)
+ {
+ respondNotFound(response);
+ }
+ else
+ {
+ respondOperationError(
+ response, revision.error(), "Invalid field order");
+ }
+ return;
+ }
+ auto reordered = game_service_->reorderFields(
+ identity->session.user.id, *short_name, *revision, field_ids);
+ if(!reordered)
+ {
+ respondMutationError(
+ response,
+ reordered.error(),
+ "Failed reorder",
+ urlFor("game-edit", {*short_name}),
+ identity->session.user);
+ return;
+ }
+ response.status = 303;
+ response.set_header("Location", urlFor("game-edit", {*short_name}));
}
void App::handleAdminPromote(const Request& request, Response& response)
@@ -1272,25 +2754,18 @@ void App::handleCardNew(
return;
}
inja::json games = inja::json::array();
- for(const GameDefinition* game : games_->games())
+ auto definitions = loadGameDefinitions(*data_source_);
+ if(!definitions)
{
- inja::json fields = inja::json::array();
- for(const GameFormField& field : game->formFields())
- {
- fields.push_back({
- {"input_type", field.input_type},
- {"label", field.label},
- {"minimum", field.minimum},
- {"name", field.name},
- {"required", field.required},
- {"value", ""},
- });
- }
- games.push_back({
- {"display_name", std::string(game->displayName())},
- {"fields", std::move(fields)},
- {"short_name", std::string(game->shortName())},
- });
+ spdlog::error(
+ "Failed to load games for card form: {}",
+ definitions.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ for(const GameDefinitionSnapshot& definition : *definitions)
+ {
+ games.push_back(gameFormJson(definition, {}));
}
inja::json series = inja::json::array();
auto series_result = data_source_->getSeries();
@@ -1322,7 +2797,12 @@ void App::handleCardNew(
{"foil_action", "keep"},
{"foil_url", ""},
{"front_url", urlFor("static", {"card_placeholder.svg"})},
+ {"error_control_id", ""},
+ {"error_message", ""},
+ {"files_need_reselection", false},
+ {"game_revision", 0},
{"games", std::move(games)},
+ {"has_errors", false},
{"has_foil", false},
{"heading", "Create card"},
{"long_description", ""},
@@ -1431,18 +2911,11 @@ void App::handleCardEdit(
{{"v", std::to_string(card.revision)}});
}
- FormFields current_game_values;
- const GameDefinition* selected_game = nullptr;
+ std::vector<GameFieldValue> current_game_values;
+ std::optional<GameDefinitionSnapshot> selected_game;
if(card.identity.game_short_name)
{
- selected_game = games_->find(*card.identity.game_short_name);
- if(selected_game == nullptr)
- {
- respondInternalError(response);
- return;
- }
- auto values = data_source_->getGameFormValues(
- *selected_game, card.id);
+ auto values = data_source_->getCardFieldValues(card.id);
if(!values)
{
spdlog::error(
@@ -1452,32 +2925,31 @@ void App::handleCardEdit(
respondInternalError(response);
return;
}
- current_game_values = std::move(*values);
+ if(!*values)
+ {
+ respondInternalError(response);
+ return;
+ }
+ selected_game = std::move((**values).definition);
+ current_game_values = std::move((**values).values);
}
inja::json games = inja::json::array();
- for(const GameDefinition* game : games_->games())
+ auto definitions = loadGameDefinitions(*data_source_);
+ if(!definitions)
{
- inja::json fields = inja::json::array();
- for(const GameFormField& field : game->formFields())
- {
- const auto value = current_game_values.find(field.name);
- fields.push_back({
- {"input_type", field.input_type},
- {"label", field.label},
- {"minimum", field.minimum},
- {"name", field.name},
- {"required", field.required},
- {"value", value == current_game_values.end()
- ? std::string()
- : value->second},
- });
- }
- games.push_back({
- {"display_name", std::string(game->displayName())},
- {"fields", std::move(fields)},
- {"short_name", std::string(game->shortName())},
- });
+ respondInternalError(response);
+ return;
+ }
+ for(const GameDefinitionSnapshot& definition : *definitions)
+ {
+ const bool selected = card.identity.game_short_name &&
+ definition.game.short_name == *card.identity.game_short_name;
+ const GameDefinitionSnapshot& displayed_definition =
+ selected && selected_game ? *selected_game : definition;
+ games.push_back(gameFormJson(
+ displayed_definition,
+ selected ? current_game_values : std::vector<GameFieldValue>{}));
}
auto all_series = data_source_->getSeries();
@@ -1508,7 +2980,11 @@ void App::handleCardEdit(
{"foil_action", "keep"},
{"foil_url", foil_url},
{"front_url", front_url},
+ {"error_control_id", ""},
+ {"error_message", ""},
+ {"files_need_reselection", false},
{"games", std::move(games)},
+ {"has_errors", false},
{"has_foil", card.foil_extension.has_value()},
{"heading", "Edit card"},
{"long_description", card.long_description.value_or("")},
@@ -1521,6 +2997,9 @@ void App::handleCardEdit(
{"show_rarity",
actor->session.user.role == UserRole::ADMINISTRATOR},
{"revision", card.revision},
+ {"game_revision", selected_game
+ ? selected_game->game.revision
+ : 0},
{"selected_game", card.identity.game_short_name.value_or("")},
{"series", std::move(series)},
{"shader_fragment_url",
@@ -1579,8 +3058,13 @@ void App::handleCardCreate(
auto upload = multipart_reader.read(content_reader);
if(!upload)
{
- respondOperationError(
- response, upload.error(), "Failed to receive a card upload");
+ respondMutationError(
+ response,
+ upload.error(),
+ "Failed to receive a card upload",
+ urlFor("card-new"),
+ actor->session.user,
+ true);
return;
}
const auto csrf = upload->fields.find("csrf_token");
@@ -1651,10 +3135,19 @@ void App::handleCardCreate(
mw::runtimeError("Card creation was not dispatched"));
if(game_short_name.empty())
{
- if(!upload->series_ids.empty())
+ if(upload->fields.contains("game_revision") ||
+ !gameFields(upload->fields).empty() ||
+ !upload->series_ids.empty())
{
- respondBadRequest(
- response, "Loose cards cannot belong to a series");
+ respondMutationError(
+ response,
+ mw::httpError(
+ 422,
+ "Loose cards cannot have game fields or series"),
+ "Failed to validate loose card fields",
+ urlFor("card-new"),
+ actor->session.user,
+ true);
return;
}
created = card_service_->createLooseCard(
@@ -1662,43 +3155,55 @@ void App::handleCardCreate(
}
else
{
- const GameDefinition* definition = games_->find(game_short_name);
- if(definition == nullptr)
- {
- respondBadRequest(response, "Unknown compiled game");
- return;
- }
- auto metadata = definition->validateMetadata(
- gameFields(upload->fields));
- if(!metadata)
+ auto game_revision = parseGameRevision(upload->fields);
+ if(!game_revision)
{
- respondOperationError(
+ respondMutationError(
response,
- metadata.error(),
- "Failed to validate game metadata");
+ game_revision.error(),
+ "Failed to parse game revision",
+ urlFor("card-new"),
+ actor->session.user,
+ true);
return;
}
- auto memberships = seriesMemberships(
- upload->series_ids, game_short_name, *data_source_);
+ auto memberships = seriesMemberships(upload->series_ids);
if(!memberships)
{
- respondOperationError(
+ respondMutationError(
response,
memberships.error(),
- "Failed to validate series memberships");
+ "Failed to validate series memberships",
+ urlFor("card-new"),
+ actor->session.user,
+ true);
return;
}
created = card_service_->createGameCard(
actor->session.user.id,
std::move(input),
- *definition,
- **metadata,
+ game_short_name,
+ *game_revision,
+ gameFields(upload->fields),
*memberships);
}
if(!created)
{
- respondOperationError(
- response, created.error(), "Failed to create a card");
+ const auto* field_error =
+ created.error().as<GameFieldValidationError>();
+ if(field_error != nullptr)
+ {
+ respondCardFieldError(
+ response, *field_error, *upload, *actor);
+ return;
+ }
+ respondMutationError(
+ response,
+ created.error(),
+ "Failed to create a card",
+ urlFor("card-new"),
+ actor->session.user,
+ true);
return;
}
@@ -1749,6 +3254,7 @@ void App::handleCardUpdate(
respondNotFound(response);
return;
}
+ const Card form_card = **card_result;
if(!request.is_multipart_form_data())
{
respondBadRequest(response, "Expected a multipart form upload");
@@ -1759,8 +3265,13 @@ void App::handleCardUpdate(
auto upload = multipart_reader.read(content_reader);
if(!upload)
{
- respondOperationError(
- response, upload.error(), "Failed to receive a card edit");
+ respondMutationError(
+ response,
+ upload.error(),
+ "Failed to receive a card edit",
+ urlFor("card-edit", {id_parameter->second}),
+ actor->session.user,
+ true);
return;
}
const auto csrf = upload->fields.find("csrf_token");
@@ -1881,10 +3392,19 @@ void App::handleCardUpdate(
mw::runtimeError("Card update was not dispatched"));
if(!input.current_card.identity.game_short_name)
{
- if(!upload->series_ids.empty())
+ if(upload->fields.contains("game_revision") ||
+ !gameFields(upload->fields).empty() ||
+ !upload->series_ids.empty())
{
- respondBadRequest(
- response, "Loose cards cannot belong to a series");
+ respondMutationError(
+ response,
+ mw::httpError(
+ 422,
+ "Loose cards cannot have game fields or series"),
+ "Failed to validate loose card fields",
+ urlFor("card-edit", {id_parameter->second}),
+ actor->session.user,
+ true);
return;
}
updated = card_service_->updateLooseCard(
@@ -1892,45 +3412,54 @@ void App::handleCardUpdate(
}
else
{
- const std::string& game_short_name =
- *input.current_card.identity.game_short_name;
- const GameDefinition* definition = games_->find(game_short_name);
- if(definition == nullptr)
- {
- respondInternalError(response);
- return;
- }
- auto metadata = definition->validateMetadata(
- gameFields(upload->fields));
- if(!metadata)
+ auto game_revision = parseGameRevision(upload->fields);
+ if(!game_revision)
{
- respondOperationError(
+ respondMutationError(
response,
- metadata.error(),
- "Failed to validate game metadata");
+ game_revision.error(),
+ "Failed to parse game revision",
+ urlFor("card-edit", {id_parameter->second}),
+ actor->session.user,
+ true);
return;
}
- auto membership_ids = seriesMemberships(
- upload->series_ids, game_short_name, *data_source_);
+ auto membership_ids = seriesMemberships(upload->series_ids);
if(!membership_ids)
{
- respondOperationError(
+ respondMutationError(
response,
membership_ids.error(),
- "Failed to validate series memberships");
+ "Failed to validate series memberships",
+ urlFor("card-edit", {id_parameter->second}),
+ actor->session.user,
+ true);
return;
}
updated = card_service_->updateGameCard(
actor->session.user.id,
std::move(input),
- *definition,
- **metadata,
+ *game_revision,
+ gameFields(upload->fields),
*membership_ids);
}
if(!updated)
{
- respondOperationError(
- response, updated.error(), "Failed to update a card");
+ const auto* field_error =
+ updated.error().as<GameFieldValidationError>();
+ if(field_error != nullptr)
+ {
+ respondCardFieldError(
+ response, *field_error, *upload, *actor, &form_card);
+ return;
+ }
+ respondMutationError(
+ response,
+ updated.error(),
+ "Failed to update a card",
+ urlFor("card-edit", {id_parameter->second}),
+ actor->session.user,
+ true);
return;
}
@@ -2051,32 +3580,37 @@ void App::handleCardView(
std::string game_name = "Loose card";
if(card.identity.game_short_name)
{
- const GameDefinition* game = games_->find(
- *card.identity.game_short_name);
- if(game == nullptr)
+ auto fields = data_source_->getCardFieldValues(card.id);
+ if(!fields)
{
spdlog::error(
- "Card {} uses an unregistered compiled game",
- card.id);
+ "Failed to load game fields for card {}: {}",
+ card.id, fields.error().msg());
respondInternalError(response);
return;
}
- game_name = std::string(game->displayName());
- auto fields = data_source_->getGameDisplayFields(*game, card.id);
- if(!fields)
+ if(!*fields)
{
- spdlog::error(
- "Failed to load game fields for card {}: {}",
- card.id,
- fields.error().msg());
respondInternalError(response);
return;
}
- for(const DisplayField& field : *fields)
+ game_name = (**fields).definition.game.display_name;
+ for(const GameFieldValue& value : (**fields).values)
{
+ const auto definition = std::ranges::find_if(
+ (**fields).definition.fields,
+ [&value](const GameField& field)
+ {
+ return field.id == value.field_id;
+ });
+ if(definition == (**fields).definition.fields.end())
+ {
+ respondInternalError(response);
+ return;
+ }
game_fields.push_back({
- {"label", field.label},
- {"value", field.value},
+ {"label", definition->label},
+ {"value", fieldValue({value}, value.field_id)},
});
}
}
@@ -2503,9 +4037,20 @@ void App::handleSeriesIndex(
}
inja::json series = inja::json::array();
std::vector<HtmlSubstitution> html_substitutions;
+ auto games_result = data_source_->getGames();
+ if(!games_result)
+ {
+ respondInternalError(response);
+ return;
+ }
for(const Series& item : *series_result)
{
- const GameDefinition* game = games_->find(item.game_short_name);
+ const auto game = std::ranges::find_if(
+ *games_result,
+ [&item](const Game& candidate)
+ {
+ return candidate.short_name == item.game_short_name;
+ });
auto rendered = MarkdownRenderer().render(item.description);
if(!rendered)
{
@@ -2526,14 +4071,14 @@ void App::handleSeriesIndex(
{"has_description", !item.description.empty()},
{"edit_url",
urlFor("series-edit", {std::to_string(item.id)})},
- {"game", game == nullptr
+ {"game", game == games_result->end()
? uppercaseAscii(item.game_short_name)
- : std::string(game->displayName())},
+ : game->display_name},
{"name", item.name},
});
}
inja::json template_data = {
- {"can_create", !games_->games().empty()},
+ {"can_create", !games_result->empty()},
{"create_url", urlFor("series-new")},
{"series", std::move(series)},
{"title", "Series · Card Collection"},
@@ -2576,11 +4121,17 @@ void App::handleSeriesNew(
return;
}
inja::json games = inja::json::array();
- for(const GameDefinition* game : games_->games())
+ auto games_result = data_source_->getGames();
+ if(!games_result)
+ {
+ respondInternalError(response);
+ return;
+ }
+ for(const Game& game : *games_result)
{
games.push_back({
- {"display_name", std::string(game->displayName())},
- {"short_name", std::string(game->shortName())},
+ {"display_name", game.display_name},
+ {"short_name", game.short_name},
});
}
inja::json template_data = {
@@ -2682,15 +4233,18 @@ void App::handleSeriesEdit(
return;
}
const Series& series = **series_result;
- const GameDefinition* game = games_->find(series.game_short_name);
+ auto game = data_source_->getGameDefinition(series.game_short_name);
+ if(!game || !*game)
+ {
+ respondInternalError(response);
+ return;
+ }
inja::json template_data = {
{"action_url", urlFor("series-item", {std::to_string(*id)})},
{"back_url", urlFor("series-index")},
{"csrf_token", actor->session.csrf_token},
{"description", series.description},
- {"game", game == nullptr
- ? uppercaseAscii(series.game_short_name)
- : std::string(game->displayName())},
+ {"game", (**game).game.display_name},
{"games", inja::json::array()},
{"heading", "Edit series"},
{"mode", "edit"},
@@ -2923,6 +4477,45 @@ void App::setup()
server.Get(
getPath("admin-games"),
std::bind_front(&App::handleAdminGames, this));
+ server.Get(
+ getPath("game-new"),
+ std::bind_front(&App::handleGameNew, this));
+ server.Post(
+ getPath("games"),
+ std::bind_front(&App::handleGameCreate, this));
+ server.Get(
+ getPath("game-edit", {"short"}),
+ std::bind_front(&App::handleGameEdit, this));
+ server.Post(
+ getPath("game-update", {"short"}),
+ std::bind_front(&App::handleGameUpdate, this));
+ server.Get(
+ getPath("game-delete", {"short"}),
+ std::bind_front(&App::handleGameDeleteConfirm, this));
+ server.Post(
+ getPath("game-delete", {"short"}),
+ std::bind_front(&App::handleGameDelete, this));
+ server.Get(
+ getPath("game-field-new", {"short"}),
+ std::bind_front(&App::handleGameFieldNew, this));
+ server.Post(
+ getPath("game-fields", {"short"}),
+ std::bind_front(&App::handleGameFieldCreate, this));
+ server.Get(
+ getPath("game-field-edit", {"short", "id"}),
+ std::bind_front(&App::handleGameFieldEdit, this));
+ server.Post(
+ getPath("game-field-update", {"short", "id"}),
+ std::bind_front(&App::handleGameFieldUpdate, this));
+ server.Get(
+ getPath("game-field-delete", {"short", "id"}),
+ std::bind_front(&App::handleGameFieldDeleteConfirm, this));
+ server.Post(
+ getPath("game-field-delete", {"short", "id"}),
+ std::bind_front(&App::handleGameFieldDelete, this));
+ server.Post(
+ getPath("game-field-order", {"short"}),
+ std::bind_front(&App::handleGameFieldOrder, this));
server.Post(
getPath("admin-promote", {"id"}),
std::bind_front(&App::handleAdminPromote, this));
diff --git a/src/app.h b/src/app.h
index 4d5eacb..3cfe9a3 100644
--- a/src/app.h
+++ b/src/app.h
@@ -16,12 +16,15 @@
#include "config.h"
#include "data.h"
#include "email_sender.h"
-#include "game_registry.h"
+#include "game_service.h"
#include "non_secret_random.h"
#include "series_service.h"
#include "url_builder.h"
#include "user_service.h"
+struct CardUpload;
+struct GameFieldValidationError;
+
/// Card Collection HTTP application and named-route owner.
class App : public mw::HTTPServer
{
@@ -42,7 +45,6 @@ public:
App(
const Config& config,
std::unique_ptr<DataSourceInterface> data_source,
- std::unique_ptr<GameRegistry> games,
std::unique_ptr<NonSecretRandom> random);
/// Return the absolute URL for a named application route.
@@ -107,6 +109,46 @@ public:
/// Render the administrator's installed-game inventory.
void handleAdminGames(const Request& request, Response& response);
+ /// Render the create-game form.
+ void handleGameNew(const Request& request, Response& response);
+
+ /// Create a database-defined game.
+ void handleGameCreate(const Request& request, Response& response);
+
+ /// Render one game and its field-definition editor.
+ void handleGameEdit(const Request& request, Response& response);
+
+ /// Update one game's mutable metadata.
+ void handleGameUpdate(const Request& request, Response& response);
+
+ /// Render a game-delete confirmation.
+ void handleGameDeleteConfirm(const Request& request, Response& response);
+
+ /// Delete an unused game.
+ void handleGameDelete(const Request& request, Response& response);
+
+ /// Render the add-field form for one game.
+ void handleGameFieldNew(const Request& request, Response& response);
+
+ /// Add one custom field to a game.
+ void handleGameFieldCreate(const Request& request, Response& response);
+
+ /// Render one custom-field editor.
+ void handleGameFieldEdit(const Request& request, Response& response);
+
+ /// Update one custom field and its choices.
+ void handleGameFieldUpdate(const Request& request, Response& response);
+
+ /// Render a custom-field delete confirmation.
+ void handleGameFieldDeleteConfirm(
+ const Request& request, Response& response);
+
+ /// Delete one unused custom field.
+ void handleGameFieldDelete(const Request& request, Response& response);
+
+ /// Save the complete custom-field ordering for one game.
+ void handleGameFieldOrder(const Request& request, Response& response);
+
/// Permanently promote one player to creator.
void handleAdminPromote(const Request& request, Response& response);
@@ -196,6 +238,45 @@ private:
/// Add role-aware authenticated navigation links to template data.
void addNavigationData(inja::json& data, const User& user) const;
+ /// Render an expected mutation failure with a safe retry destination.
+ void respondMutationError(
+ Response& response,
+ const mw::Error& error,
+ std::string_view operation,
+ const std::string& reload_url,
+ const User& user,
+ bool files_need_reselection = false);
+
+ /// Re-render a card form after custom-field validation fails.
+ void respondCardFieldError(
+ Response& response,
+ const GameFieldValidationError& error,
+ const CardUpload& upload,
+ const RequestIdentity& identity,
+ const Card* card = nullptr);
+
+ /// Re-render a game metadata form after definition validation fails.
+ void respondGameDefinitionError(
+ Response& response,
+ const GameDefinitionValidationError& error,
+ const RequestIdentity& identity,
+ const std::string& short_name,
+ const std::string& display_name,
+ const std::string& description,
+ bool editing);
+
+ /// Re-render a field editor after definition validation fails.
+ void respondGameFieldDefinitionError(
+ Response& response,
+ const GameDefinitionValidationError& error,
+ const RequestIdentity& identity,
+ const std::string& short_name,
+ std::optional<std::int64_t> field_id,
+ const std::string& key,
+ const std::string& label,
+ const std::string& type,
+ const std::vector<std::string>& choices);
+
/// Set or expire a host-only application cookie.
void setCookie(
Response& response,
@@ -206,7 +287,6 @@ private:
Config config_;
std::unique_ptr<DataSourceInterface> data_source_;
- std::unique_ptr<GameRegistry> games_;
std::unique_ptr<NonSecretRandom> random_;
std::unique_ptr<ClockInterface> clock_;
std::unique_ptr<mw::CryptoInterface> crypto_;
@@ -216,12 +296,14 @@ private:
std::unique_ptr<CollectionService> collection_service_;
std::unique_ptr<CardService> card_service_;
std::unique_ptr<SeriesService> series_service_;
+ std::unique_ptr<GameService> game_service_;
UrlBuilder url_builder_;
inja::Environment templates_;
inja::Template card_form_template_;
inja::Template card_delete_template_;
inja::Template card_index_template_;
inja::Template card_view_template_;
+ inja::Template game_admin_template_;
inja::Template series_delete_template_;
inja::Template series_form_template_;
inja::Template series_index_template_;
diff --git a/src/card.h b/src/card.h
index 5b81b44..bb01659 100644
--- a/src/card.h
+++ b/src/card.h
@@ -7,7 +7,7 @@
/// Persisted components from which a public card ID is derived.
struct CardIdentity
{
- /// Lowercase compiled game name, or null for a loose card.
+ /// Lowercase database game short name, or null for a loose card.
std::optional<std::string> game_short_name;
/// Sequential game number or random loose-card number.
diff --git a/src/card_service.cpp b/src/card_service.cpp
index 7edfe13..89ac64c 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -8,7 +8,7 @@
#include <utility>
#include "public_id.h"
-#include "game_definition.h"
+#include "game_field.h"
namespace
{
@@ -51,29 +51,32 @@ mw::E<std::string> CardService::createLooseCard(
std::int64_t actor_user_id, CreateLooseCardInput input)
{
return createCard(
- actor_user_id, std::move(input), nullptr, nullptr, {});
+ actor_user_id, std::move(input), std::nullopt, std::nullopt, {}, {});
}
mw::E<std::string> CardService::createGameCard(
std::int64_t actor_user_id,
CreateCardInput input,
- const GameDefinition& game,
- const GameCardMetadata& metadata,
+ std::string game_short_name,
+ std::int64_t expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
return createCard(
actor_user_id,
std::move(input),
- &game,
- &metadata,
+ std::move(game_short_name),
+ expected_game_revision,
+ std::move(submitted_fields),
series_ids);
}
mw::E<std::string> CardService::createCard(
std::int64_t actor_user_id,
CreateCardInput input,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
+ std::optional<std::string> game_short_name,
+ std::optional<std::int64_t> expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
if(input.short_description)
@@ -171,11 +174,59 @@ mw::E<std::string> CardService::createCard(
400, "Creators cannot submit card rarity"));
}
+ std::vector<GameFieldValue> field_values;
+ if(game_short_name)
+ {
+ if(!expected_game_revision)
+ {
+ return std::unexpected(mw::httpError(
+ 400, "Game revision is required"));
+ }
+ auto definition = (*transaction)->getGameDefinitionForUpdate(
+ *game_short_name);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ if(!*definition)
+ {
+ return std::unexpected(mw::httpError(422, "Unknown game"));
+ }
+ if((**definition).game.revision != *expected_game_revision)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "The game definition changed; reload and try again"));
+ }
+ auto decoded = decodeGameFields(**definition, submitted_fields);
+ if(!decoded)
+ {
+ return std::unexpected(std::move(decoded.error()));
+ }
+ field_values = std::move(*decoded);
+ auto memberships = (*transaction)->seriesBelongToGame(
+ *game_short_name, series_ids);
+ if(!memberships)
+ {
+ return std::unexpected(std::move(memberships.error()));
+ }
+ if(!*memberships)
+ {
+ return std::unexpected(mw::httpError(
+ 422, "A selected series belongs to another game"));
+ }
+ }
+ else if(expected_game_revision || !submitted_fields.empty() ||
+ !series_ids.empty())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Loose cards cannot have game fields or series"));
+ }
+
std::uint64_t number = 0;
- if(game != nullptr)
+ if(game_short_name)
{
auto allocated = (*transaction)->allocateGameNumber(
- std::string(game->shortName()));
+ *game_short_name);
if(!allocated)
{
return std::unexpected(std::move(allocated.error()));
@@ -209,10 +260,7 @@ mw::E<std::string> CardService::createCard(
Card card = {
0,
- {game == nullptr
- ? std::nullopt
- : std::optional<std::string>(game->shortName()),
- number},
+ {game_short_name, number},
std::move(input.name),
std::move(input.short_description),
std::move(input.long_description),
@@ -224,7 +272,7 @@ mw::E<std::string> CardService::createCard(
actor_user_id,
};
auto inserted = (*transaction)->insertCard(
- card, game, metadata, series_ids);
+ card, field_values, series_ids);
if(!inserted)
{
return std::unexpected(std::move(inserted.error()));
@@ -255,29 +303,29 @@ mw::E<std::string> CardService::updateLooseCard(
std::int64_t actor_user_id, UpdateLooseCardInput input)
{
return updateCard(
- actor_user_id, std::move(input), nullptr, nullptr, {});
+ actor_user_id, std::move(input), std::nullopt, {}, {});
}
mw::E<std::string> CardService::updateGameCard(
std::int64_t actor_user_id,
UpdateLooseCardInput input,
- const GameDefinition& game,
- const GameCardMetadata& metadata,
+ std::int64_t expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
return updateCard(
actor_user_id,
std::move(input),
- &game,
- &metadata,
+ expected_game_revision,
+ std::move(submitted_fields),
series_ids);
}
mw::E<std::string> CardService::updateCard(
std::int64_t actor_user_id,
UpdateLooseCardInput input,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
+ std::optional<std::int64_t> expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids)
{
auto stored = data_source_.getCard(input.current_card.identity);
@@ -307,13 +355,10 @@ mw::E<std::string> CardService::updateCard(
}
}
const bool is_game_card = card.identity.game_short_name.has_value();
- if(card.id <= 0 || is_game_card != (game != nullptr) ||
- (game == nullptr) != (metadata == nullptr) ||
- (game != nullptr &&
- game->shortName() != *card.identity.game_short_name))
+ if(card.id <= 0 || is_game_card != expected_game_revision.has_value())
{
return std::unexpected(mw::httpError(
- 400, "Card identity does not match its compiled game"));
+ 400, "Card identity does not match its game definition"));
}
if(input.expected_revision != card.revision)
{
@@ -503,8 +548,51 @@ mw::E<std::string> CardService::updateCard(
? (**current).rarity
: input.rarity;
+ std::vector<GameFieldValue> field_values;
+ if(card.identity.game_short_name)
+ {
+ auto definition = (*transaction)->getGameDefinitionForUpdate(
+ *card.identity.game_short_name);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ if(!*definition)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Card references a missing game"));
+ }
+ if((**definition).game.revision != *expected_game_revision)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "The game definition changed; reload and try again"));
+ }
+ auto decoded = decodeGameFields(**definition, submitted_fields);
+ if(!decoded)
+ {
+ return std::unexpected(std::move(decoded.error()));
+ }
+ field_values = std::move(*decoded);
+ auto memberships = (*transaction)->seriesBelongToGame(
+ *card.identity.game_short_name, series_ids);
+ if(!memberships)
+ {
+ return std::unexpected(std::move(memberships.error()));
+ }
+ if(!*memberships)
+ {
+ return std::unexpected(mw::httpError(
+ 422, "A selected series belongs to another game"));
+ }
+ }
+ else if(!submitted_fields.empty() || !series_ids.empty())
+ {
+ return std::unexpected(mw::httpError(
+ 422, "Loose cards cannot have game fields or series"));
+ }
+
auto updated = (*transaction)->updateCard(
- card, game, metadata, series_ids);
+ card, field_values, series_ids);
if(!updated)
{
return std::unexpected(std::move(updated.error()));
diff --git a/src/card_service.h b/src/card_service.h
index b7ac375..9df173f 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -126,8 +126,9 @@ public:
mw::E<std::string> createGameCard(
std::int64_t actor_user_id,
CreateCardInput input,
- const GameDefinition& game,
- const GameCardMetadata& metadata,
+ std::string game_short_name,
+ std::int64_t expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids);
/// Update a loose card after re-reading role and authorship.
@@ -138,8 +139,8 @@ public:
mw::E<std::string> updateGameCard(
std::int64_t actor_user_id,
UpdateLooseCardInput input,
- const GameDefinition& game,
- const GameCardMetadata& metadata,
+ std::int64_t expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids);
/// Delete a card only after re-reading an administrator actor.
@@ -147,20 +148,21 @@ public:
std::int64_t actor_user_id, const Card& card);
private:
- /// Create a loose or compiled-game card through the common pipeline.
+ /// Create a loose or database-defined game card through one pipeline.
mw::E<std::string> createCard(
std::int64_t actor_user_id,
CreateCardInput input,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
+ std::optional<std::string> game_short_name,
+ std::optional<std::int64_t> expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids);
- /// Update a loose or compiled-game card through the common pipeline.
+ /// Update a loose or database-defined game card through one pipeline.
mw::E<std::string> updateCard(
std::int64_t actor_user_id,
UpdateLooseCardInput input,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
+ std::optional<std::int64_t> expected_game_revision,
+ SubmittedGameFields submitted_fields,
const std::vector<std::int64_t>& series_ids);
DataSourceInterface& data_source_;
diff --git a/src/data.cpp b/src/data.cpp
index b1c97f5..1c61c05 100644
--- a/src/data.cpp
+++ b/src/data.cpp
@@ -26,6 +26,126 @@ mw::Error migrationVersionError(
} // namespace
+mw::E<std::int64_t> DataSourceTransactionInterface::insertCard(
+ [[maybe_unused]] const Card& card,
+ [[maybe_unused]] const std::vector<GameFieldValue>& field_values,
+ [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+{
+ return std::unexpected(mw::runtimeError(
+ "Dynamic game card persistence is unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updateCard(
+ [[maybe_unused]] const Card& card,
+ [[maybe_unused]] const std::vector<GameFieldValue>& field_values,
+ [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+{
+ return std::unexpected(mw::runtimeError(
+ "Dynamic game card persistence is unavailable"));
+}
+
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceTransactionInterface::getGameDefinitionForUpdate(
+ [[maybe_unused]] const std::string& short_name)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<GameUsage> DataSourceTransactionInterface::getGameUsage(
+ [[maybe_unused]] const std::string& short_name)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::insertGame(
+ [[maybe_unused]] const Game& game)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::updateGame(
+ [[maybe_unused]] const std::string& short_name,
+ [[maybe_unused]] const std::string& display_name,
+ [[maybe_unused]] const std::string& description,
+ [[maybe_unused]] std::int64_t expected_revision)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::deleteGame(
+ [[maybe_unused]] const std::string& short_name)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::insertGameField(
+ [[maybe_unused]] const GameField& field,
+ [[maybe_unused]] const std::vector<GameChoice>& choices)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updateGameField(
+ [[maybe_unused]] std::int64_t field_id,
+ [[maybe_unused]] const std::string& label,
+ [[maybe_unused]] const std::vector<GameChoice>& choices)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::deleteGameField(
+ [[maybe_unused]] std::int64_t field_id)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::countFieldUsage(
+ [[maybe_unused]] std::int64_t field_id)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::countChoiceUsage(
+ [[maybe_unused]] std::int64_t field_id,
+ [[maybe_unused]] const std::string& value)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updateGameFieldOrder(
+ [[maybe_unused]] const std::string& short_name,
+ [[maybe_unused]] const std::vector<std::int64_t>& field_ids)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::incrementGameRevision(
+ [[maybe_unused]] const std::string& short_name,
+ [[maybe_unused]] std::int64_t expected_revision)
+{
+ return std::unexpected(mw::runtimeError(
+ "Game definition transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::seriesBelongToGame(
+ [[maybe_unused]] const std::string& short_name,
+ [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+{
+ return std::unexpected(mw::runtimeError(
+ "Transactional series validation is unavailable"));
+}
+
mw::E<std::optional<User>>
DataSourceTransactionInterface::getUserForUpdate(
[[maybe_unused]] std::int64_t user_id)
@@ -252,7 +372,26 @@ mw::E<void> DataSourceInterface::cleanupAuthentication(
"Authentication data is unavailable"));
}
-mw::E<void> DataSourceInterface::migrateToLatest(const GameRegistry& games)
+mw::E<std::vector<Game>> DataSourceInterface::getGames() const
+{
+ return std::unexpected(mw::runtimeError("Game data is unavailable"));
+}
+
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceInterface::getGameDefinition(
+ [[maybe_unused]] const std::string& short_name) const
+{
+ return std::unexpected(mw::runtimeError("Game data is unavailable"));
+}
+
+mw::E<std::optional<CardGameFields>>
+DataSourceInterface::getCardFieldValues(
+ [[maybe_unused]] std::int64_t card_id) const
+{
+ return std::unexpected(mw::runtimeError("Game data is unavailable"));
+}
+
+mw::E<void> DataSourceInterface::migrateToLatest()
{
auto version_result = getSchemaVersion();
if(!version_result)
@@ -269,7 +408,7 @@ mw::E<void> DataSourceInterface::migrateToLatest(const GameRegistry& games)
while(version < DB_SCHEMA_VERSION)
{
const std::int64_t expected_version = version + 1;
- mw::E<void> migration_result = migrateSchema0To1(games);
+ mw::E<void> migration_result = migrateSchema0To1();
if(!migration_result)
{
return std::unexpected(std::move(migration_result.error()));
diff --git a/src/data.h b/src/data.h
index 110c8f6..23c35ff 100644
--- a/src/data.h
+++ b/src/data.h
@@ -10,7 +10,6 @@
#include "card.h"
#include "game.h"
-#include "game_definition.h"
#include "user.h"
/// Raw SHA-256 credential digest stored as a 32-byte SQLite BLOB.
@@ -76,9 +75,6 @@ struct AuthenticationReservation
std::int64_t retry_after;
};
-/// Immutable collection of compiled game definitions.
-class GameRegistry;
-
/// Latest schema version implemented by this binary.
inline constexpr std::int64_t DB_SCHEMA_VERSION = 1;
@@ -93,10 +89,66 @@ public:
virtual mw::E<std::uint64_t>
allocateGameNumber(const std::string& game_short_name) = 0;
- /// Ensure a registered game has a persistent sequence row.
+ /// Ensure a database-defined game has a persistent sequence row.
virtual mw::E<void>
ensureGameSequence(const std::string& game_short_name) = 0;
+ /// Read a complete game definition while holding the writer lock.
+ virtual mw::E<std::optional<GameDefinitionSnapshot>>
+ getGameDefinitionForUpdate(const std::string& short_name);
+
+ /// Return deletion-relevant usage for one game.
+ virtual mw::E<GameUsage> getGameUsage(const std::string& short_name);
+
+ /// Insert a game and its zero-valued number sequence.
+ virtual mw::E<void> insertGame(const Game& game);
+
+ /// Replace mutable game metadata and advance a matching revision.
+ virtual mw::E<bool> updateGame(
+ const std::string& short_name,
+ const std::string& display_name,
+ const std::string& description,
+ std::int64_t expected_revision);
+
+ /// Delete an unused game, its fields, choices, and sequence.
+ virtual mw::E<void> deleteGame(const std::string& short_name);
+
+ /// Insert a field and its initial ordered choices.
+ virtual mw::E<std::int64_t> insertGameField(
+ const GameField& field,
+ const std::vector<GameChoice>& choices);
+
+ /// Replace a field label and its complete ordered choice list.
+ virtual mw::E<void> updateGameField(
+ std::int64_t field_id,
+ const std::string& label,
+ const std::vector<GameChoice>& choices);
+
+ /// Delete a field that has no stored card values.
+ virtual mw::E<void> deleteGameField(std::int64_t field_id);
+
+ /// Return the number of cards with a value for a field.
+ virtual mw::E<std::int64_t> countFieldUsage(std::int64_t field_id);
+
+ /// Return the number of cards using one exact choice.
+ virtual mw::E<std::int64_t> countChoiceUsage(
+ std::int64_t field_id, const std::string& value);
+
+ /// Replace every field position for a game.
+ virtual mw::E<void> updateGameFieldOrder(
+ const std::string& short_name,
+ const std::vector<std::int64_t>& field_ids);
+
+ /// Return whether every requested series belongs to one game.
+ virtual mw::E<bool> seriesBelongToGame(
+ const std::string& short_name,
+ const std::vector<std::int64_t>& series_ids);
+
+ /// Advance an aggregate definition revision if it is still current.
+ virtual mw::E<bool> incrementGameRevision(
+ const std::string& short_name,
+ std::int64_t expected_revision);
+
/// Return whether a loose-card number already exists.
virtual mw::E<bool> looseNumberExists(std::uint32_t number) = 0;
@@ -187,19 +239,17 @@ public:
virtual mw::E<std::int64_t> incrementHolding(
std::int64_t user_id, std::int64_t card_id);
- /// Insert common, game-specific, and series-membership card rows.
+ /// Insert a card with generic custom values and series memberships.
virtual mw::E<std::int64_t> insertCard(
const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
- const std::vector<std::int64_t>& series_ids) = 0;
+ const std::vector<GameFieldValue>& field_values,
+ const std::vector<std::int64_t>& series_ids);
- /// Replace a card's common, game-specific, and membership rows.
+ /// Replace a card and all of its generic custom values and memberships.
virtual mw::E<void> updateCard(
const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
- const std::vector<std::int64_t>& series_ids) = 0;
+ const std::vector<GameFieldValue>& field_values,
+ const std::vector<std::int64_t>& series_ids);
/// Delete a card and its dependent database rows.
virtual mw::E<void> deleteCard(std::int64_t card_id) = 0;
@@ -227,12 +277,11 @@ public:
/// Return the stored schema version.
virtual mw::E<std::int64_t> getSchemaVersion() const = 0;
- /// Apply every required migration in order through the current version.
- mw::E<void> migrateToLatest(const GameRegistry& games);
+ /// Apply every required migration through the current schema version.
+ mw::E<void> migrateToLatest();
- /// Create schema version 1 from an empty version-0 database.
- virtual mw::E<void>
- migrateSchema0To1(const GameRegistry& games) = 0;
+ /// Migrate an empty version-0 database to schema version 1.
+ virtual mw::E<void> migrateSchema0To1() = 0;
/// Start an immediate transaction with exclusive mutation ownership.
virtual mw::E<std::unique_ptr<DataSourceTransactionInterface>>
@@ -252,6 +301,17 @@ public:
virtual mw::E<std::optional<Card>>
getCard(const CardIdentity& identity) const = 0;
+ /// Return games ordered by display name and then short name.
+ virtual mw::E<std::vector<Game>> getGames() const;
+
+ /// Return a complete, consistently read game definition.
+ virtual mw::E<std::optional<GameDefinitionSnapshot>>
+ getGameDefinition(const std::string& short_name) const;
+
+ /// Return a card's definition and ordered custom values together.
+ virtual mw::E<std::optional<CardGameFields>>
+ getCardFieldValues(std::int64_t card_id) const;
+
/// Return a user by internal identity.
virtual mw::E<std::optional<User>> getUser(
std::int64_t user_id) const;
@@ -290,16 +350,6 @@ public:
/// Best-effort removal of obsolete authentication persistence rows.
virtual mw::E<void> cleanupAuthentication(std::int64_t now);
- /// Return a card's game-owned display fields.
- virtual mw::E<std::vector<DisplayField>> getGameDisplayFields(
- const GameDefinition& game,
- std::int64_t card_id) const = 0;
-
- /// Return current game-owned values for the edit form.
- virtual mw::E<FormFields> getGameFormValues(
- const GameDefinition& game,
- std::int64_t card_id) const = 0;
-
/// Return all series, ordered by game and name.
virtual mw::E<std::vector<Series>> getSeries() const = 0;
@@ -311,10 +361,6 @@ public:
virtual mw::E<std::vector<std::int64_t>>
getCardSeries(std::int64_t card_id) const = 0;
- /// Return every persisted game name used for startup reconciliation.
- virtual mw::E<std::vector<std::string>>
- getPersistedGameNames() const = 0;
-
protected:
/// Set the schema version inside a concrete migration transaction.
virtual mw::E<void> setSchemaVersion(std::int64_t version) = 0;
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
index 674c68c..caa43ac 100644
--- a/src/data_fake.cpp
+++ b/src/data_fake.cpp
@@ -1,7 +1,6 @@
#include "data_fake.h"
#include <algorithm>
-#include <set>
#include <string_view>
#include <utility>
@@ -27,11 +26,13 @@ DataSourceFake::DataSourceFake(
std::vector<Card> cards,
std::vector<Series> series,
std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series,
- std::unordered_map<std::int64_t, std::vector<DisplayField>> display_fields)
+ std::vector<GameDefinitionSnapshot> game_definitions,
+ std::unordered_map<std::int64_t, std::vector<GameFieldValue>> field_values)
: cards_(std::move(cards)),
series_(std::move(series)),
card_series_(std::move(card_series)),
- display_fields_(std::move(display_fields))
+ game_definitions_(std::move(game_definitions)),
+ field_values_(std::move(field_values))
{}
mw::E<std::int64_t> DataSourceFake::getSchemaVersion() const
@@ -39,8 +40,7 @@ mw::E<std::int64_t> DataSourceFake::getSchemaVersion() const
return DB_SCHEMA_VERSION;
}
-mw::E<void> DataSourceFake::migrateSchema0To1(
- [[maybe_unused]] const GameRegistry& games)
+mw::E<void> DataSourceFake::migrateSchema0To1()
{
return std::unexpected(readOnlyError("migrate the schema"));
}
@@ -72,23 +72,65 @@ mw::E<std::optional<Card>> DataSourceFake::getCard(
return *card;
}
-mw::E<std::vector<DisplayField>> DataSourceFake::getGameDisplayFields(
- [[maybe_unused]] const GameDefinition& game,
- std::int64_t card_id) const
+mw::E<std::vector<Game>> DataSourceFake::getGames() const
{
- const auto fields = display_fields_.find(card_id);
- if(fields == display_fields_.end())
+ std::vector<Game> games;
+ games.reserve(game_definitions_.size());
+ for(const GameDefinitionSnapshot& definition : game_definitions_)
{
- return std::vector<DisplayField>{};
+ games.push_back(definition.game);
}
- return fields->second;
+ std::ranges::sort(
+ games,
+ [](const Game& left, const Game& right)
+ {
+ if(left.display_name != right.display_name)
+ {
+ return left.display_name < right.display_name;
+ }
+ return left.short_name < right.short_name;
+ });
+ return games;
}
-mw::E<FormFields> DataSourceFake::getGameFormValues(
- [[maybe_unused]] const GameDefinition& game,
- [[maybe_unused]] std::int64_t card_id) const
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceFake::getGameDefinition(const std::string& short_name) const
{
- return FormFields{};
+ const auto definition = std::ranges::find_if(
+ game_definitions_,
+ [&short_name](const GameDefinitionSnapshot& candidate)
+ {
+ return candidate.game.short_name == short_name;
+ });
+ if(definition == game_definitions_.end())
+ {
+ return std::nullopt;
+ }
+ return *definition;
+}
+
+mw::E<std::optional<CardGameFields>> DataSourceFake::getCardFieldValues(
+ std::int64_t card_id) const
+{
+ const auto card = std::ranges::find_if(
+ cards_,
+ [card_id](const Card& candidate) { return candidate.id == card_id; });
+ if(card == cards_.end() || !card->identity.game_short_name)
+ {
+ return std::nullopt;
+ }
+ auto definition = getGameDefinition(*card->identity.game_short_name);
+ if(!definition || !*definition)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Fake card references a missing game"));
+ }
+ const auto values = field_values_.find(card_id);
+ return CardGameFields{
+ std::move(**definition),
+ values == field_values_.end()
+ ? std::vector<GameFieldValue>{}
+ : values->second};
}
mw::E<std::vector<Series>> DataSourceFake::getSeries() const
@@ -123,24 +165,6 @@ mw::E<std::vector<std::int64_t>> DataSourceFake::getCardSeries(
return memberships->second;
}
-mw::E<std::vector<std::string>>
-DataSourceFake::getPersistedGameNames() const
-{
- std::set<std::string> names;
- for(const Card& card : cards_)
- {
- if(card.identity.game_short_name)
- {
- names.insert(*card.identity.game_short_name);
- }
- }
- for(const Series& series : series_)
- {
- names.insert(series.game_short_name);
- }
- return std::vector<std::string>(names.begin(), names.end());
-}
-
mw::E<void> DataSourceFake::setSchemaVersion(
[[maybe_unused]] std::int64_t version)
{
diff --git a/src/data_fake.h b/src/data_fake.h
index 13db8e0..70893e7 100644
--- a/src/data_fake.h
+++ b/src/data_fake.h
@@ -19,15 +19,15 @@ public:
std::vector<Series> series = {},
std::unordered_map<std::int64_t, std::vector<std::int64_t>>
card_series = {},
- std::unordered_map<std::int64_t, std::vector<DisplayField>>
- display_fields = {});
+ std::vector<GameDefinitionSnapshot> game_definitions = {},
+ std::unordered_map<std::int64_t, std::vector<GameFieldValue>>
+ field_values = {});
/// Return the latest schema version represented by the fake.
mw::E<std::int64_t> getSchemaVersion() const override;
- /// Reject migration because the fake always represents the latest schema.
- mw::E<void>
- migrateSchema0To1(const GameRegistry& games) override;
+ /// Reject migration because the fake already has the current schema.
+ mw::E<void> migrateSchema0To1() override;
/// Reject mutation transactions because the fake is read-only.
mw::E<std::unique_ptr<DataSourceTransactionInterface>>
@@ -40,14 +40,15 @@ public:
mw::E<std::optional<Card>>
getCard(const CardIdentity& identity) const override;
- /// Return configured display fields for a card.
- mw::E<std::vector<DisplayField>> getGameDisplayFields(
- const GameDefinition& game,
- std::int64_t card_id) const override;
+ /// Return configured games ordered by display name and short name.
+ mw::E<std::vector<Game>> getGames() const override;
+
+ /// Return one configured complete game definition.
+ mw::E<std::optional<GameDefinitionSnapshot>> getGameDefinition(
+ const std::string& short_name) const override;
- /// Return no editable game values from the read-only fake.
- mw::E<FormFields> getGameFormValues(
- const GameDefinition& game,
+ /// Return a configured card's game definition and values.
+ mw::E<std::optional<CardGameFields>> getCardFieldValues(
std::int64_t card_id) const override;
/// Return all configured series in insertion order.
@@ -61,10 +62,6 @@ public:
mw::E<std::vector<std::int64_t>>
getCardSeries(std::int64_t card_id) const override;
- /// Return the sorted unique compiled game names used by configured rows.
- mw::E<std::vector<std::string>>
- getPersistedGameNames() const override;
-
protected:
/// Reject schema mutation because the fake is read-only.
mw::E<void> setSchemaVersion(std::int64_t version) override;
@@ -73,6 +70,7 @@ private:
std::vector<Card> cards_;
std::vector<Series> series_;
std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series_;
- std::unordered_map<std::int64_t, std::vector<DisplayField>>
- display_fields_;
+ std::vector<GameDefinitionSnapshot> game_definitions_;
+ std::unordered_map<std::int64_t, std::vector<GameFieldValue>>
+ field_values_;
};
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 030165f..2989a5a 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -14,8 +14,7 @@
#include <spdlog/spdlog.h>
-#include "game_definition.h"
-#include "game_registry.h"
+#include "game_field.h"
namespace
{
@@ -34,7 +33,7 @@ mw::E<void> rollbackWithError(
return std::unexpected(std::move(error));
}
-const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
+const std::vector<std::string_view> SCHEMA_VERSION_1_STATEMENTS = {
R"sql(
CREATE TABLE application_metadata(
key TEXT PRIMARY KEY,
@@ -111,12 +110,92 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
CHECK(attempted_sends >= 0)
) STRICT;
)sql",
+ R"sql(
+ CREATE TABLE games(
+ short_name TEXT PRIMARY KEY,
+ display_name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
+ CHECK(length(short_name) > 0),
+ CHECK(short_name NOT GLOB '*[^a-z0-9]*')
+ ) STRICT;
+ )sql",
+ R"sql(
+ CREATE TABLE game_fields(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ game_short_name TEXT NOT NULL
+ REFERENCES games(short_name) ON DELETE RESTRICT,
+ key TEXT NOT NULL,
+ label TEXT NOT NULL,
+ type TEXT NOT NULL
+ CHECK(type IN ('INTEGER', 'STRING', 'CHOICE')),
+ position INTEGER NOT NULL CHECK(position >= 0),
+ UNIQUE(game_short_name, key),
+ UNIQUE(id, type),
+ CHECK(length(key) > 0)
+ ) STRICT;
+ )sql",
+ R"sql(
+ CREATE INDEX game_fields_by_game
+ ON game_fields(game_short_name, position, id);
+ )sql",
+ R"sql(
+ CREATE TABLE game_field_choices(
+ field_id INTEGER NOT NULL
+ REFERENCES game_fields(id) ON DELETE CASCADE,
+ value TEXT NOT NULL CHECK(length(value) > 0),
+ position INTEGER NOT NULL CHECK(position >= 0),
+ PRIMARY KEY(field_id, value)
+ ) WITHOUT ROWID, STRICT;
+ )sql",
+ R"sql(
+ CREATE TRIGGER game_choice_type_insert
+ BEFORE INSERT ON game_field_choices
+ WHEN NOT EXISTS(
+ SELECT 1 FROM game_fields
+ WHERE id = NEW.field_id AND type = 'CHOICE')
+ BEGIN
+ SELECT RAISE(ABORT, 'choices require a choice field');
+ END;
+ )sql",
+ R"sql(
+ CREATE TRIGGER game_choice_type_update
+ BEFORE UPDATE ON game_field_choices
+ WHEN NOT EXISTS(
+ SELECT 1 FROM game_fields
+ WHERE id = NEW.field_id AND type = 'CHOICE')
+ BEGIN
+ SELECT RAISE(ABORT, 'choices require a choice field');
+ END;
+ )sql",
+ R"sql(
+ CREATE TRIGGER games_identity_immutable
+ BEFORE UPDATE OF short_name ON games
+ BEGIN
+ SELECT RAISE(ABORT, 'game short name is immutable');
+ END;
+ )sql",
+ R"sql(
+ CREATE TRIGGER game_fields_identity_immutable
+ BEFORE UPDATE OF game_short_name, key, type ON game_fields
+ BEGIN
+ SELECT RAISE(ABORT, 'game field identity is immutable');
+ END;
+ )sql",
+ R"sql(
+ CREATE TRIGGER game_choices_identity_immutable
+ BEFORE UPDATE OF field_id, value ON game_field_choices
+ BEGIN
+ SELECT RAISE(ABORT, 'game choice identity is immutable');
+ END;
+ )sql",
R"sql(
CREATE TABLE cards (
id INTEGER PRIMARY KEY,
creator_user_id INTEGER NOT NULL
REFERENCES users(id) ON DELETE RESTRICT,
- game_short_name TEXT,
+ game_short_name TEXT
+ REFERENCES games(short_name) ON DELETE RESTRICT,
card_number INTEGER NOT NULL,
name TEXT NOT NULL,
short_description TEXT,
@@ -138,7 +217,7 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
OR
(game_short_name IS NOT NULL AND card_number >= 1)
)
- );
+ ) STRICT;
)sql",
R"sql(
CREATE UNIQUE INDEX cards_game_number_unique
@@ -158,18 +237,20 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
)sql",
R"sql(
CREATE TABLE game_sequences (
- game_short_name TEXT PRIMARY KEY,
+ game_short_name TEXT PRIMARY KEY
+ REFERENCES games(short_name) ON DELETE RESTRICT,
last_number INTEGER NOT NULL CHECK(last_number >= 0)
- );
+ ) STRICT;
)sql",
R"sql(
CREATE TABLE series (
id INTEGER PRIMARY KEY,
- game_short_name TEXT NOT NULL,
+ game_short_name TEXT NOT NULL
+ REFERENCES games(short_name) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(game_short_name, name)
- );
+ ) STRICT;
)sql",
R"sql(
CREATE TABLE card_series (
@@ -178,7 +259,7 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
series_id INTEGER NOT NULL
REFERENCES series(id) ON DELETE CASCADE,
PRIMARY KEY(card_id, series_id)
- );
+ ) WITHOUT ROWID, STRICT;
)sql",
R"sql(
CREATE TRIGGER card_series_same_game_insert
@@ -200,6 +281,82 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
END;
END;
)sql",
+ R"sql(
+ CREATE TRIGGER cards_identity_immutable
+ BEFORE UPDATE OF game_short_name, card_number ON cards
+ BEGIN
+ SELECT RAISE(ABORT, 'card identity is immutable');
+ END;
+ )sql",
+ R"sql(
+ CREATE TRIGGER series_game_immutable
+ BEFORE UPDATE OF game_short_name ON series
+ BEGIN
+ SELECT RAISE(ABORT, 'series game is immutable');
+ END;
+ )sql",
+ R"sql(
+ CREATE TABLE card_field_values(
+ card_id INTEGER NOT NULL
+ REFERENCES cards(id) ON DELETE CASCADE,
+ field_id INTEGER NOT NULL,
+ field_type TEXT NOT NULL,
+ integer_value INTEGER,
+ string_value TEXT,
+ choice_value TEXT,
+ PRIMARY KEY(card_id, field_id),
+ FOREIGN KEY(field_id, field_type)
+ REFERENCES game_fields(id, type) ON DELETE RESTRICT,
+ FOREIGN KEY(field_id, choice_value)
+ REFERENCES game_field_choices(field_id, value)
+ ON DELETE RESTRICT,
+ CHECK(
+ (field_type = 'INTEGER' AND integer_value IS NOT NULL
+ AND string_value IS NULL AND choice_value IS NULL)
+ OR
+ (field_type = 'STRING' AND integer_value IS NULL
+ AND string_value IS NOT NULL AND length(string_value) > 0
+ AND choice_value IS NULL)
+ OR
+ (field_type = 'CHOICE' AND integer_value IS NULL
+ AND string_value IS NULL AND choice_value IS NOT NULL)
+ )
+ ) WITHOUT ROWID, STRICT;
+ )sql",
+ R"sql(
+ CREATE INDEX card_field_values_by_field
+ ON card_field_values(field_id);
+ )sql",
+ R"sql(
+ CREATE INDEX card_field_values_by_choice
+ ON card_field_values(field_id, choice_value);
+ )sql",
+ R"sql(
+ CREATE TRIGGER card_field_value_game_insert
+ BEFORE INSERT ON card_field_values
+ WHEN NOT EXISTS(
+ SELECT 1
+ FROM cards JOIN game_fields
+ ON cards.game_short_name = game_fields.game_short_name
+ WHERE cards.id = NEW.card_id
+ AND game_fields.id = NEW.field_id)
+ BEGIN
+ SELECT RAISE(ABORT, 'card and field games differ');
+ END;
+ )sql",
+ R"sql(
+ CREATE TRIGGER card_field_value_game_update
+ BEFORE UPDATE ON card_field_values
+ WHEN NOT EXISTS(
+ SELECT 1
+ FROM cards JOIN game_fields
+ ON cards.game_short_name = game_fields.game_short_name
+ WHERE cards.id = NEW.card_id
+ AND game_fields.id = NEW.field_id)
+ BEGIN
+ SELECT RAISE(ABORT, 'card and field games differ');
+ END;
+ )sql",
R"sql(
CREATE TABLE card_holdings(
user_id INTEGER NOT NULL
@@ -329,6 +486,114 @@ mw::E<std::vector<Card>> cardsFromRows(std::vector<CardRow> rows)
return result;
}
+mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
+ mw::SQLite& connection,
+ const std::string& short_name)
+{
+ auto game_statement = connection.statementFromStr(
+ "SELECT short_name, display_name, description, revision "
+ "FROM games WHERE short_name = ?;");
+ if(!game_statement)
+ {
+ return std::unexpected(std::move(game_statement.error()));
+ }
+ auto game_bind = game_statement->bind(short_name);
+ if(!game_bind)
+ {
+ return std::unexpected(std::move(game_bind.error()));
+ }
+ auto game_rows = connection.eval<
+ std::string, std::string, std::string, std::int64_t>(
+ std::move(*game_statement));
+ if(!game_rows)
+ {
+ return std::unexpected(std::move(game_rows.error()));
+ }
+ if(game_rows->empty())
+ {
+ return std::optional<GameDefinitionSnapshot>{};
+ }
+
+ auto& [stored_short_name, display_name, description, revision] =
+ game_rows->front();
+ if(revision < 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid game revision"));
+ }
+ GameDefinitionSnapshot snapshot{
+ {std::move(stored_short_name), std::move(display_name),
+ std::move(description), revision},
+ {}};
+
+ auto field_statement = connection.statementFromStr(
+ "SELECT id, game_short_name, key, label, type, position "
+ "FROM game_fields WHERE game_short_name = ? "
+ "ORDER BY position, id;");
+ if(!field_statement)
+ {
+ return std::unexpected(std::move(field_statement.error()));
+ }
+ auto field_bind = field_statement->bind(short_name);
+ if(!field_bind)
+ {
+ return std::unexpected(std::move(field_bind.error()));
+ }
+ auto field_rows = connection.eval<
+ std::int64_t, std::string, std::string, std::string,
+ std::string, std::int64_t>(std::move(*field_statement));
+ if(!field_rows)
+ {
+ return std::unexpected(std::move(field_rows.error()));
+ }
+ snapshot.fields.reserve(field_rows->size());
+ for(auto& [id, game_short_name, key, label, type_name, position] :
+ *field_rows)
+ {
+ auto type = parseGameFieldType(type_name);
+ if(!type || position < 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid game field"));
+ }
+ GameField field{
+ id, std::move(game_short_name), std::move(key),
+ std::move(label), *type, position, {}};
+
+ auto choice_statement = connection.statementFromStr(
+ "SELECT value, position FROM game_field_choices "
+ "WHERE field_id = ? ORDER BY position, value;");
+ if(!choice_statement)
+ {
+ return std::unexpected(std::move(choice_statement.error()));
+ }
+ auto choice_bind = choice_statement->bind(id);
+ if(!choice_bind)
+ {
+ return std::unexpected(std::move(choice_bind.error()));
+ }
+ auto choice_rows = connection.eval<std::string, std::int64_t>(
+ std::move(*choice_statement));
+ if(!choice_rows)
+ {
+ return std::unexpected(std::move(choice_rows.error()));
+ }
+ field.choices.reserve(choice_rows->size());
+ for(auto& [value, choice_position] : *choice_rows)
+ {
+ if(choice_position < 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid choice position"));
+ }
+ field.choices.push_back(
+ {std::move(value), choice_position});
+ }
+ snapshot.fields.push_back(std::move(field));
+ }
+ return std::optional<GameDefinitionSnapshot>(std::move(snapshot));
+}
+
class DataSourceSQLiteTransaction final
: public DataSourceTransactionInterface
{
@@ -397,7 +662,7 @@ public:
std::get<0>(rows->front()));
}
- /// Ensure a registered game has a persistent sequence row.
+ /// Ensure a database-defined game has a persistent sequence row.
mw::E<void> ensureGameSequence(
const std::string& game_short_name) override
{
@@ -416,6 +681,371 @@ public:
return connection_.execute(std::move(*statement));
}
+ /// Read a complete game definition under this transaction's lock.
+ mw::E<std::optional<GameDefinitionSnapshot>>
+ getGameDefinitionForUpdate(const std::string& short_name) override
+ {
+ return loadGameDefinition(connection_, short_name);
+ }
+
+ /// Return deletion-relevant usage for one game.
+ mw::E<GameUsage> getGameUsage(
+ const std::string& short_name) override
+ {
+ auto statement = connection_.statementFromStr(
+ "SELECT (SELECT COUNT(*) FROM cards "
+ "WHERE game_short_name = ?), "
+ "(SELECT COUNT(*) FROM series WHERE game_short_name = ?), "
+ "COALESCE((SELECT last_number FROM game_sequences "
+ "WHERE game_short_name = ?), -1);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(short_name, short_name, short_name);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto rows = connection_.eval<
+ std::int64_t, std::int64_t, std::int64_t>(
+ std::move(*statement));
+ if(!rows || rows->size() != 1)
+ {
+ return std::unexpected(
+ rows ? mw::runtimeError("Could not read game usage")
+ : std::move(rows.error()));
+ }
+ auto [cards, series, last_number] = rows->front();
+ return GameUsage{cards, series, last_number};
+ }
+
+ /// Insert a game and its number sequence.
+ mw::E<void> insertGame(const Game& game) override
+ {
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO games(short_name, display_name, description, "
+ "revision) VALUES (?, ?, ?, ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ game.short_name, game.display_name, game.description,
+ game.revision);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+ return ensureGameSequence(game.short_name);
+ }
+
+ /// Replace mutable game metadata and advance its revision.
+ mw::E<bool> updateGame(
+ const std::string& short_name,
+ const std::string& display_name,
+ const std::string& description,
+ std::int64_t expected_revision) override
+ {
+ auto statement = connection_.statementFromStr(
+ "UPDATE games SET display_name = ?, description = ?, "
+ "revision = revision + 1 "
+ "WHERE short_name = ? AND revision = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ display_name, description, short_name, expected_revision);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto updated = connection_.execute(std::move(*statement));
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ return connection_.changedRowsCount() == 1;
+ }
+
+ /// Delete an unused game and its definition rows.
+ mw::E<void> deleteGame(const std::string& short_name) override
+ {
+ auto fields = connection_.statementFromStr(
+ "DELETE FROM game_fields WHERE game_short_name = ?;");
+ if(!fields)
+ {
+ return std::unexpected(std::move(fields.error()));
+ }
+ auto fields_bind = fields->bind(short_name);
+ if(!fields_bind)
+ {
+ return std::unexpected(std::move(fields_bind.error()));
+ }
+ auto fields_deleted = connection_.execute(std::move(*fields));
+ if(!fields_deleted)
+ {
+ return std::unexpected(std::move(fields_deleted.error()));
+ }
+ for(const std::string table : {"game_sequences", "games"})
+ {
+ auto statement = connection_.statementFromStr(
+ "DELETE FROM " + table + " WHERE " +
+ (table == "games" ? "short_name" : "game_short_name") +
+ " = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(short_name);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto deleted = connection_.execute(std::move(*statement));
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ }
+ return {};
+ }
+
+ /// Insert a field and its initial choices.
+ mw::E<std::int64_t> insertGameField(
+ const GameField& field,
+ const std::vector<GameChoice>& choices) override
+ {
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO game_fields(game_short_name, key, label, type, "
+ "position) VALUES (?, ?, ?, ?, ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ field.game_short_name, field.key, field.label,
+ std::string(gameFieldTypeName(field.type)), field.position);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+ const std::int64_t field_id = connection_.lastInsertRowID();
+ for(const GameChoice& choice : choices)
+ {
+ auto choice_result = upsertChoice(field_id, choice);
+ if(!choice_result)
+ {
+ return std::unexpected(std::move(choice_result.error()));
+ }
+ }
+ return field_id;
+ }
+
+ /// Replace a field label and complete choice list.
+ mw::E<void> updateGameField(
+ std::int64_t field_id,
+ const std::string& label,
+ const std::vector<GameChoice>& choices) override
+ {
+ auto label_statement = connection_.statementFromStr(
+ "UPDATE game_fields SET label = ? WHERE id = ?;");
+ if(!label_statement)
+ {
+ return std::unexpected(std::move(label_statement.error()));
+ }
+ auto label_bind = label_statement->bind(label, field_id);
+ if(!label_bind)
+ {
+ return std::unexpected(std::move(label_bind.error()));
+ }
+ auto label_updated = connection_.execute(
+ std::move(*label_statement));
+ if(!label_updated)
+ {
+ return std::unexpected(std::move(label_updated.error()));
+ }
+ auto rows_statement = connection_.statementFromStr(
+ "SELECT value FROM game_field_choices WHERE field_id = ?;");
+ if(!rows_statement)
+ {
+ return std::unexpected(std::move(rows_statement.error()));
+ }
+ auto rows_bind = rows_statement->bind(field_id);
+ if(!rows_bind)
+ {
+ return std::unexpected(std::move(rows_bind.error()));
+ }
+ auto rows = connection_.eval<std::string>(
+ std::move(*rows_statement));
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ for(auto& [existing] : *rows)
+ {
+ const bool retained = std::ranges::any_of(
+ choices,
+ [&existing](const GameChoice& choice)
+ {
+ return choice.value == existing;
+ });
+ if(!retained)
+ {
+ auto deleted = deleteChoice(field_id, existing);
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ }
+ }
+ for(const GameChoice& choice : choices)
+ {
+ auto updated = upsertChoice(field_id, choice);
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ }
+ return {};
+ }
+
+ /// Delete a field with no stored card values.
+ mw::E<void> deleteGameField(std::int64_t field_id) override
+ {
+ auto statement = connection_.statementFromStr(
+ "DELETE FROM game_fields WHERE id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(field_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ /// Return the number of cards using a field.
+ mw::E<std::int64_t> countFieldUsage(std::int64_t field_id) override
+ {
+ return countUsage(
+ "SELECT COUNT(*) FROM card_field_values WHERE field_id = ?;",
+ field_id, std::nullopt);
+ }
+
+ /// Return the number of cards using an exact choice.
+ mw::E<std::int64_t> countChoiceUsage(
+ std::int64_t field_id, const std::string& value) override
+ {
+ return countUsage(
+ "SELECT COUNT(*) FROM card_field_values "
+ "WHERE field_id = ? AND choice_value = ?;",
+ field_id, value);
+ }
+
+ /// Replace every field position for a game.
+ mw::E<void> updateGameFieldOrder(
+ const std::string& short_name,
+ const std::vector<std::int64_t>& field_ids) override
+ {
+ for(std::size_t position = 0; position < field_ids.size(); ++position)
+ {
+ auto statement = connection_.statementFromStr(
+ "UPDATE game_fields SET position = ? "
+ "WHERE id = ? AND game_short_name = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ static_cast<std::int64_t>(position), field_ids[position],
+ short_name);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto updated = connection_.execute(std::move(*statement));
+ if(!updated || connection_.changedRowsCount() != 1)
+ {
+ return std::unexpected(
+ updated ? mw::runtimeError("Invalid field order")
+ : std::move(updated.error()));
+ }
+ }
+ return {};
+ }
+
+ /// Advance a matching aggregate game revision.
+ mw::E<bool> incrementGameRevision(
+ const std::string& short_name,
+ std::int64_t expected_revision) override
+ {
+ auto statement = connection_.statementFromStr(
+ "UPDATE games SET revision = revision + 1 "
+ "WHERE short_name = ? AND revision = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(short_name, expected_revision);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto updated = connection_.execute(std::move(*statement));
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ return connection_.changedRowsCount() == 1;
+ }
+
+ /// Return whether every requested series belongs to one game.
+ mw::E<bool> seriesBelongToGame(
+ const std::string& short_name,
+ const std::vector<std::int64_t>& series_ids) override
+ {
+ for(std::int64_t series_id : series_ids)
+ {
+ auto statement = connection_.statementFromStr(
+ "SELECT EXISTS(SELECT 1 FROM series "
+ "WHERE id = ? AND game_short_name = ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(series_id, short_name);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto belongs = connection_.evalToValue<int>(
+ std::move(*statement));
+ if(!belongs)
+ {
+ return std::unexpected(std::move(belongs.error()));
+ }
+ if(*belongs == 0)
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
/// Return whether a loose-card number already exists.
mw::E<bool> looseNumberExists(std::uint32_t number) override
{
@@ -1025,154 +1655,79 @@ public:
return std::optional<Card>(std::move(card));
}
- /// Insert common and series-membership rows for a loose card.
+ /// Insert a card with generic custom values and memberships.
mw::E<std::int64_t> insertCard(
- const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
- const std::vector<std::int64_t>& series_ids) override
- {
- if(card.id != 0)
- {
- return std::unexpected(mw::runtimeError(
- "A new card cannot already have an internal ID"));
- }
- if((game == nullptr) != (metadata == nullptr))
- {
- return std::unexpected(mw::runtimeError(
- "Game definition and metadata must be provided together"));
- }
- if(card.identity.game_short_name)
- {
- if(game == nullptr)
- {
- return std::unexpected(mw::runtimeError(
- "A game card requires compiled game metadata"));
- }
- if(game->shortName() != *card.identity.game_short_name)
- {
- return std::unexpected(mw::runtimeError(
- "Card and compiled game identities differ"));
- }
- }
- else if(game != nullptr || !series_ids.empty())
+ const Card& card,
+ const std::vector<GameFieldValue>& field_values,
+ const std::vector<std::int64_t>& series_ids) override
+ {
+ if(card.id != 0)
{
return std::unexpected(mw::runtimeError(
- "A loose card cannot have game metadata or series"));
+ "A new card cannot already have an internal ID"));
}
if(!card.identity.game_short_name &&
- card.identity.card_number >
- std::numeric_limits<std::uint32_t>::max())
+ (!field_values.empty() || !series_ids.empty()))
{
return std::unexpected(mw::runtimeError(
- "Loose card number exceeds the 32-bit namespace"));
+ "A loose card cannot have game fields or series"));
}
-
- auto insert = connection_.statementFromStr(
- "INSERT INTO cards ("
- "creator_user_id, game_short_name, card_number, name, "
- "short_description, "
- "long_description, rarity, front_extension, foil_extension, "
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO cards (creator_user_id, game_short_name, "
+ "card_number, name, short_description, long_description, "
+ "rarity, front_extension, foil_extension, "
"thumbnail_extension, revision) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
- if(!insert)
+ if(!statement)
{
- return std::unexpected(std::move(insert.error()));
+ return std::unexpected(std::move(statement.error()));
}
- auto bind = insert->bind(
- card.creator_user_id,
- card.identity.game_short_name,
+ auto bind = statement->bind(
+ card.creator_user_id, card.identity.game_short_name,
static_cast<std::int64_t>(card.identity.card_number),
- card.name,
- card.short_description,
- card.long_description,
- card.rarity,
- card.front_extension,
- card.foil_extension,
- card.thumbnail_extension,
- card.revision);
+ card.name, card.short_description, card.long_description,
+ card.rarity, card.front_extension, card.foil_extension,
+ card.thumbnail_extension, card.revision);
if(!bind)
{
return std::unexpected(std::move(bind.error()));
}
- auto execute = connection_.execute(std::move(*insert));
- if(!execute)
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
{
- return std::unexpected(std::move(execute.error()));
+ return std::unexpected(std::move(inserted.error()));
}
const std::int64_t card_id = connection_.lastInsertRowID();
-
- if(game != nullptr)
+ auto values = insertFieldValues(card_id, field_values);
+ if(!values)
{
- auto game_insert = game->insertMetadata(
- connection_, card_id, *metadata);
- if(!game_insert)
- {
- return std::unexpected(std::move(game_insert.error()));
- }
+ return std::unexpected(std::move(values.error()));
}
-
- for(std::int64_t series_id : series_ids)
+ auto memberships = insertMemberships(card_id, series_ids);
+ if(!memberships)
{
- auto membership = connection_.statementFromStr(
- "INSERT INTO card_series (card_id, series_id) "
- "VALUES (?, ?);");
- if(!membership)
- {
- return std::unexpected(std::move(membership.error()));
- }
- auto membership_bind = membership->bind<std::int64_t,
- std::int64_t>(
- card_id, series_id);
- if(!membership_bind)
- {
- return std::unexpected(
- std::move(membership_bind.error()));
- }
- auto membership_execute = connection_.execute(
- std::move(*membership));
- if(!membership_execute)
- {
- return std::unexpected(
- std::move(membership_execute.error()));
- }
+ return std::unexpected(std::move(memberships.error()));
}
return card_id;
}
- /// Replace card rows after validation by the service layer.
+ /// Replace a card and all generic custom values and memberships.
mw::E<void> updateCard(
const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
- const std::vector<std::int64_t>& series_ids)
- override
+ const std::vector<GameFieldValue>& field_values,
+ const std::vector<std::int64_t>& series_ids) override
{
if(card.id <= 0)
{
return std::unexpected(mw::runtimeError(
"An updated card requires an internal ID"));
}
- if((game == nullptr) != (metadata == nullptr))
- {
- return std::unexpected(mw::runtimeError(
- "Game definition and metadata must be provided together"));
- }
- if(card.identity.game_short_name)
- {
- if(game == nullptr ||
- game->shortName() != *card.identity.game_short_name)
- {
- return std::unexpected(mw::runtimeError(
- "A game card requires its compiled game metadata"));
- }
- }
- else if(game != nullptr || !series_ids.empty())
+ if(!card.identity.game_short_name &&
+ (!field_values.empty() || !series_ids.empty()))
{
return std::unexpected(mw::runtimeError(
- "A loose card cannot have game metadata or series"));
+ "A loose card cannot have game fields or series"));
}
-
auto statement = connection_.statementFromStr(
"UPDATE cards SET name = ?, short_description = ?, "
"long_description = ?, rarity = ?, front_extension = ?, "
@@ -1185,89 +1740,43 @@ public:
return std::unexpected(std::move(statement.error()));
}
auto bind = statement->bind(
- card.name,
- card.short_description,
- card.long_description,
- card.rarity,
- card.front_extension,
- card.foil_extension,
- card.thumbnail_extension,
- card.revision,
- card.id,
- card.identity.game_short_name,
- card.identity.game_short_name,
+ card.name, card.short_description, card.long_description,
+ card.rarity, card.front_extension, card.foil_extension,
+ card.thumbnail_extension, card.revision, card.id,
+ card.identity.game_short_name, card.identity.game_short_name,
static_cast<std::int64_t>(card.identity.card_number));
if(!bind)
{
return std::unexpected(std::move(bind.error()));
}
- auto update = connection_.execute(std::move(*statement));
- if(!update)
- {
- return std::unexpected(std::move(update.error()));
- }
- auto changes = connection_.evalToValue<std::int64_t>(
- "SELECT changes();");
- if(!changes)
+ auto updated = connection_.execute(std::move(*statement));
+ if(!updated)
{
- return std::unexpected(std::move(changes.error()));
+ return std::unexpected(std::move(updated.error()));
}
- if(*changes != 1)
+ if(connection_.changedRowsCount() != 1)
{
return std::unexpected(mw::runtimeError(
"The card disappeared while it was being updated"));
}
- if(game != nullptr)
- {
- auto game_update = game->updateMetadata(
- connection_, card.id, *metadata);
- if(!game_update)
- {
- return std::unexpected(std::move(game_update.error()));
- }
- }
- auto delete_memberships = connection_.statementFromStr(
- "DELETE FROM card_series WHERE card_id = ?;");
- if(!delete_memberships)
- {
- return std::unexpected(std::move(delete_memberships.error()));
- }
- auto delete_bind = delete_memberships->bind<std::int64_t>(card.id);
- if(!delete_bind)
+ auto delete_values = deleteCardRelations(
+ "card_field_values", card.id);
+ if(!delete_values)
{
- return std::unexpected(std::move(delete_bind.error()));
+ return std::unexpected(std::move(delete_values.error()));
}
- auto deleted = connection_.execute(std::move(*delete_memberships));
- if(!deleted)
+ auto values = insertFieldValues(card.id, field_values);
+ if(!values)
{
- return std::unexpected(std::move(deleted.error()));
+ return std::unexpected(std::move(values.error()));
}
- for(std::int64_t series_id : series_ids)
+ auto delete_memberships = deleteCardRelations(
+ "card_series", card.id);
+ if(!delete_memberships)
{
- auto membership = connection_.statementFromStr(
- "INSERT INTO card_series (card_id, series_id) "
- "VALUES (?, ?);");
- if(!membership)
- {
- return std::unexpected(std::move(membership.error()));
- }
- auto membership_bind = membership->bind<std::int64_t,
- std::int64_t>(
- card.id, series_id);
- if(!membership_bind)
- {
- return std::unexpected(
- std::move(membership_bind.error()));
- }
- auto membership_insert = connection_.execute(
- std::move(*membership));
- if(!membership_insert)
- {
- return std::unexpected(
- std::move(membership_insert.error()));
- }
+ return std::unexpected(std::move(delete_memberships.error()));
}
- return {};
+ return insertMemberships(card.id, series_ids);
}
/// Delete a card and its dependent database rows.
@@ -1378,6 +1887,171 @@ public:
}
private:
+ mw::E<void> upsertChoice(
+ std::int64_t field_id, const GameChoice& choice)
+ {
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO game_field_choices(field_id, value, position) "
+ "VALUES (?, ?, ?) ON CONFLICT(field_id, value) DO UPDATE "
+ "SET position = excluded.position;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ field_id, choice.value, choice.position);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ mw::E<void> deleteChoice(
+ std::int64_t field_id, const std::string& value)
+ {
+ auto statement = connection_.statementFromStr(
+ "DELETE FROM game_field_choices "
+ "WHERE field_id = ? AND value = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(field_id, value);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ mw::E<std::int64_t> countUsage(
+ const std::string& sql,
+ std::int64_t field_id,
+ const std::optional<std::string>& value)
+ {
+ auto statement = connection_.statementFromStr(sql);
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = value ? statement->bind(field_id, *value)
+ : statement->bind(field_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.evalToValue<std::int64_t>(
+ std::move(*statement));
+ }
+
+ mw::E<void> deleteCardRelations(
+ std::string_view table, std::int64_t card_id)
+ {
+ auto statement = connection_.statementFromStr(
+ "DELETE FROM " + std::string(table) + " WHERE card_id = ?;");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(card_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ return connection_.execute(std::move(*statement));
+ }
+
+ mw::E<void> insertFieldValues(
+ std::int64_t card_id,
+ const std::vector<GameFieldValue>& field_values)
+ {
+ for(const GameFieldValue& field_value : field_values)
+ {
+ std::optional<std::int64_t> integer_value;
+ std::optional<std::string> string_value;
+ std::optional<std::string> choice_value;
+ if(field_value.type == GameFieldType::INTEGER)
+ {
+ const auto value = std::get_if<std::int64_t>(
+ &field_value.value);
+ if(value == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Integer field has a non-integer value"));
+ }
+ integer_value = *value;
+ }
+ else
+ {
+ const auto value = std::get_if<std::string>(
+ &field_value.value);
+ if(value == nullptr)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Text field has a non-text value"));
+ }
+ if(field_value.type == GameFieldType::STRING)
+ {
+ string_value = *value;
+ }
+ else
+ {
+ choice_value = *value;
+ }
+ }
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO card_field_values(card_id, field_id, "
+ "field_type, integer_value, string_value, choice_value) "
+ "VALUES (?, ?, ?, ?, ?, ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(
+ card_id, field_value.field_id,
+ std::string(gameFieldTypeName(field_value.type)),
+ integer_value, string_value, choice_value);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+ }
+ return {};
+ }
+
+ mw::E<void> insertMemberships(
+ std::int64_t card_id,
+ const std::vector<std::int64_t>& series_ids)
+ {
+ for(std::int64_t series_id : series_ids)
+ {
+ auto statement = connection_.statementFromStr(
+ "INSERT INTO card_series(card_id, series_id) "
+ "VALUES (?, ?);");
+ if(!statement)
+ {
+ return std::unexpected(std::move(statement.error()));
+ }
+ auto bind = statement->bind(card_id, series_id);
+ if(!bind)
+ {
+ return std::unexpected(std::move(bind.error()));
+ }
+ auto inserted = connection_.execute(std::move(*statement));
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+ }
+ return {};
+ }
+
mw::E<std::optional<User>> readUser(mw::SQLiteStatement statement)
{
auto rows = connection_.eval<
@@ -1576,8 +2250,7 @@ mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
return *version;
}
-mw::E<void> DataSourceSQLite::migrateSchema0To1(
- const GameRegistry& games)
+mw::E<void> DataSourceSQLite::migrateSchema0To1()
{
std::lock_guard lock(mutex_);
auto version = connection_->evalToValue<std::int64_t>(
@@ -1606,16 +2279,6 @@ mw::E<void> DataSourceSQLite::migrateSchema0To1(
*connection_, std::move(result.error()));
}
}
- for(const GameDefinition* game : games.games())
- {
- auto game_schema = game->createSchema(*connection_);
- if(!game_schema)
- {
- return rollbackWithError(
- *connection_, std::move(game_schema.error()));
- }
- }
-
auto set_version = connection_->execute("PRAGMA user_version = 1;");
if(!set_version)
{
@@ -1630,6 +2293,49 @@ mw::E<void> DataSourceSQLite::migrateSchema0To1(
return {};
}
+mw::E<void> DataSourceSQLite::checkIntegrity() const
+{
+ std::lock_guard lock(mutex_);
+ auto quick_check = connection_->eval<std::string>(
+ "PRAGMA quick_check;");
+ if(!quick_check)
+ {
+ return std::unexpected(std::move(quick_check.error()));
+ }
+ if(quick_check->size() != 1 ||
+ std::get<0>(quick_check->front()) != "ok")
+ {
+ return std::unexpected(mw::runtimeError(
+ "SQLite integrity check failed"));
+ }
+ auto foreign_key_errors = connection_->evalToValue<std::int64_t>(
+ "SELECT COUNT(*) FROM pragma_foreign_key_check;");
+ if(!foreign_key_errors)
+ {
+ return std::unexpected(std::move(foreign_key_errors.error()));
+ }
+ if(*foreign_key_errors != 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains foreign-key violations"));
+ }
+ auto missing_sequences = connection_->evalToValue<std::int64_t>(
+ "SELECT COUNT(*) FROM games AS game "
+ "LEFT JOIN game_sequences AS sequence "
+ "ON sequence.game_short_name = game.short_name "
+ "WHERE sequence.game_short_name IS NULL;");
+ if(!missing_sequences)
+ {
+ return std::unexpected(std::move(missing_sequences.error()));
+ }
+ if(*missing_sequences != 0)
+ {
+ return std::unexpected(mw::runtimeError(
+ "A game is missing its card-number sequence"));
+ }
+ return {};
+}
+
mw::E<std::unique_ptr<DataSourceTransactionInterface>>
DataSourceSQLite::beginTransaction()
{
@@ -1841,6 +2547,136 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
return std::optional<Card>(std::move(card));
}
+mw::E<std::vector<Game>> DataSourceSQLite::getGames() const
+{
+ std::lock_guard lock(mutex_);
+ auto rows = connection_->eval<
+ std::string, std::string, std::string, std::int64_t>(
+ "SELECT short_name, display_name, description, revision "
+ "FROM games ORDER BY display_name, short_name;");
+ if(!rows)
+ {
+ return std::unexpected(std::move(rows.error()));
+ }
+ std::vector<Game> games;
+ games.reserve(rows->size());
+ for(auto& [short_name, display_name, description, revision] : *rows)
+ {
+ if(revision < 1)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid game revision"));
+ }
+ games.push_back({
+ std::move(short_name), std::move(display_name),
+ std::move(description), revision});
+ }
+ return games;
+}
+
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceSQLite::getGameDefinition(
+ const std::string& short_name) const
+{
+ std::lock_guard lock(mutex_);
+ return loadGameDefinition(*connection_, short_name);
+}
+
+mw::E<std::optional<CardGameFields>>
+DataSourceSQLite::getCardFieldValues(std::int64_t card_id) const
+{
+ std::lock_guard lock(mutex_);
+ auto card_statement = connection_->statementFromStr(
+ "SELECT game_short_name FROM cards WHERE id = ?;");
+ if(!card_statement)
+ {
+ return std::unexpected(std::move(card_statement.error()));
+ }
+ auto card_bind = card_statement->bind(card_id);
+ if(!card_bind)
+ {
+ return std::unexpected(std::move(card_bind.error()));
+ }
+ auto card_rows = connection_->eval<std::optional<std::string>>(
+ std::move(*card_statement));
+ if(!card_rows)
+ {
+ return std::unexpected(std::move(card_rows.error()));
+ }
+ if(card_rows->empty() || !std::get<0>(card_rows->front()))
+ {
+ return std::optional<CardGameFields>{};
+ }
+ const std::string& short_name = *std::get<0>(card_rows->front());
+ auto definition = loadGameDefinition(*connection_, short_name);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ if(!*definition)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Card references a missing game"));
+ }
+
+ auto value_statement = connection_->statementFromStr(
+ "SELECT value.field_id, value.field_type, "
+ "value.integer_value, value.string_value, value.choice_value "
+ "FROM card_field_values AS value "
+ "JOIN game_fields AS field ON field.id = value.field_id "
+ "WHERE value.card_id = ? ORDER BY field.position, field.id;");
+ if(!value_statement)
+ {
+ return std::unexpected(std::move(value_statement.error()));
+ }
+ auto value_bind = value_statement->bind(card_id);
+ if(!value_bind)
+ {
+ return std::unexpected(std::move(value_bind.error()));
+ }
+ auto value_rows = connection_->eval<
+ std::int64_t, std::string, std::optional<std::int64_t>,
+ std::optional<std::string>, std::optional<std::string>>(
+ std::move(*value_statement));
+ if(!value_rows)
+ {
+ return std::unexpected(std::move(value_rows.error()));
+ }
+
+ CardGameFields result{std::move(**definition), {}};
+ result.values.reserve(value_rows->size());
+ for(auto& [field_id, type_name, integer_value, string_value,
+ choice_value] : *value_rows)
+ {
+ auto type = parseGameFieldType(type_name);
+ if(!type)
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid card field type"));
+ }
+ if(*type == GameFieldType::INTEGER && integer_value)
+ {
+ result.values.push_back({field_id, *type, *integer_value});
+ }
+ else if(*type == GameFieldType::STRING && string_value)
+ {
+ result.values.push_back(
+ {field_id, *type, std::move(*string_value)});
+ }
+ else if(*type == GameFieldType::CHOICE && choice_value)
+ {
+ result.values.push_back(
+ {field_id, *type, std::move(*choice_value)});
+ }
+ else
+ {
+ return std::unexpected(mw::runtimeError(
+ "Database contains an invalid card field value"));
+ }
+ }
+ return std::optional<CardGameFields>(std::move(result));
+}
+
mw::E<std::optional<User>> DataSourceSQLite::getUser(
std::int64_t user_id) const
{
@@ -2320,23 +3156,6 @@ mw::E<void> DataSourceSQLite::cleanupAuthentication(std::int64_t now)
return connection_->execute(std::move(*quota));
}
-mw::E<std::vector<DisplayField>>
-DataSourceSQLite::getGameDisplayFields(
- const GameDefinition& game,
- std::int64_t card_id) const
-{
- std::lock_guard lock(mutex_);
- return game.displayFields(*connection_, card_id);
-}
-
-mw::E<FormFields> DataSourceSQLite::getGameFormValues(
- const GameDefinition& game,
- std::int64_t card_id) const
-{
- std::lock_guard lock(mutex_);
- return game.formValues(*connection_, card_id);
-}
-
mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
{
std::lock_guard lock(mutex_);
@@ -2433,28 +3252,6 @@ mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
return result;
}
-mw::E<std::vector<std::string>>
-DataSourceSQLite::getPersistedGameNames() const
-{
- std::lock_guard lock(mutex_);
- auto rows = connection_->eval<std::string>(
- "SELECT game_short_name FROM cards "
- "WHERE game_short_name IS NOT NULL "
- "UNION SELECT game_short_name FROM series "
- "ORDER BY game_short_name;");
- if(!rows)
- {
- return std::unexpected(std::move(rows.error()));
- }
- std::vector<std::string> result;
- result.reserve(rows->size());
- for(auto& [name] : *rows)
- {
- result.push_back(std::move(name));
- }
- return result;
-}
-
mw::E<void> DataSourceSQLite::setSchemaVersion(
std::int64_t version)
{
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index 0b51464..ba56641 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -28,9 +28,11 @@ public:
/// Return the stored schema version.
mw::E<std::int64_t> getSchemaVersion() const override;
- /// Create schema version 1 from an empty version-0 database.
- mw::E<void>
- migrateSchema0To1(const GameRegistry& games) override;
+ /// Migrate an empty version-0 database to schema version 1.
+ mw::E<void> migrateSchema0To1() override;
+
+ /// Verify SQLite and application referential integrity after migration.
+ mw::E<void> checkIntegrity() const;
/// Start an immediate transaction with exclusive mutation ownership.
mw::E<std::unique_ptr<DataSourceTransactionInterface>>
@@ -50,6 +52,17 @@ public:
mw::E<std::optional<Card>>
getCard(const CardIdentity& identity) const override;
+ /// Return all database-defined games ordered by display name.
+ mw::E<std::vector<Game>> getGames() const override;
+
+ /// Return a complete, consistently read game definition.
+ mw::E<std::optional<GameDefinitionSnapshot>>
+ getGameDefinition(const std::string& short_name) const override;
+
+ /// Return a card's definition and ordered custom values together.
+ mw::E<std::optional<CardGameFields>>
+ getCardFieldValues(std::int64_t card_id) const override;
+
/// Return a user by internal identity.
mw::E<std::optional<User>> getUser(
std::int64_t user_id) const override;
@@ -88,16 +101,6 @@ public:
/// Best-effort removal of obsolete authentication rows.
mw::E<void> cleanupAuthentication(std::int64_t now) override;
- /// Return a card's game-owned display fields.
- mw::E<std::vector<DisplayField>> getGameDisplayFields(
- const GameDefinition& game,
- std::int64_t card_id) const override;
-
- /// Return current game-owned values for the edit form.
- mw::E<FormFields> getGameFormValues(
- const GameDefinition& game,
- std::int64_t card_id) const override;
-
/// Return all series, ordered by game and name.
mw::E<std::vector<Series>> getSeries() const override;
@@ -109,10 +112,6 @@ public:
mw::E<std::vector<std::int64_t>>
getCardSeries(std::int64_t card_id) const override;
- /// Return every persisted game name used for startup reconciliation.
- mw::E<std::vector<std::string>>
- getPersistedGameNames() const override;
-
protected:
/// Set the schema version inside a concrete migration transaction.
mw::E<void> setSchemaVersion(std::int64_t version) override;
diff --git a/src/form_limits.h b/src/form_limits.h
new file mode 100644
index 0000000..5f1166c
--- /dev/null
+++ b/src/form_limits.h
@@ -0,0 +1,9 @@
+#pragma once
+
+#include <cstddef>
+
+/// Maximum decoded byte length of one submitted text control.
+inline constexpr std::size_t MAX_FORM_TEXT_FIELD_SIZE = 1024 * 1024;
+
+/// Maximum aggregate decoded text bytes in one application form.
+inline constexpr std::size_t MAX_FORM_TOTAL_TEXT_SIZE = 8 * 1024 * 1024;
diff --git a/src/game.h b/src/game.h
index 73362ed..4ef45d6 100644
--- a/src/game.h
+++ b/src/game.h
@@ -2,24 +2,126 @@
#include <cstdint>
#include <string>
+#include <unordered_map>
+#include <variant>
+#include <vector>
-/// One game-specific label and value shown on a card page.
-struct DisplayField
+/// Supported storage and form behavior for a custom game field.
+enum class GameFieldType
{
+ INTEGER,
+ STRING,
+ CHOICE
+};
+
+/// One database-defined game.
+struct Game
+{
+ /// Permanent lowercase public card-ID prefix.
+ std::string short_name;
+
+ /// Human-readable game name.
+ std::string display_name;
+
+ /// Markdown game description.
+ std::string description;
+
+ /// Aggregate optimistic-concurrency revision.
+ std::int64_t revision;
+};
+
+/// One allowed value for a choice field.
+struct GameChoice
+{
+ /// Exact stored and displayed choice text.
+ std::string value;
+
+ /// Zero-based display position.
+ std::int64_t position;
+};
+
+/// One ordered custom field belonging to a game.
+struct GameField
+{
+ /// Internal database identity.
+ std::int64_t id;
+
+ /// Permanent owning game short name.
+ std::string game_short_name;
+
+ /// Permanent key used in submitted form names.
+ std::string key;
+
/// Human-readable field label.
std::string label;
- /// Human-readable field value.
- std::string value;
+ /// Field value type.
+ GameFieldType type;
+
+ /// Zero-based display position.
+ std::int64_t position;
+
+ /// Ordered allowed values, populated only for choice fields.
+ std::vector<GameChoice> choices;
+};
+
+/// Consistent owning snapshot of a game and all its field definitions.
+struct GameDefinitionSnapshot
+{
+ /// Database-defined game metadata.
+ Game game;
+
+ /// Fields ordered by position and stable identity.
+ std::vector<GameField> fields;
+};
+
+/// Decoded storage value for one custom field.
+struct GameFieldValue
+{
+ /// Referenced field identity.
+ std::int64_t field_id;
+
+ /// Field discriminator retained for database constraint checks.
+ GameFieldType type;
+
+ /// Parsed integer or exact string/choice text.
+ std::variant<std::int64_t, std::string> value;
};
-/// Dynamic series metadata belonging to one compiled game.
+/// One card's values paired with a consistent owning game definition.
+struct CardGameFields
+{
+ /// Definition used to interpret and present the stored values.
+ GameDefinitionSnapshot definition;
+
+ /// Set values ordered by their fields' display positions.
+ std::vector<GameFieldValue> values;
+};
+
+/// Counts that determine whether a database-defined game can be deleted.
+struct GameUsage
+{
+ /// Cards that use the game.
+ std::int64_t card_count;
+
+ /// Series that use the game.
+ std::int64_t series_count;
+
+ /// Highest public card number ever allocated for the game.
+ std::int64_t last_number;
+};
+
+/// Unique custom-field form values keyed by permanent field key.
+using SubmittedGameFields =
+ std::unordered_map<std::string, std::string>;
+
+/// Dynamic series metadata belonging to one database-defined game.
struct Series
{
/// Internal SQLite primary key, or zero before insertion.
std::int64_t id;
- /// Lowercase short name of the owning compiled game.
+ /// Lowercase short name of the owning database-defined game.
std::string game_short_name;
/// Series name, unique within its game.
diff --git a/src/game_definition.h b/src/game_definition.h
deleted file mode 100644
index 44b9e25..0000000
--- a/src/game_definition.h
+++ /dev/null
@@ -1,92 +0,0 @@
-#pragma once
-
-#include <cstdint>
-#include <memory>
-#include <string>
-#include <string_view>
-#include <unordered_map>
-#include <vector>
-
-#include <mw/database.hpp>
-#include <mw/error.hpp>
-
-#include "game.h"
-
-/// Posted fields owned by one compiled game definition.
-using FormFields = std::unordered_map<std::string, std::string>;
-
-/// One server-owned field rendered for compiled-game card metadata.
-struct GameFormField
-{
- /// Posted field name below the game namespace.
- std::string name;
-
- /// Human-readable form label.
- std::string label;
-
- /// Trusted HTML input type.
- std::string input_type;
-
- /// Whether browsers should require a value.
- bool required;
-
- /// Optional minimum value for numeric inputs.
- std::string minimum;
-};
-
-/// Polymorphic validated metadata owned by a compiled game.
-class GameCardMetadata
-{
-public:
- /// Destroy validated game metadata through its base interface.
- virtual ~GameCardMetadata() = default;
-};
-
-/// Compiled behavior and persistence hooks for one game.
-class GameDefinition
-{
-public:
- /// Destroy a compiled game through its base interface.
- virtual ~GameDefinition() = default;
-
- /// Return the immutable lowercase public short name.
- virtual std::string_view shortName() const = 0;
-
- /// Return the human-readable game name.
- virtual std::string_view displayName() const = 0;
-
- /// Return the Markdown game description.
- virtual std::string_view description() const = 0;
-
- /// Add this game's extension schema to a new database.
- virtual mw::E<void> createSchema(mw::SQLite& database) const = 0;
-
- /// Validate posted game fields into strongly typed metadata.
- virtual mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
- const FormFields& fields) const = 0;
-
- /// Return trusted field definitions for the create-card form.
- virtual std::vector<GameFormField> formFields() const = 0;
-
- /// Return current field values for the edit-card form.
- virtual mw::E<FormFields> formValues(
- mw::SQLite& database,
- std::int64_t card_id) const = 0;
-
- /// Insert game-owned metadata for a common card ID.
- virtual mw::E<void> insertMetadata(
- mw::SQLite& database,
- std::int64_t card_id,
- const GameCardMetadata& metadata) const = 0;
-
- /// Replace game-owned metadata for a common card ID.
- virtual mw::E<void> updateMetadata(
- mw::SQLite& database,
- std::int64_t card_id,
- const GameCardMetadata& metadata) const = 0;
-
- /// Return read-only label/value fields for a common card ID.
- virtual mw::E<std::vector<DisplayField>> displayFields(
- mw::SQLite& database,
- std::int64_t card_id) const = 0;
-};
diff --git a/src/game_field.cpp b/src/game_field.cpp
new file mode 100644
index 0000000..e20e58a
--- /dev/null
+++ b/src/game_field.cpp
@@ -0,0 +1,198 @@
+#include "game_field.h"
+
+#include <algorithm>
+#include <charconv>
+#include <cstdint>
+#include <string>
+#include <string_view>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+bool validGameText(std::string_view value)
+{
+ const auto* current = reinterpret_cast<const unsigned char*>(
+ value.data());
+ const auto* end = current + value.size();
+ while(current != end)
+ {
+ const unsigned char first = *current++;
+ if(first <= 0x7f)
+ {
+ if(first == 0)
+ {
+ return false;
+ }
+ continue;
+ }
+ std::size_t remaining = 0;
+ std::uint32_t code_point = 0;
+ std::uint32_t minimum = 0;
+ if(first >= 0xc2 && first <= 0xdf)
+ {
+ remaining = 1;
+ code_point = first & 0x1f;
+ minimum = 0x80;
+ }
+ else if(first >= 0xe0 && first <= 0xef)
+ {
+ remaining = 2;
+ code_point = first & 0x0f;
+ minimum = 0x800;
+ }
+ else if(first >= 0xf0 && first <= 0xf4)
+ {
+ remaining = 3;
+ code_point = first & 0x07;
+ minimum = 0x10000;
+ }
+ else
+ {
+ return false;
+ }
+ if(static_cast<std::size_t>(end - current) < remaining)
+ {
+ return false;
+ }
+ for(std::size_t index = 0; index < remaining; ++index)
+ {
+ const unsigned char continuation = *current++;
+ if((continuation & 0xc0) != 0x80)
+ {
+ return false;
+ }
+ code_point = (code_point << 6) | (continuation & 0x3f);
+ }
+ if(code_point < minimum || code_point > 0x10ffff ||
+ (code_point >= 0xd800 && code_point <= 0xdfff))
+ {
+ return false;
+ }
+ }
+ return true;
+}
+
+namespace
+{
+
+mw::Error fieldError(const GameField& field, std::string message)
+{
+ return GameFieldValidationError{
+ field.key,
+ "Invalid field '" + field.key + "': " + std::move(message)};
+}
+
+mw::E<std::int64_t> parseInteger(
+ const GameField& field, const std::string& value)
+{
+ if(value.empty() || value.front() == '+' || value.front() == ' ' ||
+ value.back() == ' ')
+ {
+ return std::unexpected(fieldError(field, "expected an integer"));
+ }
+ std::int64_t result = 0;
+ const auto parsed = std::from_chars(
+ value.data(), value.data() + value.size(), result, 10);
+ if(parsed.ec != std::errc() ||
+ parsed.ptr != value.data() + value.size())
+ {
+ return std::unexpected(fieldError(field, "expected an integer"));
+ }
+ return result;
+}
+
+} // namespace
+
+mw::E<std::vector<GameFieldValue>> decodeGameFields(
+ const GameDefinitionSnapshot& definition,
+ const SubmittedGameFields& submitted)
+{
+ std::unordered_set<std::string_view> known_keys;
+ known_keys.reserve(definition.fields.size());
+ for(const GameField& field : definition.fields)
+ {
+ known_keys.insert(field.key);
+ }
+ for(const auto& [key, value] : submitted)
+ {
+ [[maybe_unused]] const std::string& ignored_value = value;
+ if(!known_keys.contains(key))
+ {
+ return std::unexpected(mw::Error(GameFieldValidationError{
+ key, "Unknown custom field '" + key + "'"}));
+ }
+ }
+
+ std::vector<GameFieldValue> result;
+ result.reserve(submitted.size());
+ for(const GameField& field : definition.fields)
+ {
+ const auto position = submitted.find(field.key);
+ if(position == submitted.end() || position->second.empty())
+ {
+ continue;
+ }
+ const std::string& value = position->second;
+ if(!validGameText(value))
+ {
+ return std::unexpected(fieldError(field, "expected UTF-8 text"));
+ }
+ if(field.type == GameFieldType::INTEGER)
+ {
+ auto parsed = parseInteger(field, value);
+ if(!parsed)
+ {
+ return std::unexpected(std::move(parsed.error()));
+ }
+ result.push_back({field.id, field.type, *parsed});
+ continue;
+ }
+ if(field.type == GameFieldType::CHOICE)
+ {
+ const bool allowed = std::ranges::any_of(
+ field.choices,
+ [&value](const GameChoice& choice)
+ {
+ return choice.value == value;
+ });
+ if(!allowed)
+ {
+ return std::unexpected(
+ fieldError(field, "unknown choice"));
+ }
+ }
+ result.push_back({field.id, field.type, value});
+ }
+ return result;
+}
+
+std::string_view gameFieldTypeName(GameFieldType type)
+{
+ switch(type)
+ {
+ case GameFieldType::INTEGER:
+ return "INTEGER";
+ case GameFieldType::STRING:
+ return "STRING";
+ case GameFieldType::CHOICE:
+ return "CHOICE";
+ }
+ return "UNKNOWN";
+}
+
+mw::E<GameFieldType> parseGameFieldType(std::string_view value)
+{
+ if(value == "INTEGER")
+ {
+ return GameFieldType::INTEGER;
+ }
+ if(value == "STRING")
+ {
+ return GameFieldType::STRING;
+ }
+ if(value == "CHOICE")
+ {
+ return GameFieldType::CHOICE;
+ }
+ return std::unexpected(mw::runtimeError("Unknown game field type"));
+}
diff --git a/src/game_field.h b/src/game_field.h
new file mode 100644
index 0000000..aa137cc
--- /dev/null
+++ b/src/game_field.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <mw/error.hpp>
+
+#include "game.h"
+
+/// A custom-field validation failure addressable by a form control.
+struct GameFieldValidationError
+{
+ /// Permanent field key, or the unknown submitted key.
+ std::string field_key;
+
+ /// Safe user-facing validation message without the submitted value.
+ std::string msg;
+};
+
+/// Return whether text is valid UTF-8 and contains no NUL byte.
+bool validGameText(std::string_view value);
+
+/// Decode a complete custom-field submission against one game snapshot.
+mw::E<std::vector<GameFieldValue>> decodeGameFields(
+ const GameDefinitionSnapshot& definition,
+ const SubmittedGameFields& submitted);
+
+/// Return the stable database spelling for a field type.
+std::string_view gameFieldTypeName(GameFieldType type);
+
+/// Parse one stable database field-type spelling.
+mw::E<GameFieldType> parseGameFieldType(std::string_view value);
diff --git a/src/game_registry.cpp b/src/game_registry.cpp
deleted file mode 100644
index 38edc25..0000000
--- a/src/game_registry.cpp
+++ /dev/null
@@ -1,65 +0,0 @@
-#include "game_registry.h"
-
-#include <algorithm>
-#include <memory>
-#include <string_view>
-
-#include "game_definition.h"
-
-namespace
-{
-
-bool validShortName(std::string_view value)
-{
- return !value.empty() && std::ranges::all_of(
- value,
- [](char character)
- {
- return (character >= 'a' && character <= 'z') ||
- (character >= '0' && character <= '9');
- });
-}
-
-} // namespace
-
-GameRegistry::GameRegistry() = default;
-
-GameRegistry::~GameRegistry() = default;
-
-mw::E<void> GameRegistry::add(std::unique_ptr<GameDefinition> game)
-{
- if(!game || !validShortName(game->shortName()))
- {
- return std::unexpected(mw::runtimeError(
- "A compiled game requires a lowercase alphanumeric short name"));
- }
- if(find(game->shortName()) != nullptr)
- {
- return std::unexpected(mw::runtimeError(
- "A compiled game short name was registered more than once"));
- }
- games_.push_back(std::move(game));
- return {};
-}
-
-const GameDefinition* GameRegistry::find(std::string_view short_name) const
-{
- const auto position = std::ranges::find_if(
- games_,
- [short_name](const std::unique_ptr<GameDefinition>& game)
- {
- return game->shortName() == short_name;
- });
- return position == games_.end() ? nullptr : position->get();
-}
-
-std::vector<const GameDefinition*> GameRegistry::games() const
-{
- std::vector<const GameDefinition*> result;
- result.reserve(games_.size());
- for(const std::unique_ptr<GameDefinition>& game : games_)
- {
- result.push_back(game.get());
- }
- return result;
-}
diff --git a/src/game_registry.h b/src/game_registry.h
deleted file mode 100644
index b9c7569..0000000
--- a/src/game_registry.h
+++ /dev/null
@@ -1,32 +0,0 @@
-#pragma once
-
-#include <memory>
-#include <string_view>
-#include <vector>
-
-#include <mw/error.hpp>
-
-class GameDefinition;
-
-/// Immutable-after-startup collection of compiled game definitions.
-class GameRegistry
-{
-public:
- /// Construct an empty registry.
- GameRegistry();
-
- /// Destroy all owned compiled game definitions.
- ~GameRegistry();
-
- /// Register one uniquely named compiled game definition.
- mw::E<void> add(std::unique_ptr<GameDefinition> game);
-
- /// Return a registered game by short name, or null when absent.
- const GameDefinition* find(std::string_view short_name) const;
-
- /// Return all registered games in registration order.
- std::vector<const GameDefinition*> games() const;
-
-private:
- std::vector<std::unique_ptr<GameDefinition>> games_;
-};
diff --git a/src/game_service.cpp b/src/game_service.cpp
new file mode 100644
index 0000000..ef9fa44
--- /dev/null
+++ b/src/game_service.cpp
@@ -0,0 +1,580 @@
+#include "game_service.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+#include <mw/utils.hpp>
+
+#include "game_field.h"
+
+namespace
+{
+
+mw::E<void> authorizeAdministrator(
+ DataSourceTransactionInterface& transaction,
+ const AuthorizationService& authorization,
+ std::int64_t actor_user_id)
+{
+ auto actor = transaction.getUserForUpdate(actor_user_id);
+ if(!actor)
+ {
+ return std::unexpected(std::move(actor.error()));
+ }
+ if(!*actor || !(*actor)->username ||
+ !authorization.canAdminister(**actor))
+ {
+ return std::unexpected(mw::httpError(403, "Administrator required"));
+ }
+ return {};
+}
+
+bool validShortName(const std::string& value)
+{
+ return !value.empty() && std::ranges::all_of(
+ value,
+ [](unsigned char character)
+ {
+ return (character >= 'a' && character <= 'z') ||
+ (character >= '0' && character <= '9');
+ });
+}
+
+bool validFieldKey(const std::string& value)
+{
+ if(value.empty() || value.front() < 'a' || value.front() > 'z')
+ {
+ return false;
+ }
+ return std::ranges::all_of(
+ value,
+ [](unsigned char character)
+ {
+ return (character >= 'a' && character <= 'z') ||
+ (character >= '0' && character <= '9') ||
+ character == '_';
+ });
+}
+
+mw::E<void> validateDisplayText(
+ std::string& display_name,
+ const std::string& description,
+ MarkdownRenderer& renderer)
+{
+ display_name = std::string(mw::strip(display_name));
+ if(display_name.empty())
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "display_name", "Game name is required"}));
+ }
+ if(!validGameText(display_name))
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "display_name", "Game name must be UTF-8 text"}));
+ }
+ if(!validGameText(description))
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "description", "Game description must be UTF-8 text"}));
+ }
+ auto rendered = renderer.render(description);
+ if(!rendered)
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "description", rendered.error().msg()}));
+ }
+ return {};
+}
+
+mw::E<std::vector<GameChoice>> validateChoices(
+ GameFieldType type, const std::vector<std::string>& values)
+{
+ if(type != GameFieldType::CHOICE && !values.empty())
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "choice", "Only choice fields can define choices"}));
+ }
+ if(type == GameFieldType::CHOICE && values.empty())
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "choice", "A choice field requires at least one choice"}));
+ }
+ std::unordered_set<std::string> unique;
+ std::vector<GameChoice> choices;
+ choices.reserve(values.size());
+ for(std::size_t position = 0; position < values.size(); ++position)
+ {
+ const std::string& value = values[position];
+ if(value.empty() || !validGameText(value))
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "choice", "Choices must be nonempty UTF-8 text"}));
+ }
+ if(!unique.insert(value).second)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "Choice values must be unique"));
+ }
+ choices.push_back({value, static_cast<std::int64_t>(position)});
+ }
+ return choices;
+}
+
+const GameField* findField(
+ const GameDefinitionSnapshot& definition, std::int64_t field_id)
+{
+ const auto field = std::ranges::find_if(
+ definition.fields,
+ [field_id](const GameField& candidate)
+ {
+ return candidate.id == field_id;
+ });
+ return field == definition.fields.end() ? nullptr : &*field;
+}
+
+mw::E<GameDefinitionSnapshot> readDefinition(
+ DataSourceTransactionInterface& transaction,
+ const std::string& short_name,
+ std::int64_t expected_revision)
+{
+ auto definition = transaction.getGameDefinitionForUpdate(short_name);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ if(!*definition)
+ {
+ return std::unexpected(mw::httpError(404, "Game not found"));
+ }
+ if((**definition).game.revision != expected_revision)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "The game definition changed; reload and try again"));
+ }
+ return std::move(**definition);
+}
+
+mw::E<void> advanceRevision(
+ DataSourceTransactionInterface& transaction,
+ const std::string& short_name,
+ std::int64_t expected_revision)
+{
+ auto advanced = transaction.incrementGameRevision(
+ short_name, expected_revision);
+ if(!advanced)
+ {
+ return std::unexpected(std::move(advanced.error()));
+ }
+ if(!*advanced)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "The game definition changed; reload and try again"));
+ }
+ return {};
+}
+
+} // namespace
+
+GameService::GameService(DataSourceInterface& data_source)
+ : data_source_(data_source)
+{}
+
+mw::E<std::string> GameService::createGame(
+ std::int64_t actor_user_id,
+ std::string short_name,
+ std::string display_name,
+ std::string description)
+{
+ if(!validShortName(short_name))
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "short_name",
+ "Short name must contain lowercase letters and digits"}));
+ }
+ auto valid = validateDisplayText(
+ display_name, description, markdown_renderer_);
+ if(!valid)
+ {
+ return std::unexpected(std::move(valid.error()));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
+ auto existing = (*transaction)->getGameDefinitionForUpdate(short_name);
+ if(!existing)
+ {
+ return std::unexpected(std::move(existing.error()));
+ }
+ if(*existing)
+ {
+ return std::unexpected(mw::httpError(
+ 409, "A game with this short name already exists"));
+ }
+ auto inserted = (*transaction)->insertGame(
+ {short_name, std::move(display_name), std::move(description), 1});
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return short_name;
+}
+
+mw::E<void> GameService::updateGame(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision,
+ std::string display_name,
+ std::string description)
+{
+ auto valid = validateDisplayText(
+ display_name, description, markdown_renderer_);
+ if(!valid)
+ {
+ return std::unexpected(std::move(valid.error()));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
+ auto definition = readDefinition(
+ **transaction, short_name, expected_revision);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ auto updated = (*transaction)->updateGame(
+ short_name, display_name, description, expected_revision);
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ if(!*updated)
+ {
+ return std::unexpected(mw::httpError(409, "Game update conflict"));
+ }
+ return (*transaction)->commit();
+}
+
+mw::E<void> GameService::removeGame(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision)
+{
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
+ auto definition = readDefinition(
+ **transaction, short_name, expected_revision);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ auto usage = (*transaction)->getGameUsage(short_name);
+ if(!usage)
+ {
+ return std::unexpected(std::move(usage.error()));
+ }
+ if(usage->card_count != 0 || usage->series_count != 0 ||
+ usage->last_number != 0)
+ {
+ return std::unexpected(mw::httpError(
+ 409,
+ "This game cannot be deleted because it has " +
+ std::to_string(usage->card_count) + " card(s), " +
+ std::to_string(usage->series_count) + " series, and has " +
+ "issued card number " + std::to_string(usage->last_number)));
+ }
+ auto deleted = (*transaction)->deleteGame(short_name);
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ return (*transaction)->commit();
+}
+
+mw::E<std::int64_t> GameService::createField(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision,
+ std::string key,
+ std::string label,
+ GameFieldType type,
+ std::vector<std::string> choice_values)
+{
+ label = std::string(mw::strip(label));
+ if(!validFieldKey(key))
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "key", "Field key is invalid"}));
+ }
+ if(label.empty() || !validGameText(label))
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "label", "Field label is invalid"}));
+ }
+ auto choices = validateChoices(type, choice_values);
+ if(!choices)
+ {
+ return std::unexpected(std::move(choices.error()));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
+ auto definition = readDefinition(
+ **transaction, short_name, expected_revision);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ if(std::ranges::any_of(
+ definition->fields,
+ [&key](const GameField& field) { return field.key == key; }))
+ {
+ return std::unexpected(mw::httpError(
+ 409, "A field with this key already exists"));
+ }
+ GameField field{
+ 0, short_name, std::move(key), std::move(label), type,
+ static_cast<std::int64_t>(definition->fields.size()), {}};
+ auto inserted = (*transaction)->insertGameField(field, *choices);
+ if(!inserted)
+ {
+ return std::unexpected(std::move(inserted.error()));
+ }
+ auto advanced = advanceRevision(
+ **transaction, short_name, expected_revision);
+ if(!advanced)
+ {
+ return std::unexpected(std::move(advanced.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return *inserted;
+}
+
+mw::E<void> GameService::updateField(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t field_id,
+ std::int64_t expected_revision,
+ std::string label,
+ std::vector<std::string> choice_values)
+{
+ label = std::string(mw::strip(label));
+ if(label.empty() || !validGameText(label))
+ {
+ return std::unexpected(mw::Error(GameDefinitionValidationError{
+ "label", "Field label is invalid"}));
+ }
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
+ auto definition = readDefinition(
+ **transaction, short_name, expected_revision);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ const GameField* field = findField(*definition, field_id);
+ if(field == nullptr)
+ {
+ return std::unexpected(mw::httpError(404, "Game field not found"));
+ }
+ auto choices = validateChoices(field->type, choice_values);
+ if(!choices)
+ {
+ return std::unexpected(std::move(choices.error()));
+ }
+ for(const GameChoice& existing : field->choices)
+ {
+ const bool retained = std::ranges::any_of(
+ *choices,
+ [&existing](const GameChoice& choice)
+ {
+ return choice.value == existing.value;
+ });
+ if(!retained)
+ {
+ auto count = (*transaction)->countChoiceUsage(
+ field_id, existing.value);
+ if(!count)
+ {
+ return std::unexpected(std::move(count.error()));
+ }
+ if(*count != 0)
+ {
+ return std::unexpected(mw::httpError(
+ 409,
+ "This choice cannot be deleted because " +
+ std::to_string(*count) + " card(s) use it"));
+ }
+ }
+ }
+ auto updated = (*transaction)->updateGameField(
+ field_id, label, *choices);
+ if(!updated)
+ {
+ return std::unexpected(std::move(updated.error()));
+ }
+ auto advanced = advanceRevision(
+ **transaction, short_name, expected_revision);
+ if(!advanced)
+ {
+ return std::unexpected(std::move(advanced.error()));
+ }
+ return (*transaction)->commit();
+}
+
+mw::E<void> GameService::removeField(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t field_id,
+ std::int64_t expected_revision)
+{
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
+ auto definition = readDefinition(
+ **transaction, short_name, expected_revision);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ if(findField(*definition, field_id) == nullptr)
+ {
+ return std::unexpected(mw::httpError(404, "Game field not found"));
+ }
+ auto count = (*transaction)->countFieldUsage(field_id);
+ if(!count)
+ {
+ return std::unexpected(std::move(count.error()));
+ }
+ if(*count != 0)
+ {
+ return std::unexpected(mw::httpError(
+ 409,
+ "This field cannot be deleted because " +
+ std::to_string(*count) + " card(s) use it"));
+ }
+ auto deleted = (*transaction)->deleteGameField(field_id);
+ if(!deleted)
+ {
+ return std::unexpected(std::move(deleted.error()));
+ }
+ auto advanced = advanceRevision(
+ **transaction, short_name, expected_revision);
+ if(!advanced)
+ {
+ return std::unexpected(std::move(advanced.error()));
+ }
+ return (*transaction)->commit();
+}
+
+mw::E<void> GameService::reorderFields(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision,
+ const std::vector<std::int64_t>& field_ids)
+{
+ auto transaction = data_source_.beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto authorized = authorizeAdministrator(
+ **transaction, authorization_, actor_user_id);
+ if(!authorized)
+ {
+ return std::unexpected(std::move(authorized.error()));
+ }
+ auto definition = readDefinition(
+ **transaction, short_name, expected_revision);
+ if(!definition)
+ {
+ return std::unexpected(std::move(definition.error()));
+ }
+ std::unordered_set<std::int64_t> requested(
+ field_ids.begin(), field_ids.end());
+ if(requested.size() != field_ids.size() ||
+ field_ids.size() != definition->fields.size() ||
+ std::ranges::any_of(
+ definition->fields,
+ [&requested](const GameField& field)
+ {
+ return !requested.contains(field.id);
+ }))
+ {
+ return std::unexpected(mw::httpError(422, "Invalid field order"));
+ }
+ auto reordered = (*transaction)->updateGameFieldOrder(
+ short_name, field_ids);
+ if(!reordered)
+ {
+ return std::unexpected(std::move(reordered.error()));
+ }
+ auto advanced = advanceRevision(
+ **transaction, short_name, expected_revision);
+ if(!advanced)
+ {
+ return std::unexpected(std::move(advanced.error()));
+ }
+ return (*transaction)->commit();
+}
diff --git a/src/game_service.h b/src/game_service.h
new file mode 100644
index 0000000..955afe3
--- /dev/null
+++ b/src/game_service.h
@@ -0,0 +1,88 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+#include "authorization.h"
+#include "data.h"
+#include "markdown_renderer.h"
+
+/// A game-definition validation failure addressable by an editor control.
+struct GameDefinitionValidationError
+{
+ /// Logical form field containing the invalid definition value.
+ std::string field_name;
+
+ /// Safe user-facing validation message.
+ std::string msg;
+};
+
+/// Validate and coordinate database-defined game mutations.
+class GameService
+{
+public:
+ /// Construct a game service over the application persistence boundary.
+ explicit GameService(DataSourceInterface& data_source);
+
+ /// Create a game and return its permanent short name.
+ mw::E<std::string> createGame(
+ std::int64_t actor_user_id,
+ std::string short_name,
+ std::string display_name,
+ std::string description);
+
+ /// Update a game's mutable presentation metadata.
+ mw::E<void> updateGame(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision,
+ std::string display_name,
+ std::string description);
+
+ /// Delete a game only when it has never issued a card number.
+ mw::E<void> removeGame(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision);
+
+ /// Add a custom field and return its internal identity.
+ mw::E<std::int64_t> createField(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision,
+ std::string key,
+ std::string label,
+ GameFieldType type,
+ std::vector<std::string> choices);
+
+ /// Update a field label and its complete ordered choice list.
+ mw::E<void> updateField(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t field_id,
+ std::int64_t expected_revision,
+ std::string label,
+ std::vector<std::string> choices);
+
+ /// Delete an unused custom field.
+ mw::E<void> removeField(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t field_id,
+ std::int64_t expected_revision);
+
+ /// Save a complete ordering of a game's custom fields.
+ mw::E<void> reorderFields(
+ std::int64_t actor_user_id,
+ const std::string& short_name,
+ std::int64_t expected_revision,
+ const std::vector<std::int64_t>& field_ids);
+
+private:
+ DataSourceInterface& data_source_;
+ AuthorizationService authorization_;
+ MarkdownRenderer markdown_renderer_;
+};
diff --git a/src/going_home.h b/src/going_home.h
deleted file mode 100644
index 0c5b66a..0000000
--- a/src/going_home.h
+++ /dev/null
@@ -1,98 +0,0 @@
-#pragma once
-
-#include "game_definition.h"
-
-/// Going Home uses the standard card fields without additional metadata.
-class GoingHome final : public GameDefinition
-{
-public:
- /// Return the permanent public card-ID prefix.
- std::string_view shortName() const override
- {
- return "gh";
- }
-
- /// Return the name displayed in game and series selectors.
- std::string_view displayName() const override
- {
- return "Going Home";
- }
-
- /// No game description has been supplied yet.
- std::string_view description() const override
- {
- return "";
- }
-
- /// Standard card storage is sufficient for this game.
- mw::E<void> createSchema(
- [[maybe_unused]] mw::SQLite& database) const override
- {
- return {};
- }
-
- /// Accept an empty set of game-specific fields.
- mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
- const FormFields& fields) const override
- {
- if(!fields.empty())
- {
- return std::unexpected(mw::httpError(
- 422, "Going Home has no additional card fields"));
- }
- return std::make_unique<Metadata>();
- }
-
- /// No extra controls are needed on the card form.
- std::vector<GameFormField> formFields() const override
- {
- return {};
- }
-
- /// There are no extra values to load when editing a card.
- mw::E<FormFields> formValues(
- [[maybe_unused]] mw::SQLite& database,
- [[maybe_unused]] std::int64_t card_id) const override
- {
- return FormFields{};
- }
-
- /// Validate metadata ownership without creating extension rows.
- mw::E<void> insertMetadata(
- [[maybe_unused]] mw::SQLite& database,
- [[maybe_unused]] std::int64_t card_id,
- const GameCardMetadata& metadata) const override
- {
- return checkMetadata(metadata);
- }
-
- /// Validate metadata ownership without updating extension rows.
- mw::E<void> updateMetadata(
- [[maybe_unused]] mw::SQLite& database,
- [[maybe_unused]] std::int64_t card_id,
- const GameCardMetadata& metadata) const override
- {
- return checkMetadata(metadata);
- }
-
- /// Standard card details contain all the information for this game.
- mw::E<std::vector<DisplayField>> displayFields(
- [[maybe_unused]] mw::SQLite& database,
- [[maybe_unused]] std::int64_t card_id) const override
- {
- return std::vector<DisplayField>{};
- }
-
-private:
- struct Metadata final : public GameCardMetadata {};
-
- static mw::E<void> checkMetadata(const GameCardMetadata& metadata)
- {
- if(dynamic_cast<const Metadata*>(&metadata) == nullptr)
- {
- return std::unexpected(mw::runtimeError(
- "Going Home received metadata of the wrong type"));
- }
- return {};
- }
-};
diff --git a/src/main.cpp b/src/main.cpp
index 82cdbb9..001282a 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -13,8 +13,6 @@
#include "app.h"
#include "clock.h"
-#include "game_registry.h"
-#include "going_home.h"
#include "startup.h"
namespace
@@ -110,21 +108,12 @@ int main(int argc, char** argv)
una::version::unicode.minor(),
una::version::unicode.update());
- auto games = std::make_unique<GameRegistry>();
- auto registered = games->add(std::make_unique<GoingHome>());
- if(!registered)
- {
- spdlog::error(
- "Failed to register Going Home: {}", registered.error().msg());
- return 1;
- }
const auto startup_time = std::chrono::system_clock::now();
const std::int64_t startup_seconds =
std::chrono::duration_cast<std::chrono::seconds>(
startup_time.time_since_epoch()).count();
auto data_source = prepareDataSource(
config->database_path,
- *games,
config->administrator_email,
config->administrator_email_key,
startup_seconds,
@@ -152,7 +141,6 @@ int main(int argc, char** argv)
App app(
*config,
std::move(*data_source),
- std::move(games),
std::make_unique<NonSecretRandom>());
std::signal(SIGINT, handleSignal);
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index 4744426..145494f 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -13,10 +13,11 @@
#include <unordered_set>
#include <utility>
+#include "form_limits.h"
+
namespace
{
-inline constexpr std::size_t MAX_TEXT_FIELD_SIZE = 1024 * 1024;
inline constexpr std::size_t MAX_IMAGE_SIZE = 32 * 1024 * 1024;
inline constexpr std::size_t MAX_TOTAL_IMAGE_SIZE = 72 * 1024 * 1024;
@@ -40,6 +41,7 @@ bool isTextField(std::string_view name)
"front_action",
"foil_action",
"revision",
+ "game_revision",
"series_id",
"csrf_token",
};
@@ -135,6 +137,7 @@ mw::E<CardUpload> MultipartReader::read(
bool ignore_part = false;
std::size_t part_size = 0;
std::size_t total_image_size = 0;
+ std::size_t total_text_size = 0;
std::string error_message;
int error_status = 400;
@@ -226,12 +229,19 @@ mw::E<CardUpload> MultipartReader::read(
}
if(text_value != nullptr)
{
- if(part_size > MAX_TEXT_FIELD_SIZE)
+ total_text_size += size;
+ if(part_size > MAX_FORM_TEXT_FIELD_SIZE)
{
error_message = "A form field is too large";
error_status = 413;
return false;
}
+ if(total_text_size > MAX_FORM_TOTAL_TEXT_SIZE)
+ {
+ error_message = "Card metadata is too large";
+ error_status = 413;
+ return false;
+ }
text_value->append(data, size);
return true;
}
diff --git a/src/series_service.cpp b/src/series_service.cpp
index 50fd71c..a85b201 100644
--- a/src/series_service.cpp
+++ b/src/series_service.cpp
@@ -55,11 +55,8 @@ mw::E<void> authorizeAdministrator(
} // namespace
-SeriesService::SeriesService(
- DataSourceInterface& data_source,
- const GameRegistry& games)
- : data_source_(data_source),
- games_(games)
+SeriesService::SeriesService(DataSourceInterface& data_source)
+ : data_source_(data_source)
{}
mw::E<std::int64_t> SeriesService::create(
@@ -69,10 +66,6 @@ mw::E<std::int64_t> SeriesService::create(
std::string description)
{
name = std::string(mw::strip(name));
- if(games_.find(game_short_name) == nullptr)
- {
- return std::unexpected(mw::httpError(422, "Unknown compiled game"));
- }
if(name.empty())
{
return std::unexpected(mw::httpError(422, "Series name is required"));
@@ -108,6 +101,16 @@ mw::E<std::int64_t> SeriesService::create(
{
return std::unexpected(std::move(authorized.error()));
}
+ auto game = (*transaction)->getGameDefinitionForUpdate(
+ series.game_short_name);
+ if(!game)
+ {
+ return std::unexpected(std::move(game.error()));
+ }
+ if(!*game)
+ {
+ return std::unexpected(mw::httpError(422, "Unknown game"));
+ }
auto id = (*transaction)->insertSeries(series);
if(!id)
{
diff --git a/src/series_service.h b/src/series_service.h
index c05da3c..1733197 100644
--- a/src/series_service.h
+++ b/src/series_service.h
@@ -7,7 +7,6 @@
#include "authorization.h"
#include "data.h"
-#include "game_registry.h"
#include "markdown_renderer.h"
/// Validate and coordinate dynamic series mutations.
@@ -15,9 +14,7 @@ class SeriesService
{
public:
/// Construct a series service from its persistence and game boundaries.
- SeriesService(
- DataSourceInterface& data_source,
- const GameRegistry& games);
+ explicit SeriesService(DataSourceInterface& data_source);
/// Create a series and return its internal ID.
mw::E<std::int64_t> create(
@@ -40,7 +37,6 @@ public:
private:
DataSourceInterface& data_source_;
- const GameRegistry& games_;
AuthorizationService authorization_;
MarkdownRenderer markdown_renderer_;
};
diff --git a/src/startup.cpp b/src/startup.cpp
index e66d64c..8bc73ce 100644
--- a/src/startup.cpp
+++ b/src/startup.cpp
@@ -5,12 +5,8 @@
#include <spdlog/spdlog.h>
#include "data_sqlite.h"
-#include "game_definition.h"
-#include "game_registry.h"
-
mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
const std::filesystem::path& database_path,
- const GameRegistry& games,
const std::string& administrator_email,
const std::string& administrator_email_key,
std::int64_t now,
@@ -21,11 +17,16 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
{
return std::unexpected(std::move(data_source.error()));
}
- auto migration_result = (*data_source)->migrateToLatest(games);
+ auto migration_result = (*data_source)->migrateToLatest();
if(!migration_result)
{
return std::unexpected(std::move(migration_result.error()));
}
+ auto integrity_result = (*data_source)->checkIntegrity();
+ if(!integrity_result)
+ {
+ return std::unexpected(std::move(integrity_result.error()));
+ }
auto administrator = (*data_source)->reconcileAdministrator(
administrator_email,
@@ -44,39 +45,5 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
cleaned.error().msg());
}
- auto transaction = (*data_source)->beginTransaction();
- if(!transaction)
- {
- return std::unexpected(std::move(transaction.error()));
- }
- for(const GameDefinition* game : games.games())
- {
- auto ensured = (*transaction)->ensureGameSequence(
- std::string(game->shortName()));
- if(!ensured)
- {
- return std::unexpected(std::move(ensured.error()));
- }
- }
- auto committed = (*transaction)->commit();
- if(!committed)
- {
- return std::unexpected(std::move(committed.error()));
- }
-
- auto persisted_games = (*data_source)->getPersistedGameNames();
- if(!persisted_games)
- {
- return std::unexpected(std::move(persisted_games.error()));
- }
- for(const std::string& name : *persisted_games)
- {
- if(games.find(name) == nullptr)
- {
- return std::unexpected(mw::runtimeError(
- "Database uses unregistered compiled game '" + name +
- "'; a schema migration is required"));
- }
- }
return std::move(*data_source);
}
diff --git a/src/startup.h b/src/startup.h
index 2f20502..6114313 100644
--- a/src/startup.h
+++ b/src/startup.h
@@ -9,10 +9,9 @@
#include "data.h"
-/// Open and migrate the application data source before server startup.
+/// Open, migrate, and reconcile the data source before server startup.
mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
const std::filesystem::path& database_path,
- const GameRegistry& games,
const std::string& administrator_email = "admin@example.com",
const std::string& administrator_email_key = "admin@example.com",
std::int64_t now = 0,
diff --git a/static/card_form.js b/static/card_form.js
index 4f56ae7..7733c59 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -61,22 +61,24 @@ function updateSourceMode()
foil_url.required = state.foil_url_enabled && state.foil_required;
}
-/** Show only series belonging to the selected compiled game. */
+/** Show controls and series belonging to the selected database game. */
function updateSeriesChoices()
{
const game = document.getElementById("CardGame").value;
for(const group of document.querySelectorAll(".game-fields"))
{
- const visible = group.dataset.game == game;
+ const visible = window.cardFormLogic.gameControlEnabled(
+ game, group.dataset.game);
group.hidden = !visible;
- for(const input of group.querySelectorAll("input"))
+ for(const input of group.querySelectorAll("input, textarea, select"))
{
input.disabled = !visible;
}
}
for(const choice of document.querySelectorAll(".series-choice"))
{
- const visible = game != "" && choice.dataset.game == game;
+ const visible = window.cardFormLogic.seriesChoiceEnabled(
+ game, choice.dataset.game);
choice.hidden = !visible;
choice.querySelector("input").disabled = !visible;
}
diff --git a/static/card_form_logic.js b/static/card_form_logic.js
index aaadb16..52605e0 100644
--- a/static/card_form_logic.js
+++ b/static/card_form_logic.js
@@ -50,10 +50,24 @@
};
}
+ /** Return whether controls for one game belong to the selection. */
+ function gameControlEnabled(selected_game, control_game)
+ {
+ return selected_game == control_game;
+ }
+
+ /** Return whether a series choice belongs to the selected game. */
+ function seriesChoiceEnabled(selected_game, series_game)
+ {
+ return selected_game != "" && selected_game == series_game;
+ }
+
const api = {
validRemoteImageUrl,
foilThumbnailRequired,
sourceInputState,
+ gameControlEnabled,
+ seriesChoiceEnabled,
};
if(typeof module != "undefined" && module.exports != null)
{
diff --git a/static/css/styles.css b/static/css/styles.css
index 59273f9..aabdc6d 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -591,6 +591,10 @@ h1 {
font-weight: 800;
}
+.game-field-value {
+ white-space: pre-wrap;
+}
+
.card-info-section {
margin-top: 2rem;
padding-top: 1.75rem;
@@ -652,6 +656,10 @@ h1 {
border-top: 1px solid rgb(99 95 105 / 12%);
}
+.form-section[hidden] {
+ display: none;
+}
+
.form-section h2,
.form-section legend {
margin: 0;
@@ -1093,6 +1101,18 @@ h1 {
color: var(--violet);
}
+.clay-action-danger {
+ background: linear-gradient(145deg, #fb7185, #be123c);
+ color: #ffffff;
+}
+
+.clay-action:disabled {
+ box-shadow: var(--clay-pressed-shadow);
+ cursor: not-allowed;
+ opacity: 0.48;
+ transform: none;
+}
+
.welcome-preview {
position: relative;
display: grid;
@@ -1799,35 +1819,6 @@ h1 {
width: 100%;
}
-.compiled-game-note {
- display: flex;
- align-items: center;
- gap: 1.25rem;
- margin-bottom: 2.5rem;
- padding: 1.25rem 1.5rem;
- border: 1px solid rgb(255 255 255 / 82%);
- border-radius: 2rem;
- background: rgb(255 255 255 / 70%);
- box-shadow: var(--clay-card-shadow);
- backdrop-filter: blur(1.25rem);
-}
-
-.compiled-game-note .clay-icon {
- margin: 0;
-}
-
-.compiled-game-note strong {
- font-family: Nunito, ui-rounded, sans-serif;
- font-size: 1.1rem;
- font-weight: 900;
-}
-
-.compiled-game-note p {
- max-width: 56rem;
- margin: 0.1rem 0 0;
- color: var(--muted);
-}
-
.game-admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
@@ -1940,6 +1931,148 @@ h1 {
text-transform: uppercase;
}
+.game-editor {
+ width: 100%;
+}
+
+.clay-form-panel,
+.definition-panel {
+ padding: clamp(1.5rem, 4vw, 2.5rem);
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: 2.5rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+ backdrop-filter: blur(1.25rem);
+}
+
+.clay-form-panel {
+ width: min(52rem, 100%);
+}
+
+.definition-panel {
+ margin-top: 2.5rem;
+}
+
+.section-heading,
+.definition-list > li,
+.definition-actions,
+.choice-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.section-heading h2,
+.definition-copy h3 {
+ margin: 0;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-weight: 900;
+}
+
+.definition-list {
+ display: grid;
+ gap: 1rem;
+ margin: 1.75rem 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.definition-list > li {
+ padding: 1.25rem;
+ border-radius: 1.75rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
+}
+
+.definition-copy p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.definition-actions {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.definition-actions form {
+ margin: 0;
+}
+
+.definition-actions .clay-action,
+.choice-row .clay-action {
+ min-height: 2.75rem;
+ padding: 0.55rem 0.9rem;
+}
+
+.choice-rows {
+ display: grid;
+ gap: 0.85rem;
+ margin-bottom: 1rem;
+}
+
+.choice-row input {
+ min-width: 10rem;
+ min-height: 3.25rem;
+ flex: 1;
+ padding: 0.75rem 1rem;
+ border: 0;
+ border-radius: 1.15rem;
+ background: #efebf5;
+ box-shadow:
+ inset 7px 7px 15px rgb(185 177 198 / 55%),
+ inset -7px -7px 15px rgb(255 255 255 / 90%);
+ color: var(--foreground);
+ font: inherit;
+ font-weight: 600;
+}
+
+.choice-row input:focus {
+ background: rgb(255 255 255 / 84%);
+ outline: 0.25rem solid rgb(124 58 237 / 20%);
+}
+
+.form-tail-actions {
+ margin-top: 2rem;
+ margin-bottom: 0;
+}
+
+.mutation-error-page {
+ width: min(56rem, 100%);
+}
+
+.mutation-error-page h1 {
+ margin-bottom: 2rem;
+}
+
+.error-summary {
+ margin-bottom: 2rem;
+ padding: 1.5rem;
+ border: 2px solid rgb(190 24 93 / 28%);
+ border-radius: 1.75rem;
+ background: rgb(255 255 255 / 72%);
+ box-shadow: var(--clay-card-shadow);
+}
+
+.error-summary h2,
+.error-summary p {
+ margin-top: 0;
+}
+
+.error-summary p:last-child {
+ margin-bottom: 0;
+}
+
+.field-error {
+ color: #9d174d;
+ font-weight: 800;
+}
+
+[aria-invalid="true"] {
+ border-color: #be185d;
+ box-shadow: 0 0 0 0.2rem rgb(190 24 93 / 14%);
+}
+
@media(max-width: 42rem) {
.management-actions,
.page-heading .management-actions {
@@ -1956,12 +2089,21 @@ h1 {
grid-template-columns: 1fr;
}
- .compiled-game-note {
- align-items: flex-start;
+ .game-admin-card {
+ border-radius: 2rem;
+ }
+
+ .section-heading,
+ .definition-list > li,
+ .choice-row {
+ align-items: stretch;
flex-direction: column;
}
- .game-admin-card {
- border-radius: 2rem;
+ .definition-actions,
+ .definition-actions form,
+ .definition-actions .clay-action,
+ .choice-row .clay-action {
+ width: 100%;
}
}
diff --git a/static/game_field_form.js b/static/game_field_form.js
new file mode 100644
index 0000000..81392a5
--- /dev/null
+++ b/static/game_field_form.js
@@ -0,0 +1,108 @@
+/** Create one editable choice row. */
+function createChoiceRow()
+{
+ const row = document.createElement("div");
+ row.className = "choice-row";
+ row.innerHTML = `
+ <input name="choice" type="text" required>
+ <button class="clay-action clay-action-secondary move-up"
+ type="button">Move up</button>
+ <button class="clay-action clay-action-secondary move-down"
+ type="button">Move down</button>
+ <button class="clay-action clay-action-danger remove-choice"
+ type="button">Remove</button>`;
+ return row;
+}
+
+/** Return the bounded destination for one choice movement. */
+function choiceMoveIndex(index, direction, count)
+{
+ if(count <= 0 || index < 0 || index >= count)
+ {
+ return index;
+ }
+ return Math.max(0, Math.min(count - 1, index + direction));
+}
+
+/** Update choice-editor visibility and button states. */
+function updateChoiceEditor()
+{
+ const type = document.getElementById("FieldType");
+ const is_choice = type.value == "CHOICE" ||
+ type.dataset.fieldType == "CHOICE";
+ const editor = document.getElementById("ChoiceEditor");
+ editor.hidden = !is_choice;
+ for(const input of editor.querySelectorAll("input"))
+ {
+ input.disabled = !is_choice;
+ }
+ const rows = [...document.querySelectorAll(".choice-row")];
+ for(let index = 0; index < rows.length; ++index)
+ {
+ rows[index].querySelector(".move-up").disabled = index == 0;
+ rows[index].querySelector(".move-down").disabled =
+ index + 1 == rows.length;
+ }
+}
+
+/** Handle add, remove, and reorder controls for choice rows. */
+function handleChoiceAction(event)
+{
+ const button = event.target.closest("button");
+ if(button == null)
+ {
+ return;
+ }
+ const rows = document.getElementById("ChoiceRows");
+ if(button.id == "AddChoice")
+ {
+ const row = createChoiceRow();
+ rows.append(row);
+ row.querySelector("input").focus();
+ }
+ else
+ {
+ const row = button.closest(".choice-row");
+ if(button.classList.contains("remove-choice"))
+ {
+ const focus_target = row.nextElementSibling ??
+ row.previousElementSibling;
+ row.remove();
+ focus_target?.querySelector("input").focus();
+ }
+ else
+ {
+ const current_rows = [...rows.querySelectorAll(".choice-row")];
+ const index = current_rows.indexOf(row);
+ const direction = button.classList.contains("move-up")
+ ? -1
+ : button.classList.contains("move-down") ? 1 : 0;
+ const target = choiceMoveIndex(
+ index, direction, current_rows.length);
+ if(target < index)
+ {
+ rows.insertBefore(row, current_rows[target]);
+ button.focus();
+ }
+ else if(target > index)
+ {
+ rows.insertBefore(row, current_rows[target].nextSibling);
+ button.focus();
+ }
+ }
+ }
+ updateChoiceEditor();
+}
+
+if(typeof document != "undefined")
+{
+ document.getElementById("GameFieldForm").addEventListener(
+ "click", handleChoiceAction);
+ document.getElementById("FieldType").addEventListener(
+ "change", updateChoiceEditor);
+ updateChoiceEditor();
+}
+if(typeof module != "undefined" && module.exports != null)
+{
+ module.exports = {choiceMoveIndex};
+}
diff --git a/templates/card_form.html b/templates/card_form.html
index b9d9d0d..da7eda4 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -18,6 +18,22 @@
<p class="card-view-id">{{ display_id }}</p>
<h1 id="CardFormHeading">{{ heading }}</h1>
+ {% if has_errors %}
+ <div class="error-summary" role="alert" tabindex="-1">
+ <h2>Check your card details</h2>
+ <p>
+ {% if length(error_control_id) > 0 %}
+ <a href="#{{ error_control_id }}">{{ error_message }}</a>
+ {% else %}
+ {{ error_message }}
+ {% endif %}
+ </p>
+ {% if files_need_reselection %}
+ <p>Uploaded files were discarded and must be selected again.</p>
+ {% endif %}
+ </div>
+ {% endif %}
+
<form id="CardForm" class="card-form" method="post"
action="{{ action_url }}" enctype="multipart/form-data">
<input name="csrf_token" type="hidden" value="{{ csrf_token }}">
@@ -64,19 +80,59 @@
data-game="{{ game.short_name }}"
{% if game.short_name != selected_game %}hidden{% endif %}>
<legend>{{ game.display_name }} details</legend>
+ <input name="game_revision" type="hidden"
+ value="{{ game.revision }}"
+ {% if game.short_name != selected_game %}
+ disabled
+ {% endif %}>
{% for field in game.fields %}
- <label class="form-field">
+ <label class="form-field" for="{{ field.control_id }}">
<span>{{ field.label }}</span>
- <input name="game.{{ field.name }}"
- type="{{ field.input_type }}"
- {% if length(field.minimum) > 0 %}
- min="{{ field.minimum }}"
+ {% if field.is_string %}
+ <textarea id="{{ field.control_id }}"
+ name="game.{{ field.key }}" rows="4"
+ {% if field.has_error %}
+ aria-invalid="true"
+ aria-describedby="{{ field.error_id }}"
+ {% endif %}
+ {% if game.short_name != selected_game %}
+ disabled
+ {% endif %}>{{ field.value }}</textarea>
+ {% else if field.is_choice %}
+ <select id="{{ field.control_id }}"
+ name="game.{{ field.key }}"
+ {% if field.has_error %}
+ aria-invalid="true"
+ aria-describedby="{{ field.error_id }}"
+ {% endif %}
+ {% if game.short_name != selected_game %}
+ disabled
+ {% endif %}>
+ <option value="">Not set</option>
+ {% for choice in field.choices %}
+ <option value="{{ choice }}"
+ {% if choice == field.value %}selected{% endif %}>
+ {{ choice }}
+ </option>
+ {% endfor %}
+ </select>
+ {% else %}
+ <input id="{{ field.control_id }}"
+ name="game.{{ field.key }}" type="text"
+ inputmode="numeric" value="{{ field.value }}"
+ {% if field.has_error %}
+ aria-invalid="true"
+ aria-describedby="{{ field.error_id }}"
{% endif %}
- {% if field.required %}required{% endif %}
- value="{{ field.value }}"
{% if game.short_name != selected_game %}
disabled
{% endif %}>
+ {% endif %}
+ {% if field.has_error %}
+ <small id="{{ field.error_id }}" class="field-error">
+ {{ field.error_message }}
+ </small>
+ {% endif %}
</label>
{% endfor %}
</fieldset>
diff --git a/templates/card_view.html b/templates/card_view.html
index 786cc28..20e5453 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -54,7 +54,7 @@
{% for field in game_fields %}
<div>
<dt>{{ field.label }}</dt>
- <dd>{{ field.value }}</dd>
+ <dd class="game-field-value">{{ field.value }}</dd>
</div>
{% endfor %}
</dl>
diff --git a/templates/game_admin.html b/templates/game_admin.html
index ded4358..2dee589 100644
--- a/templates/game_admin.html
+++ b/templates/game_admin.html
@@ -8,30 +8,23 @@
<h1 id="GamesHeading">Games</h1>
</div>
<div class="management-actions" aria-label="Game actions">
+ <a class="clay-action" href="{{ create_url }}">
+ Create game
+ </a>
<a class="clay-action clay-action-secondary"
href="{{ series_url }}">
Manage series
</a>
- <a class="clay-action" href="{{ create_card_url }}">
+ <a class="clay-action clay-action-secondary"
+ href="{{ create_card_url }}">
Create card
</a>
</div>
</header>
- <div class="compiled-game-note" role="note">
- <div class="clay-icon clay-icon-blue" aria-hidden="true">⌘</div>
- <div>
- <strong>Games are installed with the application</strong>
- <p>
- Their fields and database schema are compiled definitions.
- Deploy a code change to add or alter a game.
- </p>
- </div>
- </div>
-
{% if length(games) == 0 %}
<div class="empty-panel">
- <h2>No games installed</h2>
+ <h2>No games defined</h2>
<p>Loose cards can still be created without a game definition.</p>
</div>
{% else %}
@@ -47,20 +40,28 @@
<h2>{{ game.display_name }}</h2>
</div>
</header>
- <p class="game-description">{{ game.description }}</p>
+ {% if game.has_description %}
+ <div class="game-description description-copy">
+ {{ game.description }}
+ </div>
+ {% endif %}
+ <div class="management-actions">
+ <a class="clay-action clay-action-secondary"
+ href="{{ game.edit_url }}">Edit game</a>
+ </div>
<div class="game-field-heading">
<strong>Card fields</strong>
<span>{{ game.field_count }}</span>
</div>
{% if length(game.fields) == 0 %}
- <p class="muted-copy">This game has no additional fields.</p>
+ <p class="muted-copy">Uses standard card fields.</p>
{% else %}
<ul class="game-field-list">
{% for field in game.fields %}
<li>
<span>
<strong>{{ field.label }}</strong>
- <small>{{ field.name }}</small>
+ <small>{{ field.key }}</small>
</span>
<span class="field-type">{{ field.input_type }}</span>
</li>
diff --git a/templates/game_delete.html b/templates/game_delete.html
new file mode 100644
index 0000000..37e2987
--- /dev/null
+++ b/templates/game_delete.html
@@ -0,0 +1,18 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteGameHeading">
+ <a class="back-link" href="{{ back_url }}">← Back to game</a>
+ <p class="eyebrow">Game administration</p>
+ <h1 id="DeleteGameHeading">Delete {{ display_name }}?</h1>
+ <p>This is available only before the game has issued any card numbers.</p>
+ <form class="standalone-form" method="post" action="{{ action_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <input type="hidden" name="game_revision"
+ value="{{ game_revision }}">
+ <button class="form-submit destructive-submit" type="submit">
+ Delete game
+ </button>
+ </form>
+</section>
+{% endblock %}
diff --git a/templates/game_field_delete.html b/templates/game_field_delete.html
new file mode 100644
index 0000000..f104c6b
--- /dev/null
+++ b/templates/game_field_delete.html
@@ -0,0 +1,18 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteFieldHeading">
+ <a class="back-link" href="{{ back_url }}">← Back to field</a>
+ <p class="eyebrow">Game administration</p>
+ <h1 id="DeleteFieldHeading">Delete {{ field_label }}?</h1>
+ <p>A field with values on any card cannot be deleted.</p>
+ <form class="standalone-form" method="post" action="{{ action_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <input type="hidden" name="game_revision"
+ value="{{ game_revision }}">
+ <button class="form-submit destructive-submit" type="submit">
+ Delete field
+ </button>
+ </form>
+</section>
+{% endblock %}
diff --git a/templates/game_field_form.html b/templates/game_field_form.html
new file mode 100644
index 0000000..6bf878d
--- /dev/null
+++ b/templates/game_field_form.html
@@ -0,0 +1,128 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="FieldFormHeading">
+ <a class="back-link" href="{{ back_url }}">← {{ game_name }}</a>
+ <p class="eyebrow">Game administration</p>
+ <h1 id="FieldFormHeading">{{ heading }}</h1>
+
+ {% if has_errors %}
+ <div class="error-summary" role="alert" tabindex="-1">
+ <h2>Check the field definition</h2>
+ <p><a href="#{{ error_control_id }}">{{ error_message }}</a></p>
+ </div>
+ {% endif %}
+
+ <form id="GameFieldForm" class="standalone-form" method="post"
+ action="{{ action_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ <input type="hidden" name="game_revision"
+ value="{{ game_revision }}">
+ <label class="form-field" for="FieldKey">
+ <span>Key</span>
+ <input id="FieldKey" {% if mode == "create" %}name="key"{% endif %}
+ type="text" value="{{ key }}"
+ pattern="[a-z][a-z0-9_]*" required
+ {% if error_field == "key" %}
+ aria-invalid="true" aria-describedby="FieldKeyError"
+ {% endif %}
+ {% if mode == "edit" %}readonly{% endif %}>
+ <small>Permanent form and storage identity.</small>
+ {% if error_field == "key" %}
+ <small id="FieldKeyError" class="field-error">
+ {{ error_message }}
+ </small>
+ {% endif %}
+ </label>
+ <label class="form-field" for="FieldLabel">
+ <span>Label</span>
+ <input id="FieldLabel" name="label" type="text"
+ value="{{ label }}" required
+ {% if error_field == "label" %}
+ aria-invalid="true" aria-describedby="FieldLabelError"
+ {% endif %}>
+ {% if error_field == "label" %}
+ <small id="FieldLabelError" class="field-error">
+ {{ error_message }}
+ </small>
+ {% endif %}
+ </label>
+ <label class="form-field" for="FieldType">
+ <span>Type</span>
+ {% if mode == "create" %}
+ <select id="FieldType" name="type"
+ {% if error_field == "type" %}
+ aria-invalid="true" aria-describedby="FieldTypeError"
+ {% endif %}>
+ <option value="INTEGER"
+ {% if field_type == "INTEGER" %}selected{% endif %}>
+ Integer
+ </option>
+ <option value="STRING"
+ {% if field_type == "STRING" %}selected{% endif %}>
+ String
+ </option>
+ <option value="CHOICE"
+ {% if field_type == "CHOICE" %}selected{% endif %}>
+ Choice
+ </option>
+ </select>
+ {% else %}
+ <input id="FieldType" type="text" value="{{ field_type }}"
+ readonly data-field-type="{{ field_type }}">
+ {% endif %}
+ {% if error_field == "type" %}
+ <small id="FieldTypeError" class="field-error">
+ {{ error_message }}
+ </small>
+ {% endif %}
+ </label>
+
+ <fieldset id="ChoiceEditor" class="form-section"
+ {% if error_field == "choice" %}
+ aria-invalid="true" aria-describedby="ChoiceEditorError"
+ {% endif %}
+ {% if field_type != "CHOICE" %}hidden{% endif %}>
+ <legend>Choices</legend>
+ <p class="form-hint">
+ Values match exactly. Existing values are permanent while
+ cards use them.
+ </p>
+ <div id="ChoiceRows" class="choice-rows">
+ {% for choice in choices %}
+ <div class="choice-row">
+ <input name="choice" type="text"
+ value="{{ choice.value }}"
+ {% if choice.existing %}readonly{% endif %} required>
+ <button class="clay-action clay-action-secondary move-up"
+ type="button">Move up</button>
+ <button class="clay-action clay-action-secondary move-down"
+ type="button">Move down</button>
+ <button class="clay-action clay-action-danger remove-choice"
+ type="button">Remove</button>
+ </div>
+ {% endfor %}
+ </div>
+ <button id="AddChoice" class="clay-action clay-action-secondary"
+ type="button">Add choice</button>
+ {% if error_field == "choice" %}
+ <p id="ChoiceEditorError" class="field-error">
+ {{ error_message }}
+ </p>
+ {% endif %}
+ </fieldset>
+
+ <button class="form-submit" type="submit">{{ submit_label }}</button>
+ </form>
+ {% if mode == "edit" %}
+ <div class="management-actions form-tail-actions">
+ <a class="clay-action clay-action-danger"
+ href="{{ delete_url }}">Delete field</a>
+ </div>
+ {% endif %}
+</section>
+{% endblock %}
+
+{% block scripts %}
+<script src="{{ url_for("static", "game_field_form.js") }}"></script>
+{% endblock %}
diff --git a/templates/game_form.html b/templates/game_form.html
new file mode 100644
index 0000000..666a101
--- /dev/null
+++ b/templates/game_form.html
@@ -0,0 +1,150 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="game-editor" aria-labelledby="GameFormHeading">
+ <a class="back-link" href="{{ back_url }}">← All games</a>
+ <header class="page-heading">
+ <div>
+ <p class="eyebrow">Game administration</p>
+ <h1 id="GameFormHeading">{{ heading }}</h1>
+ </div>
+ {% if mode == "edit" %}
+ <div class="management-actions">
+ <a class="clay-action clay-action-secondary"
+ href="{{ add_field_url }}">Add field</a>
+ <a class="clay-action clay-action-danger"
+ href="{{ delete_url }}">Delete game</a>
+ </div>
+ {% endif %}
+ </header>
+
+ {% if has_errors %}
+ <div class="error-summary" role="alert" tabindex="-1">
+ <h2>Check the game details</h2>
+ <p><a href="#{{ error_control_id }}">{{ error_message }}</a></p>
+ </div>
+ {% endif %}
+
+ <form class="standalone-form clay-form-panel" method="post"
+ action="{{ action_url }}">
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+ {% if mode == "edit" %}
+ <input type="hidden" name="game_revision"
+ value="{{ game_revision }}">
+ {% endif %}
+ <label class="form-field" for="GameShortName">
+ <span>Short name</span>
+ <input id="GameShortName"
+ {% if mode == "create" %}name="short_name"{% endif %}
+ type="text" value="{{ short_name }}"
+ pattern="[a-z0-9]+" required
+ {% if error_field == "short_name" %}
+ aria-invalid="true"
+ aria-describedby="GameShortNameError"
+ {% endif %}
+ {% if mode == "edit" %}readonly{% endif %}>
+ <small>Permanent lowercase prefix used in card IDs.</small>
+ {% if error_field == "short_name" %}
+ <small id="GameShortNameError" class="field-error">
+ {{ error_message }}
+ </small>
+ {% endif %}
+ </label>
+ <label class="form-field" for="GameDisplayName">
+ <span>Display name</span>
+ <input id="GameDisplayName" name="display_name" type="text"
+ value="{{ display_name }}" required
+ {% if error_field == "display_name" %}
+ aria-invalid="true"
+ aria-describedby="GameDisplayNameError"
+ {% endif %}>
+ {% if error_field == "display_name" %}
+ <small id="GameDisplayNameError" class="field-error">
+ {{ error_message }}
+ </small>
+ {% endif %}
+ </label>
+ <label class="form-field" for="GameDescription">
+ <span>Description <em>Optional</em></span>
+ <textarea id="GameDescription" name="description"
+ rows="7"
+ {% if error_field == "description" %}
+ aria-invalid="true"
+ aria-describedby="GameDescriptionError"
+ {% endif %}>{{ description }}</textarea>
+ {% if error_field == "description" %}
+ <small id="GameDescriptionError" class="field-error">
+ {{ error_message }}
+ </small>
+ {% endif %}
+ </label>
+ <p class="form-hint">Descriptions support Markdown.</p>
+ <button class="form-submit" type="submit">{{ submit_label }}</button>
+ </form>
+
+ {% if mode == "edit" %}
+ <section class="definition-panel" aria-labelledby="FieldsHeading">
+ <div class="section-heading">
+ <div>
+ <p class="eyebrow">Card details</p>
+ <h2 id="FieldsHeading">Custom fields</h2>
+ </div>
+ <a class="clay-action" href="{{ add_field_url }}">Add field</a>
+ </div>
+ {% if length(fields) == 0 %}
+ <div class="empty-panel">
+ <h2>Uses standard card fields</h2>
+ <p>Add a custom field when this game needs extra card details.</p>
+ </div>
+ {% else %}
+ <ul class="definition-list">
+ {% for field in fields %}
+ <li>
+ <div class="definition-copy">
+ <p class="eyebrow">{{ field.type }}</p>
+ <h3>{{ field.label }}</h3>
+ <p><code>{{ field.key }}</code>
+ {% if field.choice_count > 0 %}
+ · {{ field.choice_count }} choices
+ {% endif %}</p>
+ </div>
+ <div class="definition-actions">
+ <form method="post" action="{{ field_order_url }}">
+ <input type="hidden" name="csrf_token"
+ value="{{ csrf_token }}">
+ <input type="hidden" name="game_revision"
+ value="{{ game_revision }}">
+ {% for id in field.up_order %}
+ <input type="hidden" name="field_id" value="{{ id }}">
+ {% endfor %}
+ <button class="clay-action clay-action-secondary"
+ type="submit" {% if field.is_first %}disabled{% endif %}>
+ Move up
+ </button>
+ </form>
+ <form method="post" action="{{ field_order_url }}">
+ <input type="hidden" name="csrf_token"
+ value="{{ csrf_token }}">
+ <input type="hidden" name="game_revision"
+ value="{{ game_revision }}">
+ {% for id in field.down_order %}
+ <input type="hidden" name="field_id" value="{{ id }}">
+ {% endfor %}
+ <button class="clay-action clay-action-secondary"
+ type="submit" {% if field.is_last %}disabled{% endif %}>
+ Move down
+ </button>
+ </form>
+ <a class="clay-action clay-action-secondary"
+ href="{{ field.edit_url }}">Edit field</a>
+ <a class="clay-action clay-action-danger"
+ href="{{ field.delete_url }}">Delete</a>
+ </div>
+ </li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ </section>
+ {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/mutation_error.html b/templates/mutation_error.html
new file mode 100644
index 0000000..fff9415
--- /dev/null
+++ b/templates/mutation_error.html
@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell mutation-error-page"
+ aria-labelledby="MutationErrorHeading">
+ <p class="eyebrow">
+ {% if conflict %}Edit conflict{% else %}Unable to save{% endif %}
+ </p>
+ <h1 id="MutationErrorHeading">
+ {% if conflict %}The definition changed{% else %}Check your changes{% endif %}
+ </h1>
+ <div class="error-summary" role="alert" tabindex="-1">
+ <h2>Nothing was saved</h2>
+ <p>{{ message }}</p>
+ {% if files_need_reselection %}
+ <p>Uploaded files were discarded and must be selected again.</p>
+ {% endif %}
+ </div>
+ <a class="clay-action" href="{{ reload_url }}">
+ Reload the editor
+ </a>
+</section>
+{% endblock %}
diff --git a/templates/series_index.html b/templates/series_index.html
index 6b94380..7c57c0e 100644
--- a/templates/series_index.html
+++ b/templates/series_index.html
@@ -20,7 +20,7 @@
{% if can_create %}
<p>Create a series to organize cards within a game.</p>
{% else %}
- <p>A compiled game must be registered before creating series.</p>
+ <p>Create a game before adding a series.</p>
{% endif %}
</div>
{% else %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index 92c767e..f546e42 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -12,11 +12,8 @@
#include <gtest/gtest.h>
#include <httplib.h>
-#include "game_registry.h"
-#include "going_home.h"
#include "non_secret_random.h"
#include "startup.h"
-#include "test_game.h"
namespace
{
@@ -137,17 +134,19 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
256,
};
config.email.link_file = temporary.path() / "latest-link.txt";
- auto games = std::make_unique<GameRegistry>();
- ASSERT_TRUE(games->add(std::make_unique<TestGame>()));
- ASSERT_TRUE(games->add(std::make_unique<GoingHome>()));
- auto data_source = prepareDataSource(config.database_path, *games);
+ auto data_source = prepareDataSource(config.database_path);
ASSERT_TRUE(data_source);
- const GameDefinition* game = games->find("test");
- ASSERT_NE(game, nullptr);
- auto metadata = game->validateMetadata({{"hp", "20"}, {"attack", "5"}});
- ASSERT_TRUE(metadata);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"test", "Test Game", "Dynamic extension test game.", 1}));
+ auto hp_id = (*transaction)->insertGameField(
+ {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+ auto attack_id = (*transaction)->insertGameField(
+ {0, "test", "attack", "Attack", GameFieldType::INTEGER, 1, {}},
+ {});
+ ASSERT_TRUE(hp_id);
+ ASSERT_TRUE(attack_id);
auto series_id = (*transaction)->insertSeries(
{0, "test", "Core <Set>", "A **series**."});
ASSERT_TRUE(series_id);
@@ -167,8 +166,8 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
1,
1,
},
- game,
- metadata->get(),
+ {{*hp_id, GameFieldType::INTEGER, std::int64_t{20}},
+ {*attack_id, GameFieldType::INTEGER, std::int64_t{5}}},
{*series_id});
ASSERT_TRUE(card_id);
ASSERT_TRUE((*transaction)->commit());
@@ -186,7 +185,6 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
App app(
config,
std::move(*data_source),
- std::move(games),
std::make_unique<NonSecretRandom>(1));
ASSERT_TRUE(app.start());
@@ -306,7 +304,8 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
auto create = client.Get("/collection/cards/new", session_headers);
ASSERT_NE(create, nullptr);
EXPECT_EQ(create->status, 200) << create->body;
- EXPECT_NE(create->body.find("Going Home"), std::string::npos);
+ EXPECT_NE(create->body.find("Test Game"), std::string::npos);
+ EXPECT_EQ(create->body.find("Going Home"), std::string::npos);
auto edit = client.Get(
"/collection/cards/test-1/edit", session_headers);
ASSERT_NE(edit, nullptr);
@@ -322,10 +321,10 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(games_page, nullptr);
EXPECT_EQ(games_page->status, 200) << games_page->body;
EXPECT_NE(games_page->body.find("Test Game"), std::string::npos);
- EXPECT_NE(games_page->body.find("Compiled extension test game."),
+ EXPECT_NE(games_page->body.find("Dynamic extension test game."),
std::string::npos);
EXPECT_NE(games_page->body.find("HP"), std::string::npos);
- EXPECT_NE(games_page->body.find("Going Home"), std::string::npos);
+ EXPECT_EQ(games_page->body.find("Going Home"), std::string::npos);
auto stylesheet = client.Get("/collection/static/css/styles.css");
ASSERT_NE(stylesheet, nullptr);
EXPECT_EQ(stylesheet->status, 200);
@@ -339,9 +338,436 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
ASSERT_NE(traversal, nullptr);
EXPECT_EQ(traversal->status, 404);
+ const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
+ const httplib::Params oversized_game_fields = {
+ {"csrf_token", csrf_token},
+ {"short_name", "oversized"},
+ {"display_name", "Oversized"},
+ {"description", std::string(1024 * 1024 + 1, 'x')},
+ };
+ auto oversized_game = client.Post(
+ "/collection/admin/games",
+ session_headers,
+ oversized_game_fields);
+ ASSERT_NE(oversized_game, nullptr);
+ EXPECT_EQ(oversized_game->status, 413);
+
+ const httplib::Params dynamic_game_fields = {
+ {"csrf_token", csrf_token},
+ {"short_name", "dynamic"},
+ {"display_name", "Dynamic Game"},
+ {"description", "A **dynamic** game."},
+ };
+ const httplib::Params missing_csrf_game_fields = {
+ {"short_name", "forbidden"},
+ {"display_name", "Forbidden"},
+ {"description", ""},
+ };
+ auto missing_csrf_game = client.Post(
+ "/collection/admin/games",
+ session_headers,
+ missing_csrf_game_fields);
+ ASSERT_NE(missing_csrf_game, nullptr);
+ EXPECT_EQ(missing_csrf_game->status, 403);
+
+ const httplib::Params invalid_game_fields = {
+ {"csrf_token", csrf_token},
+ {"short_name", "Invalid-Game"},
+ {"display_name", "Invalid Game"},
+ {"description", ""},
+ };
+ auto invalid_game = client.Post(
+ "/collection/admin/games",
+ session_headers,
+ invalid_game_fields);
+ ASSERT_NE(invalid_game, nullptr);
+ EXPECT_EQ(invalid_game->status, 422) << invalid_game->body;
+ EXPECT_NE(invalid_game->body.find("Check the game details"),
+ std::string::npos);
+ EXPECT_NE(invalid_game->body.find(
+ "aria-describedby=\"GameShortNameError\""),
+ std::string::npos);
+ EXPECT_NE(invalid_game->body.find("Invalid-Game"),
+ std::string::npos);
+
+ auto dynamic_game = client.Post(
+ "/collection/admin/games",
+ session_headers,
+ dynamic_game_fields);
+ ASSERT_NE(dynamic_game, nullptr);
+ ASSERT_EQ(dynamic_game->status, 303) << dynamic_game->body;
+
+ const httplib::Params invalid_field_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "1"},
+ {"key", "Invalid-Key"},
+ {"label", "Score"},
+ {"type", "INTEGER"},
+ };
+ auto invalid_field = client.Post(
+ "/collection/admin/games/dynamic/fields",
+ session_headers,
+ invalid_field_fields);
+ ASSERT_NE(invalid_field, nullptr);
+ EXPECT_EQ(invalid_field->status, 422) << invalid_field->body;
+ EXPECT_NE(invalid_field->body.find("Check the field definition"),
+ std::string::npos);
+ EXPECT_NE(invalid_field->body.find(
+ "aria-describedby=\"FieldKeyError\""), std::string::npos);
+ EXPECT_NE(invalid_field->body.find("Invalid-Key"),
+ std::string::npos);
+
+ const httplib::Params integer_field_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "1"},
+ {"key", "score"},
+ {"label", "Score"},
+ {"type", "INTEGER"},
+ };
+ auto integer_field = client.Post(
+ "/collection/admin/games/dynamic/fields",
+ session_headers,
+ integer_field_fields);
+ ASSERT_NE(integer_field, nullptr);
+ ASSERT_EQ(integer_field->status, 303) << integer_field->body;
+
+ const httplib::Params string_field_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "2"},
+ {"key", "note"},
+ {"label", "Note"},
+ {"type", "STRING"},
+ };
+ auto string_field = client.Post(
+ "/collection/admin/games/dynamic/fields",
+ session_headers,
+ string_field_fields);
+ ASSERT_NE(string_field, nullptr);
+ ASSERT_EQ(string_field->status, 303) << string_field->body;
+
+ const httplib::Params choice_field_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "3"},
+ {"key", "route"},
+ {"label", "Route"},
+ {"type", "CHOICE"},
+ {"choice", "Forest & River"},
+ {"choice", "Coast+Town"},
+ };
+ auto choice_field = client.Post(
+ "/collection/admin/games/dynamic/fields",
+ session_headers,
+ choice_field_fields);
+ ASSERT_NE(choice_field, nullptr);
+ ASSERT_EQ(choice_field->status, 303) << choice_field->body;
+
+ const httplib::Params field_order_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "4"},
+ {"field_id", "5"},
+ {"field_id", "4"},
+ {"field_id", "3"},
+ };
+ auto fields_reordered = client.Post(
+ "/collection/admin/games/dynamic/field-order",
+ session_headers,
+ field_order_fields);
+ ASSERT_NE(fields_reordered, nullptr);
+ ASSERT_EQ(fields_reordered->status, 303) << fields_reordered->body;
+
+ const httplib::Params game_update_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "5"},
+ {"display_name", "Dynamic Renamed"},
+ {"description", "A **rendered** description."},
+ };
+ auto game_updated = client.Post(
+ "/collection/admin/games/dynamic",
+ session_headers,
+ game_update_fields);
+ ASSERT_NE(game_updated, nullptr);
+ ASSERT_EQ(game_updated->status, 303) << game_updated->body;
+ auto dynamic_games_page = client.Get(
+ "/collection/admin/games", session_headers);
+ ASSERT_NE(dynamic_games_page, nullptr);
+ ASSERT_EQ(dynamic_games_page->status, 200);
+ EXPECT_NE(dynamic_games_page->body.find("Dynamic Renamed"),
+ std::string::npos);
+ EXPECT_NE(dynamic_games_page->body.find("<strong>rendered</strong>"),
+ std::string::npos);
+ auto foreign_field = client.Get(
+ "/collection/admin/games/dynamic/fields/1/edit",
+ session_headers);
+ ASSERT_NE(foreign_field, nullptr);
+ EXPECT_EQ(foreign_field->status, 404);
+
+ const httplib::Params stale_game_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "4"},
+ {"display_name", "Stale name"},
+ {"description", ""},
+ };
+ auto stale_game = client.Post(
+ "/collection/admin/games/dynamic",
+ session_headers,
+ stale_game_fields);
+ ASSERT_NE(stale_game, nullptr);
+ EXPECT_EQ(stale_game->status, 409) << stale_game->body;
+ EXPECT_NE(stale_game->body.find("Nothing was saved"),
+ std::string::npos);
+ EXPECT_NE(stale_game->body.find(
+ "/collection/admin/games/dynamic/edit"), std::string::npos);
+
+ const httplib::Params dynamic_series_fields = {
+ {"csrf_token", csrf_token},
+ {"game", "dynamic"},
+ {"name", "Dynamic series"},
+ {"description", "A dynamic series."},
+ };
+ auto dynamic_series = client.Post(
+ "/collection/admin/series",
+ session_headers,
+ dynamic_series_fields);
+ ASSERT_NE(dynamic_series, nullptr);
+ ASSERT_EQ(dynamic_series->status, 303) << dynamic_series->body;
+
+ const httplib::UploadFormDataItems dynamic_card_fields = {
+ {"csrf_token", csrf_token, "", ""},
+ {"game", "dynamic", "", ""},
+ {"game_revision", "6", "", ""},
+ {"name", "Dynamic card", "", ""},
+ {"rarity", "7", "", ""},
+ {"game.score", "9007199254740993", "", ""},
+ {"game.note", "Line <one>\nLine two", "", ""},
+ {"game.route", "Forest & River", "", ""},
+ {"series_id", "2", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front", upload_bytes, "card.png", "image/png"},
+ };
+ httplib::UploadFormDataItems invalid_dynamic_card_fields =
+ dynamic_card_fields;
+ for(httplib::UploadFormData& field : invalid_dynamic_card_fields)
+ {
+ if(field.name == "game.route")
+ {
+ field.content = "Unknown route";
+ }
+ }
+ auto invalid_dynamic_card = client.Post(
+ "/collection/cards",
+ session_headers,
+ invalid_dynamic_card_fields);
+ ASSERT_NE(invalid_dynamic_card, nullptr);
+ EXPECT_EQ(invalid_dynamic_card->status, 422)
+ << invalid_dynamic_card->body;
+ EXPECT_NE(invalid_dynamic_card->body.find("Check your card details"),
+ std::string::npos);
+ EXPECT_NE(invalid_dynamic_card->body.find(
+ "aria-describedby=\"GameField-dynamic-route-error\""),
+ std::string::npos);
+ EXPECT_NE(invalid_dynamic_card->body.find("9007199254740993"),
+ std::string::npos);
+ EXPECT_NE(invalid_dynamic_card->body.find("must be selected again"),
+ std::string::npos);
+
+ auto dynamic_card = client.Post(
+ "/collection/cards", session_headers, dynamic_card_fields);
+ ASSERT_NE(dynamic_card, nullptr);
+ ASSERT_EQ(dynamic_card->status, 303) << dynamic_card->body;
+ const std::string dynamic_card_path = urlRequestPath(
+ dynamic_card->get_header_value("Location"));
+ EXPECT_EQ(dynamic_card_path, "/collection/cards/dynamic-1");
+ auto dynamic_card_page = client.Get(
+ dynamic_card_path, session_headers);
+ ASSERT_NE(dynamic_card_page, nullptr);
+ ASSERT_EQ(dynamic_card_page->status, 200) << dynamic_card_page->body;
+ EXPECT_NE(dynamic_card_page->body.find("9007199254740993"),
+ std::string::npos);
+ EXPECT_NE(dynamic_card_page->body.find("Line <one>\nLine two"),
+ std::string::npos);
+ EXPECT_NE(dynamic_card_page->body.find("Forest & River"),
+ std::string::npos);
+
+ const httplib::UploadFormDataItems dynamic_edit_fields = {
+ {"csrf_token", csrf_token, "", ""},
+ {"name", "Dynamic card edited", "", ""},
+ {"rarity", "8", "", ""},
+ {"revision", "1", "", ""},
+ {"game_revision", "6", "", ""},
+ {"game.score", "-7", "", ""},
+ {"game.note", "Whitespace kept", "", ""},
+ {"game.route", "Coast+Town", "", ""},
+ {"series_id", "2", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front_action", "keep", "", ""},
+ {"foil_action", "keep", "", ""},
+ };
+ auto dynamic_edited = client.Post(
+ dynamic_card_path + "/edit",
+ session_headers,
+ dynamic_edit_fields);
+ ASSERT_NE(dynamic_edited, nullptr);
+ ASSERT_EQ(dynamic_edited->status, 303) << dynamic_edited->body;
+ dynamic_card_page = client.Get(dynamic_card_path, session_headers);
+ ASSERT_NE(dynamic_card_page, nullptr);
+ ASSERT_EQ(dynamic_card_page->status, 200);
+ EXPECT_NE(dynamic_card_page->body.find("Dynamic card edited"),
+ std::string::npos);
+ EXPECT_NE(dynamic_card_page->body.find("Coast+Town"),
+ std::string::npos);
+
+ const httplib::Params second_game_update_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "6"},
+ {"display_name", "Dynamic Final"},
+ {"description", "Final description."},
+ };
+ auto second_game_update = client.Post(
+ "/collection/admin/games/dynamic",
+ session_headers,
+ second_game_update_fields);
+ ASSERT_NE(second_game_update, nullptr);
+ ASSERT_EQ(second_game_update->status, 303)
+ << second_game_update->body;
+ httplib::UploadFormDataItems stale_dynamic_edit_fields =
+ dynamic_edit_fields;
+ for(httplib::UploadFormData& field : stale_dynamic_edit_fields)
+ {
+ if(field.name == "name")
+ {
+ field.content = "Stale card change";
+ }
+ }
+ auto stale_card = client.Post(
+ dynamic_card_path + "/edit",
+ session_headers,
+ stale_dynamic_edit_fields);
+ ASSERT_NE(stale_card, nullptr);
+ EXPECT_EQ(stale_card->status, 409) << stale_card->body;
+ EXPECT_NE(stale_card->body.find("must be selected again"),
+ std::string::npos);
+ dynamic_card_page = client.Get(dynamic_card_path, session_headers);
+ ASSERT_NE(dynamic_card_page, nullptr);
+ EXPECT_EQ(dynamic_card_page->status, 200);
+ EXPECT_EQ(dynamic_card_page->body.find("Stale card change"),
+ std::string::npos);
+ EXPECT_NE(dynamic_card_page->body.find("Dynamic card edited"),
+ std::string::npos);
+
+ const httplib::Params used_choice_fields = {
+ {"csrf_token", csrf_token},
+ {"game_revision", "7"},
+ {"label", "Route"},
+ {"choice", "Forest & River"},
+ };
+ auto used_choice = client.Post(
+ "/collection/admin/games/dynamic/fields/5",
+ session_headers,
+ used_choice_fields);
+ ASSERT_NE(used_choice, nullptr);
+ EXPECT_EQ(used_choice->status, 409) << used_choice->body;
+ EXPECT_NE(used_choice->body.find("1 card"), std::string::npos);
+
+ auto dynamic_deleted = client.Post(
+ dynamic_card_path + "/delete", session_headers, csrf_fields);
+ ASSERT_NE(dynamic_deleted, nullptr);
+ ASSERT_EQ(dynamic_deleted->status, 303) << dynamic_deleted->body;
+ auto dynamic_series_deleted = client.Post(
+ "/collection/admin/series/2/delete",
+ session_headers,
+ csrf_fields);
+ ASSERT_NE(dynamic_series_deleted, nullptr);
+ ASSERT_EQ(dynamic_series_deleted->status, 303)
+ << dynamic_series_deleted->body;
+
+ auto choice_after_card = client.Post(
+ "/collection/admin/games/dynamic/fields/5",
+ session_headers,
+ used_choice_fields);
+ ASSERT_NE(choice_after_card, nullptr);
+ ASSERT_EQ(choice_after_card->status, 303) << choice_after_card->body;
+ const httplib::Params delete_choice_field = {
+ {"csrf_token", csrf_token}, {"game_revision", "8"}};
+ const httplib::Params delete_string_field = {
+ {"csrf_token", csrf_token}, {"game_revision", "9"}};
+ const httplib::Params delete_integer_field = {
+ {"csrf_token", csrf_token}, {"game_revision", "10"}};
+ auto choice_deleted = client.Post(
+ "/collection/admin/games/dynamic/fields/5/delete",
+ session_headers,
+ delete_choice_field);
+ ASSERT_NE(choice_deleted, nullptr);
+ ASSERT_EQ(choice_deleted->status, 303) << choice_deleted->body;
+ auto string_deleted = client.Post(
+ "/collection/admin/games/dynamic/fields/4/delete",
+ session_headers,
+ delete_string_field);
+ ASSERT_NE(string_deleted, nullptr);
+ ASSERT_EQ(string_deleted->status, 303) << string_deleted->body;
+ auto integer_deleted = client.Post(
+ "/collection/admin/games/dynamic/fields/3/delete",
+ session_headers,
+ delete_integer_field);
+ ASSERT_NE(integer_deleted, nullptr);
+ ASSERT_EQ(integer_deleted->status, 303) << integer_deleted->body;
+
+ const httplib::Params delete_issued_game = {
+ {"csrf_token", csrf_token}, {"game_revision", "11"}};
+ auto issued_game = client.Post(
+ "/collection/admin/games/dynamic/delete",
+ session_headers,
+ delete_issued_game);
+ ASSERT_NE(issued_game, nullptr);
+ EXPECT_EQ(issued_game->status, 409) << issued_game->body;
+
+ const httplib::Params empty_game_fields = {
+ {"csrf_token", csrf_token},
+ {"short_name", "empty"},
+ {"display_name", "Empty Game"},
+ {"description", ""},
+ };
+ auto empty_game = client.Post(
+ "/collection/admin/games", session_headers, empty_game_fields);
+ ASSERT_NE(empty_game, nullptr);
+ ASSERT_EQ(empty_game->status, 303) << empty_game->body;
+ const httplib::Params delete_empty_game = {
+ {"csrf_token", csrf_token}, {"game_revision", "1"}};
+ auto empty_deleted = client.Post(
+ "/collection/admin/games/empty/delete",
+ session_headers,
+ delete_empty_game);
+ ASSERT_NE(empty_deleted, nullptr);
+ EXPECT_EQ(empty_deleted->status, 303) << empty_deleted->body;
+
+ const httplib::UploadFormDataItems invalid_loose_fields = {
+ {"csrf_token", csrf_token, "", ""},
+ {"game", "", "", ""},
+ {"game.route", "Forest", "", ""},
+ {"name", "Tampered loose card", "", ""},
+ {"rarity", "1", "", ""},
+ {"source_mode", "files", "", ""},
+ {"front", upload_bytes, "card.png", "image/png"},
+ };
+ auto invalid_loose = client.Post(
+ "/collection/cards", session_headers, invalid_loose_fields);
+ ASSERT_NE(invalid_loose, nullptr);
+ EXPECT_EQ(invalid_loose->status, 422) << invalid_loose->body;
+
+ const httplib::Params going_home_fields = {
+ {"csrf_token", csrf_token},
+ {"short_name", "gh"},
+ {"display_name", "Going Home"},
+ {"description", ""},
+ };
+ auto going_home = client.Post(
+ "/collection/admin/games", session_headers, going_home_fields);
+ ASSERT_NE(going_home, nullptr);
+ ASSERT_EQ(going_home->status, 303) << going_home->body;
+
const httplib::UploadFormDataItems create_fields = {
{"csrf_token", csrf_token, "", ""},
{"game", "gh", "", ""},
+ {"game_revision", "1", "", ""},
{"name", "Uploaded card", "", ""},
{"rarity", "3", "", ""},
{"source_mode", "files", "", ""},
@@ -366,6 +792,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
{"name", "Edited upload", "", ""},
{"rarity", "4", "", ""},
{"revision", "1", "", ""},
+ {"game_revision", "1", "", ""},
{"source_mode", "files", "", ""},
{"front_action", "keep", "", ""},
{"foil_action", "keep", "", ""},
@@ -380,7 +807,6 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
EXPECT_NE(edited_page->body.find("Going Home"), std::string::npos);
EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
- const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
auto deleted = client.Post(
created_path + "/delete",
session_headers,
@@ -495,6 +921,18 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
"/collection/admin/games", player_headers);
ASSERT_NE(player_games, nullptr);
EXPECT_EQ(player_games->status, 403);
+ const httplib::Params player_game_fields = {
+ {"csrf_token", player_csrf},
+ {"short_name", "playergame"},
+ {"display_name", "Player Game"},
+ {"description", ""},
+ };
+ auto player_game_create = client.Post(
+ "/collection/admin/games",
+ player_headers,
+ player_game_fields);
+ ASSERT_NE(player_game_create, nullptr);
+ EXPECT_EQ(player_game_create->status, 403);
auto promoted = client.Post(
"/collection/admin/users/2/promote",
@@ -502,6 +940,12 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
csrf_fields);
ASSERT_NE(promoted, nullptr);
ASSERT_EQ(promoted->status, 303) << promoted->body;
+ auto creator_game_create = client.Post(
+ "/collection/admin/games",
+ player_headers,
+ player_game_fields);
+ ASSERT_NE(creator_game_create, nullptr);
+ EXPECT_EQ(creator_game_create->status, 403);
auto creator_new = client.Get("/collection/cards/new", player_headers);
ASSERT_NE(creator_new, nullptr);
ASSERT_EQ(creator_new->status, 200) << creator_new->body;
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index d15b6fd..c18328f 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -14,7 +14,6 @@
#include "data_fake.h"
#include "public_id.h"
-#include "test_game.h"
namespace
{
@@ -48,21 +47,6 @@ std::unique_ptr<NonSecretRandom> deterministicRandom()
return std::make_unique<NonSecretRandom>(1);
}
-std::unique_ptr<GameRegistry> emptyGames()
-{
- return std::make_unique<GameRegistry>();
-}
-
-std::unique_ptr<GameRegistry> testGames()
-{
- auto games = std::make_unique<GameRegistry>();
- if(!games->add(std::make_unique<TestGame>()))
- {
- throw std::runtime_error("Failed to construct test game registry");
- }
- return games;
-}
-
Card makeCard(std::int64_t id, std::uint64_t number, std::string name)
{
return {
@@ -128,7 +112,6 @@ TEST(AppTest, BuildsNamedUrls)
const App app(
makeConfig("https://example.test/collection/"),
emptyDataSource(),
- emptyGames(),
deterministicRandom());
EXPECT_EQ(
@@ -146,6 +129,9 @@ TEST(AppTest, BuildsNamedUrls)
EXPECT_EQ(
app.urlFor("admin-games"),
"https://example.test/collection/admin/games");
+ EXPECT_EQ(
+ app.urlFor("game-field-edit", {"a/b", "42"}),
+ "https://example.test/collection/admin/games/a%2fb/fields/42/edit");
}
/// Verify static relative paths and ordered query values are encoded.
@@ -154,7 +140,6 @@ TEST(AppTest, BuildsStaticAndQueryUrls)
const App app(
makeConfig("https://example.test/collection/"),
emptyDataSource(),
- emptyGames(),
deterministicRandom());
EXPECT_EQ(
@@ -172,7 +157,6 @@ TEST(AppTest, RejectsInvalidRoutes)
const App app(
makeConfig("https://example.test/"),
emptyDataSource(),
- emptyGames(),
deterministicRandom());
EXPECT_THROW(app.urlFor("missing"), std::invalid_argument);
@@ -192,7 +176,6 @@ TEST(AppTest, RendersCardIndex)
App app(
makeConfig("https://example.test/collection/"),
std::move(data_source),
- emptyGames(),
deterministicRandom());
App::Request request = authenticatedRequest();
App::Response response;
@@ -221,7 +204,6 @@ TEST(AppTest, RendersCardCreationForm)
App app(
makeConfig("https://example.test/collection/"),
std::make_unique<AuthenticatedDataSource>(),
- emptyGames(),
deterministicRandom());
App::Request request = authenticatedRequest();
App::Response response;
@@ -255,7 +237,6 @@ TEST(AppTest, RendersCardEditForm)
App app(
makeConfig("https://example.test/collection/"),
std::move(data_source),
- emptyGames(),
deterministicRandom());
auto public_id = formatPublicId(card.identity);
ASSERT_TRUE(public_id);
@@ -294,11 +275,13 @@ TEST(AppTest, RendersCardView)
},
std::unordered_map<std::int64_t, std::vector<std::int64_t>>{
{2, {7}},
- });
+ },
+ std::vector<GameDefinitionSnapshot>{
+ {{"test", "Test Game", "", 1}, {}}},
+ std::unordered_map<std::int64_t, std::vector<GameFieldValue>>{});
App app(
makeConfig("https://example.test/collection/"),
std::move(data_source),
- testGames(),
deterministicRandom());
App::Request request = authenticatedRequest();
request.path_params.emplace("id", "test-2");
@@ -325,7 +308,6 @@ TEST(AppTest, RejectsUnknownCardView)
App app(
makeConfig("https://example.test/"),
std::make_unique<AuthenticatedDataSource>(),
- emptyGames(),
deterministicRandom());
App::Request request = authenticatedRequest();
request.path_params.emplace("id", "PKM-2");
diff --git a/tests/authentication_test.cpp b/tests/authentication_test.cpp
index 46abf2d..ed2d46d 100644
--- a/tests/authentication_test.cpp
+++ b/tests/authentication_test.cpp
@@ -11,7 +11,6 @@
#include <mw/crypto_mock.hpp>
#include "authentication.h"
-#include "game_registry.h"
#include "startup.h"
namespace
@@ -108,8 +107,7 @@ std::string hexToken(char low_nibble)
TEST(AuthenticationServiceTest, CompletesSingleUseNonSlidingFlow)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto base_url = mw::URL::fromStr("http://127.0.0.1/app/");
ASSERT_TRUE(base_url);
@@ -165,8 +163,7 @@ TEST(AuthenticationServiceTest, CompletesSingleUseNonSlidingFlow)
TEST(AuthenticationServiceTest, RateLimitsAndInvalidatesSenderFailure)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto base_url = mw::URL::fromStr("http://127.0.0.1/");
ASSERT_TRUE(base_url);
diff --git a/tests/card_form_logic_test.js b/tests/card_form_logic_test.js
index 8fd54ca..368e58b 100644
--- a/tests/card_form_logic_test.js
+++ b/tests/card_form_logic_test.js
@@ -66,3 +66,13 @@ test("source input state follows mode and replacements", function()
foil_required: true,
});
});
+
+test("game selection enables only matching controls and series", function()
+{
+ assert.equal(logic.gameControlEnabled("gh", "gh"), true);
+ assert.equal(logic.gameControlEnabled("gh", "other"), false);
+ assert.equal(logic.gameControlEnabled("", "gh"), false);
+ assert.equal(logic.seriesChoiceEnabled("gh", "gh"), true);
+ assert.equal(logic.seriesChoiceEnabled("gh", "other"), false);
+ assert.equal(logic.seriesChoiceEnabled("", ""), false);
+});
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index 642a8e2..c7ae6f7 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -1,10 +1,8 @@
#include "card_service.h"
#include "data_sqlite.h"
-#include "game_registry.h"
#include "multipart_reader.h"
#include "series_service.h"
#include "startup.h"
-#include "test_game.h"
#include <chrono>
#include <filesystem>
@@ -86,10 +84,9 @@ std::string readFile(const std::filesystem::path& path)
}
mw::E<std::unique_ptr<DataSourceInterface>> prepareTestDataSource(
- const std::filesystem::path& database,
- const GameRegistry& games)
+ const std::filesystem::path& database)
{
- auto data_source = prepareDataSource(database, games);
+ auto data_source = prepareDataSource(database);
if(!data_source)
{
return std::unexpected(std::move(data_source.error()));
@@ -121,8 +118,7 @@ TEST(CardServiceTest, CreatesLooseCard)
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
- GameRegistry games;
- auto data_source = prepareTestDataSource(database, games);
+ auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
@@ -175,8 +171,7 @@ TEST(CardServiceTest, RejectsInvalidImage)
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
- GameRegistry games;
- auto data_source = prepareTestDataSource(database, games);
+ auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
@@ -216,8 +211,7 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
- GameRegistry games;
- auto data_source = prepareTestDataSource(database, games);
+ auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
@@ -256,28 +250,30 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
}
-/// Verify compiled-game creation allocates a sequence and memberships.
-TEST(CardServiceTest, CreatesCompiledGameCard)
+/// Verify dynamic-game creation allocates a sequence and memberships.
+TEST(CardServiceTest, CreatesDynamicGameCard)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
- GameRegistry games;
- ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
- const GameDefinition* game = games.find("test");
- ASSERT_NE(game, nullptr);
- auto data_source = prepareTestDataSource(database, games);
+ auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
auto series_transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(series_transaction);
+ ASSERT_TRUE((*series_transaction)->insertGame(
+ {"test", "Test Game", "", 1}));
+ auto hp_id = (*series_transaction)->insertGameField(
+ {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+ auto attack_id = (*series_transaction)->insertGameField(
+ {0, "test", "attack", "Attack", GameFieldType::INTEGER, 1, {}},
+ {});
+ ASSERT_TRUE(hp_id);
+ ASSERT_TRUE(attack_id);
auto series_id = (*series_transaction)->insertSeries(
{0, "test", "Core", "Core cards"});
ASSERT_TRUE(series_id);
ASSERT_TRUE((*series_transaction)->commit());
- auto metadata = game->validateMetadata({{"hp", "50"}, {"attack", "10"}});
- ASSERT_TRUE(metadata);
-
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
const std::filesystem::path front = staging / "upload_front";
@@ -300,8 +296,9 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
std::nullopt,
std::nullopt,
},
- *game,
- **metadata,
+ "test",
+ 1,
+ {{"hp", "50"}, {"attack", "10"}},
{*series_id});
ASSERT_TRUE(public_id) << public_id.error().msg();
@@ -312,13 +309,10 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
auto memberships = (*data_source)->getCardSeries((**stored).id);
ASSERT_TRUE(memberships);
EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
- auto display = (*data_source)->getGameDisplayFields(
- *game, (**stored).id);
- ASSERT_TRUE(display);
- EXPECT_EQ(display->front().value, "50");
-
- metadata = game->validateMetadata({{"hp", "80"}, {"attack", "40"}});
- ASSERT_TRUE(metadata);
+ auto values = (*data_source)->getCardFieldValues((**stored).id);
+ ASSERT_TRUE(values);
+ ASSERT_TRUE(*values);
+ EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
const std::filesystem::path edit_staging =
temporary.path() / ".staging/edit";
std::filesystem::create_directory(edit_staging);
@@ -338,30 +332,167 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
std::nullopt,
std::nullopt,
},
- *game,
- **metadata,
+ 1,
+ {{"hp", "80"}, {"attack", "40"}},
{});
ASSERT_TRUE(updated) << updated.error().msg();
stored = (*data_source)->getCard({"test", 1});
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
EXPECT_EQ((**stored).name, "Updated game card");
- display = (*data_source)->getGameDisplayFields(*game, (**stored).id);
- ASSERT_TRUE(display);
- EXPECT_EQ(display->front().value, "80");
+ values = (*data_source)->getCardFieldValues((**stored).id);
+ ASSERT_TRUE(values);
+ ASSERT_TRUE(*values);
+ EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 80);
memberships = (*data_source)->getCardSeries((**stored).id);
ASSERT_TRUE(memberships);
EXPECT_TRUE(memberships->empty());
}
+/// Verify generic metadata failures roll back every card side effect.
+TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+ auto data_source = prepareTestDataSource(database);
+ ASSERT_TRUE(data_source);
+
+ auto setup = (*data_source)->beginTransaction();
+ ASSERT_TRUE(setup);
+ ASSERT_TRUE((*setup)->insertGame({"test", "Test Game", "", 1}));
+ auto hp_id = (*setup)->insertGameField(
+ {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+ ASSERT_TRUE(hp_id);
+ auto series_id = (*setup)->insertSeries(
+ {0, "test", "Core", "Core cards"});
+ ASSERT_TRUE(series_id);
+ ASSERT_TRUE((*setup)->commit());
+
+ auto injection = mw::SQLite::connectFile(database.string());
+ ASSERT_TRUE(injection);
+ ASSERT_TRUE((*injection)->execute(
+ "CREATE TRIGGER fail_card_values BEFORE INSERT "
+ "ON card_field_values BEGIN "
+ "SELECT RAISE(ABORT, 'forced metadata failure'); END;"));
+
+ const std::filesystem::path staging =
+ temporary.path() / ".staging/upload";
+ const std::filesystem::path front = staging / "upload_front";
+ writePng(front);
+ NonSecretRandom random(1);
+ CardService service(
+ **data_source,
+ random,
+ ImageProcessor(75, 256),
+ AssetStore(temporary.path()));
+ auto failed_create = service.createGameCard(
+ 1,
+ {
+ "Failed card",
+ std::nullopt,
+ std::nullopt,
+ 2,
+ staging,
+ front,
+ std::nullopt,
+ std::nullopt,
+ },
+ "test",
+ 1,
+ {{"hp", "50"}},
+ {*series_id});
+ ASSERT_FALSE(failed_create);
+ auto cards = (*data_source)->getCards();
+ ASSERT_TRUE(cards);
+ EXPECT_TRUE(cards->empty());
+ EXPECT_FALSE(std::filesystem::exists(
+ temporary.path() / "published/test-1"));
+ EXPECT_TRUE(std::filesystem::is_regular_file(staging / "front-art.avif"));
+ ASSERT_TRUE((*injection)->execute("DROP TRIGGER fail_card_values;"));
+
+ writePng(front);
+ auto public_id = service.createGameCard(
+ 1,
+ {
+ "Committed card",
+ std::nullopt,
+ std::nullopt,
+ 2,
+ staging,
+ front,
+ std::nullopt,
+ std::nullopt,
+ },
+ "test",
+ 1,
+ {{"hp", "50"}},
+ {*series_id});
+ ASSERT_TRUE(public_id) << public_id.error().msg();
+ EXPECT_EQ(*public_id, "test-1");
+ auto stored = (*data_source)->getCard({"test", 1});
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ const std::filesystem::path published =
+ temporary.path() / "published/test-1";
+ const std::string original_art = readFile(published / "front-art.avif");
+
+ ASSERT_TRUE((*injection)->execute(
+ "CREATE TRIGGER fail_card_values BEFORE INSERT "
+ "ON card_field_values BEGIN "
+ "SELECT RAISE(ABORT, 'forced metadata failure'); END;"));
+ const std::filesystem::path edit_staging =
+ temporary.path() / ".staging/edit";
+ std::filesystem::create_directory(edit_staging);
+ const std::filesystem::path replacement =
+ edit_staging / "upload_front";
+ writeJpeg(replacement);
+ auto failed_update = service.updateGameCard(
+ 1,
+ {
+ **stored,
+ 1,
+ "Failed update",
+ std::nullopt,
+ std::nullopt,
+ 3,
+ edit_staging,
+ FrontAssetAction::REPLACE,
+ FoilAssetAction::KEEP,
+ replacement,
+ std::nullopt,
+ std::nullopt,
+ },
+ 1,
+ {{"hp", "80"}},
+ {});
+ ASSERT_FALSE(failed_update);
+ stored = (*data_source)->getCard({"test", 1});
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ EXPECT_EQ((**stored).name, "Committed card");
+ EXPECT_EQ((**stored).revision, 1);
+ auto values = (*data_source)->getCardFieldValues((**stored).id);
+ ASSERT_TRUE(values);
+ ASSERT_TRUE(*values);
+ ASSERT_EQ((**values).values.size(), 1);
+ EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
+ auto memberships = (*data_source)->getCardSeries((**stored).id);
+ ASSERT_TRUE(memberships);
+ EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
+ EXPECT_EQ(readFile(published / "front-art.avif"), original_art);
+ EXPECT_FALSE(std::filesystem::exists(published / "front-art.jpg"));
+ EXPECT_TRUE(std::filesystem::is_regular_file(
+ edit_staging / "front-art.jpg"));
+}
+
/// Verify metadata and artwork edits increment revisions and replace assets.
TEST(CardServiceTest, UpdatesLooseCard)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
- GameRegistry games;
- auto data_source = prepareTestDataSource(database, games);
+ auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path create_staging =
@@ -490,8 +621,7 @@ TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
- GameRegistry games;
- auto data_source = prepareTestDataSource(database, games);
+ auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
const std::filesystem::path staging =
temporary.path() / ".staging/upload";
@@ -591,15 +721,67 @@ TEST(MultipartReaderTest, StreamsExpectedFields)
EXPECT_FALSE(upload->foil);
}
+/// Verify multipart custom fields reject duplicates and excessive text.
+TEST(MultipartReaderTest, RejectsDuplicateAndOversizedGameFields)
+{
+ initializeImageMagick();
+ TemporaryCardRoot temporary;
+ const httplib::ContentReader duplicate_reader(
+ [](httplib::ContentReceiver)
+ {
+ return false;
+ },
+ [](httplib::FormDataHeader header,
+ httplib::ContentReceiver receiver)
+ {
+ httplib::FormData field;
+ field.name = "game.note";
+ return header(field) && receiver("first", 5) &&
+ header(field) && receiver("second", 6);
+ });
+ MultipartReader duplicate_parser(temporary.path());
+ auto duplicate = duplicate_parser.read(duplicate_reader);
+ ASSERT_FALSE(duplicate);
+ const auto* duplicate_error = duplicate.error().as<mw::HTTPError>();
+ ASSERT_NE(duplicate_error, nullptr);
+ EXPECT_EQ(duplicate_error->code, 400);
+
+ const std::string chunk(1024 * 1024, 'x');
+ const httplib::ContentReader oversized_reader(
+ [](httplib::ContentReceiver)
+ {
+ return false;
+ },
+ [&chunk](httplib::FormDataHeader header,
+ httplib::ContentReceiver receiver)
+ {
+ for(int index = 0; index < 9; ++index)
+ {
+ httplib::FormData field;
+ field.name = "game.value" + std::to_string(index);
+ if(!header(field) ||
+ !receiver(chunk.data(), chunk.size()))
+ {
+ return false;
+ }
+ }
+ return true;
+ });
+ MultipartReader oversized_parser(temporary.path());
+ auto oversized = oversized_parser.read(oversized_reader);
+ ASSERT_FALSE(oversized);
+ const auto* oversized_error = oversized.error().as<mw::HTTPError>();
+ ASSERT_NE(oversized_error, nullptr);
+ EXPECT_EQ(oversized_error->code, 413);
+}
+
/// Verify creator authorship and rarity rules survive direct service calls.
TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
{
initializeImageMagick();
TemporaryCardRoot temporary;
const std::filesystem::path database = temporary.path() / "cards.sqlite3";
- GameRegistry games;
- ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
- auto data_source = prepareTestDataSource(database, games);
+ auto data_source = prepareTestDataSource(database);
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
@@ -665,9 +847,9 @@ TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
std::nullopt,
true,
}));
- SeriesService series_service(**data_source, games);
+ SeriesService series_service(**data_source);
auto forbidden_series = series_service.create(
- *creator_id, "test", "Creator series", "Not allowed");
+ *creator_id, "gh", "Creator series", "Not allowed");
ASSERT_FALSE(forbidden_series);
const auto* http_error = forbidden_series.error().as<mw::HTTPError>();
ASSERT_NE(http_error, nullptr);
diff --git a/tests/data_fake_test.cpp b/tests/data_fake_test.cpp
index f5e6e6b..2cb9233 100644
--- a/tests/data_fake_test.cpp
+++ b/tests/data_fake_test.cpp
@@ -56,21 +56,42 @@ TEST(DataSourceFakeTest, ReturnsCards)
EXPECT_FALSE(*missing);
}
-/// Verify series data, memberships, and game names are deterministic.
+/// Verify series data, memberships, and game definitions are deterministic.
TEST(DataSourceFakeTest, ReturnsRelatedData)
{
DataSourceFake data_source(
{makeCard(2, "test", 3, "Game card")},
{{7, "test", "First series", "Description"}},
- {{2, {7}}});
+ {{2, {7}}},
+ {{{"test", "Test Game", "", 1}, {}}});
const auto memberships = data_source.getCardSeries(2);
ASSERT_TRUE(memberships);
EXPECT_EQ(*memberships, std::vector<std::int64_t>({7}));
- const auto names = data_source.getPersistedGameNames();
- ASSERT_TRUE(names);
- EXPECT_EQ(*names, std::vector<std::string>({"test"}));
+ const auto games = data_source.getGames();
+ ASSERT_TRUE(games);
+ ASSERT_EQ(games->size(), 1);
+ EXPECT_EQ(games->front().short_name, "test");
+}
+
+/// Verify equal display names use the permanent short name as a tie-breaker.
+TEST(DataSourceFakeTest, SortsGamesDeterministically)
+{
+ DataSourceFake data_source(
+ {},
+ {},
+ {},
+ {
+ {{"z", "Same", "", 1}, {}},
+ {{"a", "Same", "", 1}, {}},
+ });
+
+ const auto games = data_source.getGames();
+ ASSERT_TRUE(games);
+ ASSERT_EQ(games->size(), 2);
+ EXPECT_EQ((*games)[0].short_name, "a");
+ EXPECT_EQ((*games)[1].short_name, "z");
}
/// Verify development data cannot accidentally be mutated.
diff --git a/tests/data_mock.h b/tests/data_mock.h
index c38f2f9..311ce88 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -28,6 +28,67 @@ public:
(const std::string& game_short_name),
(override));
+ /// Mock a transaction-consistent game definition read.
+ MOCK_METHOD((mw::E<std::optional<GameDefinitionSnapshot>>),
+ getGameDefinitionForUpdate,
+ (const std::string& short_name), (override));
+
+ /// Mock deletion-relevant game usage.
+ MOCK_METHOD((mw::E<GameUsage>), getGameUsage,
+ (const std::string& short_name), (override));
+
+ /// Mock game insertion.
+ MOCK_METHOD((mw::E<void>), insertGame, (const Game& game), (override));
+
+ /// Mock mutable game metadata replacement.
+ MOCK_METHOD((mw::E<bool>), updateGame,
+ (const std::string& short_name,
+ const std::string& display_name,
+ const std::string& description,
+ std::int64_t expected_revision), (override));
+
+ /// Mock game deletion.
+ MOCK_METHOD((mw::E<void>), deleteGame,
+ (const std::string& short_name), (override));
+
+ /// Mock custom-field insertion.
+ MOCK_METHOD((mw::E<std::int64_t>), insertGameField,
+ (const GameField& field,
+ const std::vector<GameChoice>& choices), (override));
+
+ /// Mock custom-field replacement.
+ MOCK_METHOD((mw::E<void>), updateGameField,
+ (std::int64_t field_id, const std::string& label,
+ const std::vector<GameChoice>& choices), (override));
+
+ /// Mock custom-field deletion.
+ MOCK_METHOD((mw::E<void>), deleteGameField,
+ (std::int64_t field_id), (override));
+
+ /// Mock field usage count.
+ MOCK_METHOD((mw::E<std::int64_t>), countFieldUsage,
+ (std::int64_t field_id), (override));
+
+ /// Mock exact choice usage count.
+ MOCK_METHOD((mw::E<std::int64_t>), countChoiceUsage,
+ (std::int64_t field_id, const std::string& value),
+ (override));
+
+ /// Mock complete game-field ordering.
+ MOCK_METHOD((mw::E<void>), updateGameFieldOrder,
+ (const std::string& short_name,
+ const std::vector<std::int64_t>& field_ids), (override));
+
+ /// Mock aggregate definition revision advancement.
+ MOCK_METHOD((mw::E<bool>), incrementGameRevision,
+ (const std::string& short_name,
+ std::int64_t expected_revision), (override));
+
+ /// Mock transaction-local series ownership validation.
+ MOCK_METHOD((mw::E<bool>), seriesBelongToGame,
+ (const std::string& short_name,
+ const std::vector<std::int64_t>& series_ids), (override));
+
/// Mock the loose-card number collision query.
MOCK_METHOD(
(mw::E<bool>),
@@ -123,13 +184,12 @@ public:
MOCK_METHOD((mw::E<std::int64_t>), incrementHolding,
(std::int64_t user_id, std::int64_t card_id), (override));
- /// Mock insertion of a card and its dependent rows.
+ /// Mock insertion of a card and its dynamic dependent rows.
MOCK_METHOD(
(mw::E<std::int64_t>),
insertCard,
(const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
+ const std::vector<GameFieldValue>& field_values,
const std::vector<std::int64_t>& series_ids),
(override));
@@ -138,8 +198,7 @@ public:
(mw::E<void>),
updateCard,
(const Card& card,
- const GameDefinition* game,
- const GameCardMetadata* metadata,
+ const std::vector<GameFieldValue>& field_values,
const std::vector<std::int64_t>& series_ids),
(override));
@@ -190,7 +249,7 @@ public:
MOCK_METHOD(
(mw::E<void>),
migrateSchema0To1,
- (const GameRegistry& games),
+ (),
(override));
/// Mock creation of a mutation transaction.
@@ -222,6 +281,20 @@ public:
(const CardIdentity& identity),
(const, override));
+ /// Mock retrieval of all database-defined games.
+ MOCK_METHOD((mw::E<std::vector<Game>>), getGames,
+ (), (const, override));
+
+ /// Mock retrieval of one complete game definition.
+ MOCK_METHOD((mw::E<std::optional<GameDefinitionSnapshot>>),
+ getGameDefinition, (const std::string& short_name),
+ (const, override));
+
+ /// Mock retrieval of a card's definition and custom values.
+ MOCK_METHOD((mw::E<std::optional<CardGameFields>>),
+ getCardFieldValues, (std::int64_t card_id),
+ (const, override));
+
/// Mock retrieval of a user by internal identity.
MOCK_METHOD((mw::E<std::optional<User>>), getUser,
(std::int64_t user_id), (const, override));
@@ -264,20 +337,6 @@ public:
MOCK_METHOD((mw::E<void>), cleanupAuthentication,
(std::int64_t now), (override));
- /// Mock retrieval of game-specific card display fields.
- MOCK_METHOD(
- (mw::E<std::vector<DisplayField>>),
- getGameDisplayFields,
- (const GameDefinition& game, std::int64_t card_id),
- (const, override));
-
- /// Mock retrieval of current game-owned edit values.
- MOCK_METHOD(
- (mw::E<FormFields>),
- getGameFormValues,
- (const GameDefinition& game, std::int64_t card_id),
- (const, override));
-
/// Mock retrieval of all series.
MOCK_METHOD(
(mw::E<std::vector<Series>>),
@@ -299,13 +358,6 @@ public:
(std::int64_t card_id),
(const, override));
- /// Mock retrieval of all persisted game short names.
- MOCK_METHOD(
- (mw::E<std::vector<std::string>>),
- getPersistedGameNames,
- (),
- (const, override));
-
/// Mock mutation of the stored schema version.
MOCK_METHOD(
(mw::E<void>),
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index f177ddd..eca1044 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -1,7 +1,5 @@
#include "data_sqlite.h"
-#include "game_registry.h"
#include "startup.h"
-#include "test_game.h"
#include <chrono>
#include <cstdint>
@@ -84,16 +82,14 @@ TEST(DataSourceSQLiteTest, RejectsReadsBeforeMigration)
EXPECT_FALSE(data_source->getSeries());
EXPECT_FALSE(data_source->getSeries(1));
- EXPECT_FALSE(data_source->getPersistedGameNames());
+ EXPECT_FALSE(data_source->getGames());
}
/// Verify startup prepares a fresh database for its first card-index read.
TEST(DataSourceSQLiteTest, PreparesFreshDatabaseForStartup)
{
TemporaryDatabase database;
- GameRegistry games;
-
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto version = (*data_source)->getSchemaVersion();
@@ -197,8 +193,7 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
TEST(DataSourceSQLiteTest, InsertsLooseCard)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
@@ -207,7 +202,7 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
ASSERT_TRUE(exists_before);
EXPECT_FALSE(*exists_before);
auto card_id = (*transaction)->insertCard(
- makeLooseCard(42, "New card"), nullptr, nullptr, {});
+ makeLooseCard(42, "New card"), {}, {});
ASSERT_TRUE(card_id);
EXPECT_GT(*card_id, 0);
auto exists_after = (*transaction)->looseNumberExists(42);
@@ -227,13 +222,12 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
TEST(DataSourceSQLiteTest, UpdatesLooseCard)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
auto card_id = (*transaction)->insertCard(
- makeLooseCard(42, "Original"), nullptr, nullptr, {});
+ makeLooseCard(42, "Original"), {}, {});
ASSERT_TRUE(card_id);
ASSERT_TRUE((*transaction)->commit());
@@ -247,7 +241,7 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
(**card).foil_extension = "webp";
(**card).revision = 2;
ASSERT_TRUE((*transaction)->updateCard(
- **card, nullptr, nullptr, {}));
+ **card, {}, {}));
ASSERT_TRUE((*transaction)->commit());
auto stored = (*data_source)->getCard({std::nullopt, 42});
@@ -265,14 +259,15 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
TEST(DataSourceSQLiteTest, MutatesSeries)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"gh", "Going Home", "", 1}));
auto series_id = (*transaction)->insertSeries(
- {0, "test", "First series", "Description"});
+ {0, "gh", "First series", "Description"});
ASSERT_TRUE(series_id);
ASSERT_TRUE((*transaction)->commit());
@@ -303,98 +298,95 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
EXPECT_FALSE(*series);
}
-/// Verify compiled-game schema, numbering, metadata, and memberships.
-TEST(DataSourceSQLiteTest, PersistsCompiledGameCards)
+/// Verify dynamic fields, numbering, values, and memberships persist.
+TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
{
TemporaryDatabase database;
- GameRegistry games;
- ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
- const GameDefinition* game = games.find("test");
- ASSERT_NE(game, nullptr);
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
- auto series_transaction = (*data_source)->beginTransaction();
- ASSERT_TRUE(series_transaction);
- auto series_id = (*series_transaction)->insertSeries(
- {0, "test", "Core set", "The first set."});
- ASSERT_TRUE(series_id);
- ASSERT_TRUE((*series_transaction)->commit());
-
- auto metadata = game->validateMetadata({{"hp", "60"}, {"attack", "20"}});
- ASSERT_TRUE(metadata);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"test", "Test Game", "Description", 1}));
+ auto hp_id = (*transaction)->insertGameField(
+ {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+ auto note_id = (*transaction)->insertGameField(
+ {0, "test", "note", "Note", GameFieldType::STRING, 1, {}}, {});
+ auto route_id = (*transaction)->insertGameField(
+ {0, "test", "route", "Route", GameFieldType::CHOICE, 2, {}},
+ {{"Forest", 0}, {"Coast", 1}});
+ ASSERT_TRUE(hp_id);
+ ASSERT_TRUE(note_id);
+ ASSERT_TRUE(route_id);
+ auto series_id = (*transaction)->insertSeries(
+ {0, "test", "Core set", "The first set."});
+ ASSERT_TRUE(series_id);
auto number = (*transaction)->allocateGameNumber("test");
ASSERT_TRUE(number);
Card card = {
- 0,
- {"test", *number},
- "Compiled card",
- std::nullopt,
- std::nullopt,
- 2,
- "avif",
- std::nullopt,
- "avif",
- 1,
- 1,
- };
+ 0, {"test", *number}, "Dynamic card", std::nullopt,
+ std::nullopt, 2, "avif", std::nullopt, "avif", 1, 1};
auto card_id = (*transaction)->insertCard(
- card, game, metadata->get(), {*series_id});
+ card,
+ {{*hp_id, GameFieldType::INTEGER, std::int64_t{60}},
+ {*note_id, GameFieldType::STRING, std::string("Near home")},
+ {*route_id, GameFieldType::CHOICE, std::string("Forest")}},
+ {*series_id});
ASSERT_TRUE(card_id);
ASSERT_TRUE((*transaction)->commit());
auto stored = (*data_source)->getCard({"test", 1});
ASSERT_TRUE(stored);
ASSERT_TRUE(*stored);
- EXPECT_EQ((**stored).id, *card_id);
- auto memberships = (*data_source)->getCardSeries(*card_id);
- ASSERT_TRUE(memberships);
- EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
- auto display = (*data_source)->getGameDisplayFields(*game, *card_id);
- ASSERT_TRUE(display);
- ASSERT_EQ(display->size(), 2);
- EXPECT_EQ((*display)[0].value, "60");
- EXPECT_EQ((*display)[1].value, "20");
- auto form_values = (*data_source)->getGameFormValues(
- *game, *card_id);
- ASSERT_TRUE(form_values);
- EXPECT_EQ(form_values->at("hp"), "60");
- EXPECT_EQ(form_values->at("attack"), "20");
-
- metadata = game->validateMetadata({{"hp", "70"}, {"attack", "30"}});
- ASSERT_TRUE(metadata);
+ auto values = (*data_source)->getCardFieldValues(*card_id);
+ ASSERT_TRUE(values);
+ ASSERT_TRUE(*values);
+ ASSERT_EQ((**values).definition.fields.size(), 3);
+ ASSERT_EQ((**values).values.size(), 3);
+ EXPECT_EQ(std::get<std::int64_t>((**values).values[0].value), 60);
+ EXPECT_EQ(std::get<std::string>((**values).values[1].value), "Near home");
+ EXPECT_EQ(std::get<std::string>((**values).values[2].value), "Forest");
+
Card updated = **stored;
updated.revision = 2;
transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->updateCard(
- updated, game, metadata->get(), {}));
+ updated,
+ {{*hp_id, GameFieldType::INTEGER, std::int64_t{70}}},
+ {}));
+ ASSERT_TRUE((*transaction)->commit());
+ values = (*data_source)->getCardFieldValues(*card_id);
+ ASSERT_TRUE(values);
+ ASSERT_TRUE(*values);
+ ASSERT_EQ((**values).values.size(), 1);
+ EXPECT_EQ(std::get<std::int64_t>((**values).values[0].value), 70);
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto later_id = (*transaction)->insertGameField(
+ {0, "test", "later", "Later", GameFieldType::STRING, 3, {}},
+ {});
+ ASSERT_TRUE(later_id);
ASSERT_TRUE((*transaction)->commit());
- memberships = (*data_source)->getCardSeries(*card_id);
- ASSERT_TRUE(memberships);
- EXPECT_TRUE(memberships->empty());
- display = (*data_source)->getGameDisplayFields(*game, *card_id);
- ASSERT_TRUE(display);
- EXPECT_EQ((*display)[0].value, "70");
- form_values = (*data_source)->getGameFormValues(*game, *card_id);
- ASSERT_TRUE(form_values);
- EXPECT_EQ(form_values->at("hp"), "70");
- EXPECT_EQ(form_values->at("attack"), "30");
+ values = (*data_source)->getCardFieldValues(*card_id);
+ ASSERT_TRUE(values);
+ ASSERT_TRUE(*values);
+ EXPECT_EQ((**values).definition.fields.size(), 4);
+ EXPECT_EQ((**values).values.size(), 1);
}
/// Verify destroying an uncommitted transaction rolls card insertion back.
TEST(DataSourceSQLiteTest, RollsBackLooseCard)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
ASSERT_TRUE((*transaction)->insertCard(
- makeLooseCard(91, "Temporary card"), nullptr, nullptr, {}));
+ makeLooseCard(91, "Temporary card"), {}, {}));
transaction->reset();
@@ -407,15 +399,14 @@ TEST(DataSourceSQLiteTest, RollsBackLooseCard)
TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(transaction);
- ASSERT_TRUE((*transaction)->ensureGameSequence("pkm"));
-
- auto first = (*transaction)->allocateGameNumber("pkm");
- auto second = (*transaction)->allocateGameNumber("pkm");
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"gh", "Going Home", "", 1}));
+ auto first = (*transaction)->allocateGameNumber("gh");
+ auto second = (*transaction)->allocateGameNumber("gh");
ASSERT_TRUE(first);
ASSERT_TRUE(second);
EXPECT_EQ(*first, 1);
@@ -424,7 +415,7 @@ TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
auto next_transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(next_transaction);
- auto third = (*next_transaction)->allocateGameNumber("pkm");
+ auto third = (*next_transaction)->allocateGameNumber("gh");
ASSERT_TRUE(third);
EXPECT_EQ(*third, 3);
}
@@ -433,13 +424,12 @@ TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto first_transaction = (*data_source)->beginTransaction();
ASSERT_TRUE(first_transaction);
ASSERT_TRUE((*first_transaction)->insertCard(
- makeLooseCard(8, "First"), nullptr, nullptr, {}));
+ makeLooseCard(8, "First"), {}, {}));
ASSERT_TRUE((*first_transaction)->commit());
auto second_transaction = (*data_source)->beginTransaction();
@@ -448,16 +438,15 @@ TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
ASSERT_TRUE(exists);
EXPECT_TRUE(*exists);
EXPECT_FALSE((*second_transaction)->insertCard(
- makeLooseCard(8, "Duplicate"), nullptr, nullptr, {}));
+ makeLooseCard(8, "Duplicate"), {}, {}));
}
/// Verify administrator metadata is immutable and exactly one row is kept.
TEST(DataSourceSQLiteTest, ReconcilesImmutableAdministrator)
{
TemporaryDatabase database;
- GameRegistry games;
auto data_source = prepareDataSource(
- database.path(), games, "Admin@Example.com", "admin@example.com",
+ database.path(), "Admin@Example.com", "admin@example.com",
100, 0);
ASSERT_TRUE(data_source) << data_source.error().msg();
@@ -494,8 +483,7 @@ TEST(DataSourceSQLiteTest, ReconcilesImmutableAdministrator)
TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source) << data_source.error().msg();
auto administrator = (*data_source)->getUserByEmailKey(
"admin@example.com");
@@ -597,7 +585,7 @@ TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
ASSERT_TRUE(transaction);
Card card = makeLooseCard(123, "Collectible");
card.creator_user_id = (**administrator).id;
- auto card_id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+ auto card_id = (*transaction)->insertCard(card, {}, {});
ASSERT_TRUE(card_id) << card_id.error().msg();
auto quantity = (*transaction)->incrementHolding(*player_id, *card_id);
ASSERT_TRUE(quantity);
@@ -638,3 +626,237 @@ TEST(DataSourceSQLiteTest, RejectsObsoletePrototypeSchema)
EXPECT_NE(version.error().msg().find("delete and recreate"),
std::string::npos);
}
+
+/// Verify fresh initialization and reopening do not create any games.
+TEST(DataSourceSQLiteTest, StartsAndReopensWithoutGames)
+{
+ TemporaryDatabase database;
+ auto data_source = prepareDataSource(database.path());
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+
+ auto games = (*data_source)->getGames();
+ ASSERT_TRUE(games);
+ EXPECT_TRUE(games->empty());
+ data_source->reset();
+
+ data_source = prepareDataSource(database.path());
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+ games = (*data_source)->getGames();
+ ASSERT_TRUE(games);
+ EXPECT_TRUE(games->empty());
+}
+
+/// Verify failed initialization rolls back newly attempted schema objects.
+TEST(DataSourceSQLiteTest, RollsBackFailedInitialization)
+{
+ TemporaryDatabase database;
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute(
+ "CREATE TABLE games(existing_marker TEXT);"));
+ connection->reset();
+
+ auto data_source = DataSourceSQLite::fromFile(database.path());
+ ASSERT_TRUE(data_source);
+ EXPECT_FALSE((*data_source)->migrateToLatest());
+ data_source->reset();
+
+ connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ auto users = (*connection)->evalToValue<int>(
+ "SELECT EXISTS(SELECT 1 FROM sqlite_schema "
+ "WHERE type = 'table' AND name = 'users');");
+ ASSERT_TRUE(users);
+ EXPECT_EQ(*users, 0);
+ auto marker = (*connection)->evalToValue<int>(
+ "SELECT EXISTS(SELECT 1 FROM sqlite_schema "
+ "WHERE type = 'table' AND name = 'games');");
+ ASSERT_TRUE(marker);
+ EXPECT_EQ(*marker, 1);
+}
+
+/// Verify future schema versions are rejected without modifying their data.
+TEST(DataSourceSQLiteTest, RejectsFutureSchemaWithoutModification)
+{
+ TemporaryDatabase database;
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute(
+ "CREATE TABLE future_marker(value TEXT);"));
+ ASSERT_TRUE((*connection)->execute(
+ "INSERT INTO future_marker VALUES ('kept');"));
+ ASSERT_TRUE((*connection)->execute("PRAGMA user_version = 2;"));
+ connection->reset();
+
+ auto data_source = DataSourceSQLite::fromFile(database.path());
+ ASSERT_TRUE(data_source);
+ EXPECT_FALSE((*data_source)->migrateToLatest());
+ data_source->reset();
+
+ connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ auto value = (*connection)->evalToValue<std::string>(
+ "SELECT value FROM future_marker;");
+ ASSERT_TRUE(value);
+ EXPECT_EQ(*value, "kept");
+ auto version = (*connection)->evalToValue<std::int64_t>(
+ "PRAGMA user_version;");
+ ASSERT_TRUE(version);
+ EXPECT_EQ(*version, 2);
+}
+
+/// Verify startup rejects a version-1 game missing its number sequence.
+TEST(DataSourceSQLiteTest, RejectsMissingGameSequence)
+{
+ TemporaryDatabase database;
+ auto data_source = prepareDataSource(database.path());
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"test", "Test Game", "", 1}));
+ ASSERT_TRUE((*transaction)->commit());
+ data_source->reset();
+
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute(
+ "DELETE FROM game_sequences WHERE game_short_name = 'test';"));
+ connection->reset();
+
+ auto reopened = prepareDataSource(database.path());
+ ASSERT_FALSE(reopened);
+ EXPECT_NE(reopened.error().msg().find("missing its card-number sequence"),
+ std::string::npos);
+}
+
+/// Verify database constraints reject cross-game and mistyped field values.
+TEST(DataSourceSQLiteTest, EnforcesDynamicFieldInvariants)
+{
+ TemporaryDatabase database;
+ auto data_source = prepareDataSource(database.path());
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"one", "One", "", 1}));
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"two", "Two", "", 1}));
+ auto integer_id = (*transaction)->insertGameField(
+ {0, "one", "score", "Score", GameFieldType::INTEGER, 0, {}},
+ {});
+ auto first_choice_id = (*transaction)->insertGameField(
+ {0, "one", "route", "Route", GameFieldType::CHOICE, 1, {}},
+ {{"Shared", 0}});
+ auto second_choice_id = (*transaction)->insertGameField(
+ {0, "one", "color", "Color", GameFieldType::CHOICE, 2, {}},
+ {{"Other", 0}});
+ ASSERT_TRUE(integer_id);
+ ASSERT_TRUE(first_choice_id);
+ ASSERT_TRUE(second_choice_id);
+ ASSERT_TRUE((*transaction)->commit());
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto one_number = (*transaction)->allocateGameNumber("one");
+ ASSERT_TRUE(one_number);
+ Card one_card = makeLooseCard(*one_number, "One card");
+ one_card.identity.game_short_name = "one";
+ EXPECT_FALSE((*transaction)->insertCard(
+ one_card,
+ {{*integer_id, GameFieldType::STRING, std::string("5")}},
+ {}));
+ transaction->reset();
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto two_number = (*transaction)->allocateGameNumber("two");
+ ASSERT_TRUE(two_number);
+ Card two_card = makeLooseCard(*two_number, "Two card");
+ two_card.identity.game_short_name = "two";
+ EXPECT_FALSE((*transaction)->insertCard(
+ two_card,
+ {{*integer_id, GameFieldType::INTEGER, std::int64_t{5}}},
+ {}));
+ transaction->reset();
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ one_number = (*transaction)->allocateGameNumber("one");
+ ASSERT_TRUE(one_number);
+ one_card.identity.card_number = *one_number;
+ EXPECT_FALSE((*transaction)->insertCard(
+ one_card,
+ {{*first_choice_id,
+ GameFieldType::CHOICE,
+ std::string("Other")}},
+ {}));
+}
+
+/// Verify definition identities are immutable and card values cascade.
+TEST(DataSourceSQLiteTest, PreservesDynamicIdentitiesAndCascadesValues)
+{
+ TemporaryDatabase database;
+ auto data_source = prepareDataSource(database.path());
+ ASSERT_TRUE(data_source) << data_source.error().msg();
+ auto transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->insertGame(
+ {"fixed", "Fixed", "", 1}));
+ auto field_id = (*transaction)->insertGameField(
+ {0, "fixed", "score", "Score", GameFieldType::INTEGER, 0, {}},
+ {});
+ auto choice_id = (*transaction)->insertGameField(
+ {0, "fixed", "route", "Route", GameFieldType::CHOICE, 1, {}},
+ {{"Home", 0}});
+ ASSERT_TRUE(field_id);
+ ASSERT_TRUE(choice_id);
+ auto number = (*transaction)->allocateGameNumber("fixed");
+ ASSERT_TRUE(number);
+ Card card = makeLooseCard(*number, "Fixed card");
+ card.identity.game_short_name = "fixed";
+ auto card_id = (*transaction)->insertCard(
+ card,
+ {{*field_id, GameFieldType::INTEGER, std::int64_t{9}}},
+ {});
+ ASSERT_TRUE(card_id);
+ ASSERT_TRUE((*transaction)->commit());
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto stored = (*transaction)->getCardForUpdate(*card_id);
+ ASSERT_TRUE(stored);
+ ASSERT_TRUE(*stored);
+ (**stored).identity.card_number = 99;
+ EXPECT_FALSE((*transaction)->updateCard(
+ **stored,
+ {{*field_id, GameFieldType::INTEGER, std::int64_t{10}}},
+ {}));
+ transaction->reset();
+
+ transaction = (*data_source)->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->deleteCard(*card_id));
+ ASSERT_TRUE((*transaction)->commit());
+ data_source->reset();
+
+ auto connection = mw::SQLite::connectFile(database.path().string());
+ ASSERT_TRUE(connection);
+ ASSERT_TRUE((*connection)->execute("PRAGMA foreign_keys = ON;"));
+ EXPECT_FALSE((*connection)->execute(
+ "UPDATE games SET short_name = 'changed' "
+ "WHERE short_name = 'fixed';"));
+ EXPECT_FALSE((*connection)->execute(
+ "UPDATE game_fields SET key = 'changed' "
+ "WHERE id = 1;"));
+ EXPECT_FALSE((*connection)->execute(
+ "INSERT INTO game_field_choices(field_id, value, position) "
+ "VALUES (1, 'Invalid', 0);"));
+ EXPECT_FALSE((*connection)->execute(
+ "UPDATE game_field_choices SET value = 'Changed' "
+ "WHERE field_id = 2 AND value = 'Home';"));
+ auto value_count = (*connection)->evalToValue<std::int64_t>(
+ "SELECT COUNT(*) FROM card_field_values;");
+ ASSERT_TRUE(value_count);
+ EXPECT_EQ(*value_count, 0);
+}
diff --git a/tests/game_field_form_logic_test.js b/tests/game_field_form_logic_test.js
new file mode 100644
index 0000000..f665e89
--- /dev/null
+++ b/tests/game_field_form_logic_test.js
@@ -0,0 +1,13 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const logic = require("../static/game_field_form.js");
+
+test("choice movement stays within the ordered list", function()
+{
+ assert.equal(logic.choiceMoveIndex(1, -1, 3), 0);
+ assert.equal(logic.choiceMoveIndex(1, 1, 3), 2);
+ assert.equal(logic.choiceMoveIndex(0, -1, 3), 0);
+ assert.equal(logic.choiceMoveIndex(2, 1, 3), 2);
+ assert.equal(logic.choiceMoveIndex(-1, 1, 3), -1);
+ assert.equal(logic.choiceMoveIndex(0, 1, 0), 0);
+});
diff --git a/tests/game_field_test.cpp b/tests/game_field_test.cpp
new file mode 100644
index 0000000..8b08b69
--- /dev/null
+++ b/tests/game_field_test.cpp
@@ -0,0 +1,108 @@
+#include <cstdint>
+#include <limits>
+#include <string>
+#include <variant>
+
+#include <gtest/gtest.h>
+
+#include "game_field.h"
+
+namespace
+{
+
+GameDefinitionSnapshot definition()
+{
+ return {
+ {"test", "Test", "", 1},
+ {
+ {1, "test", "score", "Score", GameFieldType::INTEGER, 0, {}},
+ {2, "test", "note", "Note", GameFieldType::STRING, 1, {}},
+ {3, "test", "route", "Route", GameFieldType::CHOICE, 2,
+ {{"Home", 0}, {"Forest", 1}, {"A&B+ C", 2}}},
+ },
+ };
+}
+
+TEST(GameFieldTest, DecodesAllSupportedTypesAndUnsetValues)
+{
+ const auto decoded = decodeGameFields(definition(), {
+ {"score", "-7"}, {"note", "Near\nthe station"},
+ {"route", "Forest"},
+ });
+ ASSERT_TRUE(decoded) << decoded.error().msg();
+ ASSERT_EQ(decoded->size(), 3);
+ EXPECT_EQ(std::get<std::int64_t>((*decoded)[0].value), -7);
+ EXPECT_EQ(
+ std::get<std::string>((*decoded)[1].value), "Near\nthe station");
+ EXPECT_EQ(std::get<std::string>((*decoded)[2].value), "Forest");
+
+ const auto empty = decodeGameFields(definition(), {
+ {"score", ""}, {"note", ""}, {"route", ""},
+ });
+ ASSERT_TRUE(empty);
+ EXPECT_TRUE(empty->empty());
+}
+
+TEST(GameFieldTest, AcceptsFullSignedIntegerRange)
+{
+ auto smallest = decodeGameFields(definition(), {{
+ "score", std::to_string(std::numeric_limits<std::int64_t>::min())}});
+ auto largest = decodeGameFields(definition(), {{
+ "score", std::to_string(std::numeric_limits<std::int64_t>::max())}});
+ ASSERT_TRUE(smallest);
+ ASSERT_TRUE(largest);
+ EXPECT_EQ(
+ std::get<std::int64_t>(smallest->front().value),
+ std::numeric_limits<std::int64_t>::min());
+ EXPECT_EQ(
+ std::get<std::int64_t>(largest->front().value),
+ std::numeric_limits<std::int64_t>::max());
+
+ auto zero = decodeGameFields(definition(), {{"score", "0"}});
+ auto leading_zero = decodeGameFields(
+ definition(), {{"score", "007"}});
+ ASSERT_TRUE(zero);
+ ASSERT_TRUE(leading_zero);
+ EXPECT_EQ(std::get<std::int64_t>(zero->front().value), 0);
+ EXPECT_EQ(std::get<std::int64_t>(leading_zero->front().value), 7);
+}
+
+TEST(GameFieldTest, RejectsMalformedIntegersAndUnknownChoices)
+{
+ for(const std::string& value : {
+ "+1", " 1", "1 ", "1.0", "1e2", "1x",
+ "9223372036854775808", "-9223372036854775809"})
+ {
+ EXPECT_FALSE(decodeGameFields(definition(), {{"score", value}}))
+ << value;
+ }
+ EXPECT_FALSE(decodeGameFields(definition(), {{"route", "home"}}));
+ EXPECT_FALSE(decodeGameFields(definition(), {{"route", "Home "}}));
+ EXPECT_FALSE(decodeGameFields(definition(), {{"route", "Unknown"}}));
+ auto punctuation = decodeGameFields(
+ definition(), {{"route", "A&B+ C"}});
+ ASSERT_TRUE(punctuation);
+ EXPECT_EQ(
+ std::get<std::string>(punctuation->front().value), "A&B+ C");
+}
+
+TEST(GameFieldTest, RejectsUnknownKeysAndInvalidText)
+{
+ EXPECT_FALSE(decodeGameFields(definition(), {{"other", "value"}}));
+ EXPECT_FALSE(decodeGameFields(
+ definition(), {{"note", std::string("bad\0text", 8)}}));
+ EXPECT_FALSE(decodeGameFields(
+ definition(), {{"note", std::string("\xc3", 1)}}));
+ const auto whitespace = decodeGameFields(
+ definition(), {{"note", " \n"}});
+ ASSERT_TRUE(whitespace);
+ EXPECT_EQ(std::get<std::string>(whitespace->front().value), " \n");
+
+ const std::string large_text(2 * 1024 * 1024, 'x');
+ auto large = decodeGameFields(
+ definition(), {{"note", large_text}});
+ ASSERT_TRUE(large);
+ EXPECT_EQ(std::get<std::string>(large->front().value), large_text);
+}
+
+} // namespace
diff --git a/tests/game_service_test.cpp b/tests/game_service_test.cpp
new file mode 100644
index 0000000..189fb4a
--- /dev/null
+++ b/tests/game_service_test.cpp
@@ -0,0 +1,301 @@
+#include "game_service.h"
+
+#include <chrono>
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "startup.h"
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+ /// Allocate a unique database path for one test.
+ TemporaryDatabase()
+ : path_(
+ std::filesystem::path(testing::TempDir()) /
+ ("game_service_" + std::to_string(
+ std::chrono::steady_clock::now()
+ .time_since_epoch().count()) + ".sqlite3"))
+ {}
+
+ /// Remove the database and SQLite sidecars.
+ ~TemporaryDatabase()
+ {
+ std::error_code error;
+ std::filesystem::remove(path_, error);
+ std::filesystem::remove(path_.string() + "-shm", error);
+ std::filesystem::remove(path_.string() + "-wal", error);
+ }
+
+ /// Return the allocated path.
+ const std::filesystem::path& path() const
+ {
+ return path_;
+ }
+
+private:
+ std::filesystem::path path_;
+};
+
+struct ServiceFixture
+{
+ std::unique_ptr<DataSourceInterface> data_source;
+ std::int64_t administrator_id;
+ std::int64_t player_id;
+};
+
+mw::E<ServiceFixture> prepareFixture(const std::filesystem::path& path)
+{
+ auto data_source = prepareDataSource(path);
+ if(!data_source)
+ {
+ return std::unexpected(std::move(data_source.error()));
+ }
+ auto transaction = (*data_source)->beginTransaction();
+ if(!transaction)
+ {
+ return std::unexpected(std::move(transaction.error()));
+ }
+ auto named = (*transaction)->updateUsername(
+ 1, "Administrator", "administrator");
+ if(!named)
+ {
+ return std::unexpected(std::move(named.error()));
+ }
+ User player = {
+ 0,
+ "player@example.com",
+ "player@example.com",
+ std::nullopt,
+ UserRole::PLAYER,
+ 1,
+ 0,
+ 0};
+ auto player_id = (*transaction)->insertUser(player);
+ if(!player_id)
+ {
+ return std::unexpected(std::move(player_id.error()));
+ }
+ named = (*transaction)->updateUsername(
+ *player_id, "Player", "player");
+ if(!named)
+ {
+ return std::unexpected(std::move(named.error()));
+ }
+ auto committed = (*transaction)->commit();
+ if(!committed)
+ {
+ return std::unexpected(std::move(committed.error()));
+ }
+ return ServiceFixture{std::move(*data_source), 1, *player_id};
+}
+
+const mw::HTTPError* httpError(const mw::Error& error)
+{
+ return error.as<mw::HTTPError>();
+}
+
+} // namespace
+
+/// Verify complete game definitions mutate atomically and advance revisions.
+TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
+{
+ TemporaryDatabase database;
+ auto fixture = prepareFixture(database.path());
+ ASSERT_TRUE(fixture) << fixture.error().msg();
+ GameService service(*fixture->data_source);
+
+ auto created = service.createGame(
+ fixture->administrator_id, "demo", " Demo Game ", "A **game**.");
+ ASSERT_TRUE(created) << created.error().msg();
+ EXPECT_EQ(*created, "demo");
+
+ auto integer_id = service.createField(
+ fixture->administrator_id,
+ "demo",
+ 1,
+ "score",
+ " Score ",
+ GameFieldType::INTEGER,
+ {});
+ ASSERT_TRUE(integer_id) << integer_id.error().msg();
+ auto string_id = service.createField(
+ fixture->administrator_id,
+ "demo",
+ 2,
+ "note",
+ "Note",
+ GameFieldType::STRING,
+ {});
+ ASSERT_TRUE(string_id) << string_id.error().msg();
+ auto choice_id = service.createField(
+ fixture->administrator_id,
+ "demo",
+ 3,
+ "route",
+ "Route",
+ GameFieldType::CHOICE,
+ {"Forest", "Coast"});
+ ASSERT_TRUE(choice_id) << choice_id.error().msg();
+
+ auto duplicate_choices = service.createField(
+ fixture->administrator_id,
+ "demo",
+ 4,
+ "duplicate",
+ "Duplicate",
+ GameFieldType::CHOICE,
+ {"Same", "Same"});
+ ASSERT_FALSE(duplicate_choices);
+ ASSERT_NE(httpError(duplicate_choices.error()), nullptr);
+ EXPECT_EQ(httpError(duplicate_choices.error())->code, 409);
+
+ auto stale = service.updateGame(
+ fixture->administrator_id, "demo", 3, "Stale", "");
+ ASSERT_FALSE(stale);
+ ASSERT_NE(httpError(stale.error()), nullptr);
+ EXPECT_EQ(httpError(stale.error())->code, 409);
+
+ ASSERT_TRUE(service.reorderFields(
+ fixture->administrator_id,
+ "demo",
+ 4,
+ {*choice_id, *string_id, *integer_id}));
+ ASSERT_TRUE(service.updateField(
+ fixture->administrator_id,
+ "demo",
+ *choice_id,
+ 5,
+ "Path",
+ {"Coast", "Forest", "Town"}));
+ ASSERT_TRUE(service.updateGame(
+ fixture->administrator_id,
+ "demo",
+ 6,
+ "Renamed Game",
+ "New description"));
+
+ auto definition = fixture->data_source->getGameDefinition("demo");
+ ASSERT_TRUE(definition);
+ ASSERT_TRUE(*definition);
+ EXPECT_EQ((**definition).game.display_name, "Renamed Game");
+ EXPECT_EQ((**definition).game.revision, 7);
+ ASSERT_EQ((**definition).fields.size(), 3);
+ EXPECT_EQ((**definition).fields[0].id, *choice_id);
+ EXPECT_EQ((**definition).fields[0].label, "Path");
+ ASSERT_EQ((**definition).fields[0].choices.size(), 3);
+ EXPECT_EQ((**definition).fields[0].choices[0].value, "Coast");
+ EXPECT_EQ((**definition).fields[0].choices[2].value, "Town");
+}
+
+/// Verify definition authorization, identity validation, and usage conflicts.
+TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
+{
+ TemporaryDatabase database;
+ auto fixture = prepareFixture(database.path());
+ ASSERT_TRUE(fixture) << fixture.error().msg();
+ GameService service(*fixture->data_source);
+
+ auto forbidden = service.createGame(
+ fixture->player_id, "player", "Player Game", "");
+ ASSERT_FALSE(forbidden);
+ ASSERT_NE(httpError(forbidden.error()), nullptr);
+ EXPECT_EQ(httpError(forbidden.error())->code, 403);
+
+ auto invalid = service.createGame(
+ fixture->administrator_id, "d\xc3\xa9mo", "Invalid", "");
+ ASSERT_FALSE(invalid);
+ const auto* validation =
+ invalid.error().as<GameDefinitionValidationError>();
+ ASSERT_NE(validation, nullptr);
+ EXPECT_EQ(validation->field_name, "short_name");
+
+ ASSERT_TRUE(service.createGame(
+ fixture->administrator_id, "used", "Used Game", ""));
+ auto choice_id = service.createField(
+ fixture->administrator_id,
+ "used",
+ 1,
+ "route",
+ "Route",
+ GameFieldType::CHOICE,
+ {"Forest", "Coast"});
+ ASSERT_TRUE(choice_id) << choice_id.error().msg();
+
+ auto transaction = fixture->data_source->beginTransaction();
+ ASSERT_TRUE(transaction);
+ auto number = (*transaction)->allocateGameNumber("used");
+ ASSERT_TRUE(number);
+ Card card = {
+ 0,
+ {"used", *number},
+ "Used card",
+ std::nullopt,
+ std::nullopt,
+ 0,
+ "avif",
+ std::nullopt,
+ "avif",
+ 1,
+ fixture->administrator_id};
+ auto card_id = (*transaction)->insertCard(
+ card,
+ {{*choice_id, GameFieldType::CHOICE, std::string("Forest")}},
+ {});
+ ASSERT_TRUE(card_id) << card_id.error().msg();
+ ASSERT_TRUE((*transaction)->commit());
+
+ auto used_choice = service.updateField(
+ fixture->administrator_id,
+ "used",
+ *choice_id,
+ 2,
+ "Route",
+ {"Coast"});
+ ASSERT_FALSE(used_choice);
+ ASSERT_NE(httpError(used_choice.error()), nullptr);
+ EXPECT_EQ(httpError(used_choice.error())->code, 409);
+ EXPECT_NE(httpError(used_choice.error())->msg.find("1 card"),
+ std::string::npos);
+
+ auto used_field = service.removeField(
+ fixture->administrator_id, "used", *choice_id, 2);
+ ASSERT_FALSE(used_field);
+ ASSERT_NE(httpError(used_field.error()), nullptr);
+ EXPECT_EQ(httpError(used_field.error())->code, 409);
+
+ auto used_game = service.removeGame(
+ fixture->administrator_id, "used", 2);
+ ASSERT_FALSE(used_game);
+ ASSERT_NE(httpError(used_game.error()), nullptr);
+ EXPECT_EQ(httpError(used_game.error())->code, 409);
+
+ transaction = fixture->data_source->beginTransaction();
+ ASSERT_TRUE(transaction);
+ ASSERT_TRUE((*transaction)->deleteCard(*card_id));
+ ASSERT_TRUE((*transaction)->commit());
+ ASSERT_TRUE(service.removeField(
+ fixture->administrator_id, "used", *choice_id, 2));
+ auto issued_game = service.removeGame(
+ fixture->administrator_id, "used", 3);
+ ASSERT_FALSE(issued_game);
+ ASSERT_NE(httpError(issued_game.error()), nullptr);
+ EXPECT_EQ(httpError(issued_game.error())->code, 409);
+
+ ASSERT_TRUE(service.createGame(
+ fixture->administrator_id, "empty", "Empty Game", ""));
+ ASSERT_TRUE(service.removeGame(
+ fixture->administrator_id, "empty", 1));
+ auto removed = fixture->data_source->getGameDefinition("empty");
+ ASSERT_TRUE(removed);
+ EXPECT_FALSE(*removed);
+}
diff --git a/tests/test_game.h b/tests/test_game.h
deleted file mode 100644
index c1fb7ee..0000000
--- a/tests/test_game.h
+++ /dev/null
@@ -1,222 +0,0 @@
-#pragma once
-
-#include <cstdint>
-#include <memory>
-#include <string>
-#include <string_view>
-#include <tuple>
-#include <utility>
-#include <vector>
-
-#include <mw/utils.hpp>
-
-#include "game_definition.h"
-
-/// Strongly typed metadata used by the compiled-game test fixture.
-class TestGameMetadata final : public GameCardMetadata
-{
-public:
- /// Construct test metadata values.
- TestGameMetadata(std::int64_t hp, std::int64_t attack)
- : hp(hp), attack(attack)
- {}
-
- /// Nonnegative hit-point value.
- std::int64_t hp;
-
- /// Nonnegative attack value.
- std::int64_t attack;
-};
-
-/// Compiled game fixture proving extension schema and metadata hooks.
-class TestGame final : public GameDefinition
-{
-public:
- /// Return the fixture's canonical short name.
- std::string_view shortName() const override
- {
- return "test";
- }
-
- /// Return the fixture's human-readable name.
- std::string_view displayName() const override
- {
- return "Test Game";
- }
-
- /// Return the fixture game description.
- std::string_view description() const override
- {
- return "Compiled extension test game.";
- }
-
- /// Create the fixture extension table.
- mw::E<void> createSchema(mw::SQLite& database) const override
- {
- return database.execute(
- "CREATE TABLE test_cards ("
- "card_id INTEGER PRIMARY KEY "
- "REFERENCES cards(id) ON DELETE CASCADE, "
- "hp INTEGER NOT NULL CHECK(hp >= 0), "
- "attack INTEGER NOT NULL CHECK(attack >= 0));");
- }
-
- /// Validate the fixture's posted integer fields.
- mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
- const FormFields& fields) const override
- {
- const auto hp_position = fields.find("hp");
- const auto attack_position = fields.find("attack");
- if(hp_position == fields.end() || attack_position == fields.end())
- {
- return std::unexpected(mw::httpError(
- 422, "HP and attack are required"));
- }
- auto hp = mw::strToNumber<std::int64_t>(hp_position->second);
- auto attack = mw::strToNumber<std::int64_t>(
- attack_position->second);
- if(!hp || !attack || *hp < 0 || *attack < 0)
- {
- return std::unexpected(mw::httpError(
- 422, "HP and attack must be nonnegative integers"));
- }
- return std::unique_ptr<GameCardMetadata>(
- new TestGameMetadata(*hp, *attack));
- }
-
- /// Return the fixture's numeric metadata controls.
- std::vector<GameFormField> formFields() const override
- {
- return {
- {"hp", "HP", "number", true, "0"},
- {"attack", "Attack", "number", true, "0"},
- };
- }
-
- /// Return fixture metadata as editable form values.
- mw::E<FormFields> formValues(
- mw::SQLite& database,
- std::int64_t card_id) const override
- {
- auto statement = database.statementFromStr(
- "SELECT hp, attack FROM test_cards WHERE card_id = ?;");
- if(!statement)
- {
- return std::unexpected(std::move(statement.error()));
- }
- auto bind = statement->bind<std::int64_t>(card_id);
- if(!bind)
- {
- return std::unexpected(std::move(bind.error()));
- }
- auto rows = database.eval<std::int64_t, std::int64_t>(
- std::move(*statement));
- if(!rows)
- {
- return std::unexpected(std::move(rows.error()));
- }
- if(rows->size() != 1)
- {
- return std::unexpected(mw::runtimeError(
- "Test Game metadata is missing"));
- }
- const auto& [hp, attack] = rows->front();
- return FormFields{
- {"hp", std::to_string(hp)},
- {"attack", std::to_string(attack)},
- };
- }
-
- /// Insert fixture metadata for a common card ID.
- mw::E<void> insertMetadata(
- mw::SQLite& database,
- std::int64_t card_id,
- const GameCardMetadata& metadata) const override
- {
- const auto* values = dynamic_cast<const TestGameMetadata*>(&metadata);
- if(values == nullptr)
- {
- return std::unexpected(mw::runtimeError(
- "Test Game received metadata of the wrong type"));
- }
- auto statement = database.statementFromStr(
- "INSERT INTO test_cards (card_id, hp, attack) "
- "VALUES (?, ?, ?);");
- if(!statement)
- {
- return std::unexpected(std::move(statement.error()));
- }
- auto bind = statement->bind<std::int64_t,
- std::int64_t,
- std::int64_t>(
- card_id, values->hp, values->attack);
- if(!bind)
- {
- return std::unexpected(std::move(bind.error()));
- }
- return database.execute(std::move(*statement));
- }
-
- /// Replace fixture metadata for a common card ID.
- mw::E<void> updateMetadata(
- mw::SQLite& database,
- std::int64_t card_id,
- const GameCardMetadata& metadata) const override
- {
- const auto* values = dynamic_cast<const TestGameMetadata*>(&metadata);
- if(values == nullptr)
- {
- return std::unexpected(mw::runtimeError(
- "Test Game received metadata of the wrong type"));
- }
- auto statement = database.statementFromStr(
- "UPDATE test_cards SET hp = ?, attack = ? WHERE card_id = ?;");
- if(!statement)
- {
- return std::unexpected(std::move(statement.error()));
- }
- auto bind = statement->bind<std::int64_t,
- std::int64_t,
- std::int64_t>(
- values->hp, values->attack, card_id);
- if(!bind)
- {
- return std::unexpected(std::move(bind.error()));
- }
- return database.execute(std::move(*statement));
- }
-
- /// Return fixture metadata formatted for card display.
- mw::E<std::vector<DisplayField>> displayFields(
- mw::SQLite& database,
- std::int64_t card_id) const override
- {
- auto statement = database.statementFromStr(
- "SELECT hp, attack FROM test_cards WHERE card_id = ?;");
- if(!statement)
- {
- return std::unexpected(std::move(statement.error()));
- }
- auto bind = statement->bind<std::int64_t>(card_id);
- if(!bind)
- {
- return std::unexpected(std::move(bind.error()));
- }
- auto rows = database.eval<std::int64_t, std::int64_t>(
- std::move(*statement));
- if(!rows)
- {
- return std::unexpected(std::move(rows.error()));
- }
- if(rows->size() != 1)
- {
- return std::unexpected(mw::runtimeError(
- "Test Game metadata is missing"));
- }
- const auto& [hp, attack] = rows->front();
- return std::vector<DisplayField>{
- {"HP", std::to_string(hp)},
- {"Attack", std::to_string(attack)},
- };
- }
-};
diff --git a/tests/user_collection_test.cpp b/tests/user_collection_test.cpp
index b05ac26..bce76c9 100644
--- a/tests/user_collection_test.cpp
+++ b/tests/user_collection_test.cpp
@@ -13,7 +13,6 @@
#include "collection.h"
#include "data_sqlite.h"
-#include "game_registry.h"
#include "startup.h"
#include "user_service.h"
@@ -120,7 +119,7 @@ mw::E<std::int64_t> insertPoolCard(
"jpg",
1,
creator_user_id};
- auto id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+ auto id = (*transaction)->insertCard(card, {}, {});
if(!id)
{
return std::unexpected(std::move(id.error()));
@@ -138,8 +137,7 @@ mw::E<std::int64_t> insertPoolCard(
TEST(UserServiceTest, OnboardsAndPermanentlyPromotesPlayer)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto administrator = (*data_source)->getUserByEmailKey(
"admin@example.com");
@@ -164,8 +162,7 @@ TEST(UserServiceTest, OnboardsAndPermanentlyPromotesPlayer)
TEST(CollectionServiceTest, AccruesCapsAndPullsAtomically)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto administrator = (*data_source)->getUserByEmailKey(
"admin@example.com");
@@ -205,8 +202,7 @@ TEST(CollectionServiceTest, AccruesCapsAndPullsAtomically)
TEST(CollectionServiceTest, EmptyPoolPreservesRefreshedPull)
{
TemporaryDatabase database;
- GameRegistry games;
- auto data_source = prepareDataSource(database.path(), games);
+ auto data_source = prepareDataSource(database.path());
ASSERT_TRUE(data_source);
auto player_id = insertPlayer(**data_source, "empty@example.com");
ASSERT_TRUE(player_id);