BareGit
# Database-defined games and custom card fields

Status: implemented.

This document replaces the compiled-game architecture in
[design-0-prototype.md](design-0-prototype.md) and the game-registration
startup dependency retained by [design-1-mvp.md](design-1-mvp.md).
Authentication, ownership, rarity, pulls, images, and public card numbering
continue to follow those documents except where explicitly changed here.

## 1. Product scope and decisions

Administrators can create and edit games through the application. Each game
has a permanent short name, a display name, a description, and an ordered
list of custom card fields. Creating a game or field takes effect without
rebuilding or restarting the server.

The supported field types are integer, string, and choice. A choice is a
single selection from a list of strings, not a multiple-selection field.
There are no configurable validation rules: no required setting, minimum,
maximum, pattern, string length, default value, expression, or dependency.

The following semantics are design recommendations, rather than additional
user requirements:

- Every custom field is optional. Adding a field to a populated game does
  not require rewriting existing cards.
- Integer values must parse as signed 64-bit integers. Type correctness
  and representable range are storage requirements, not game rules.
- Strings are plain text, including multiline text. They are not Markdown,
  HTML, executable templates, or scripts.
- Nonempty choice values must exactly match an option in that field.
- Empty controls clear a value. There is no distinction between an empty
  string and an unset field in this version.
- Field types and keys are immutable. Labels and ordering can change.
- A used field or option cannot be deleted. An option's string is immutable;
  changing it means adding a new option and moving cards explicitly.
- Game short names remain immutable, because they are part of card URLs
  and asset directory names.

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.

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.

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

The current `GameDefinition` interface combines presentation metadata with
executable validation and SQLite extension-table hooks. `GameRegistry`
owns definitions for the life of the process. `main.cpp` registers Going
Home before `prepareDataSource()` migrates and reconciles the database.

`App` calls registry methods to build game selectors, metadata controls,
and card details. `CardService` accepts a `GameDefinition` and polymorphic
`GameCardMetadata`; `DataSourceSQLite` invokes game-owned persistence hooks.
`SeriesService` also checks the registry. These dependencies all change.

Replace them with value objects read through `DataSourceInterface`, one
generic field decoder, and common relational metadata tables. There will
be no SQL generated from field names and no new table per game.

Keep `cards.game_short_name`, `series.game_short_name`, and the existing
game sequence structure. They already represent stable game identity.
Add references to a database game row rather than changing public IDs or
introducing a second externally meaningful identifier.

The existing common card fields are not redefined as custom fields. For
example, custom fields do not control rarity or creator ownership.

## 3. Field values and decoding

### Integer

Use `std::int64_t` and a full-consumption decimal parser. Accept an optional
leading minus and one or more ASCII digits; leading zeros are allowed.
Reject plus signs, fractional notation, exponents, whitespace, trailing
characters, and overflow. Store the parsed number, so `007` displays as `7`.
Accept zero, negative numbers, and both signed 64-bit endpoints.

Use a text input with a decimal-integer hint rather than relying on browser
floating-point number conversion. Never pass integers through JavaScript
`Number`; it cannot preserve every signed 64-bit value. Submit strings and
parse on the server. An empty input means unset.

### String

Store submitted text unchanged after ordinary browser form decoding. Do
not trim, case-fold, normalize Unicode, or interpret markup. Whitespace-only
strings are values; a zero-length string is unset. Render with Inja escaping
and whitespace-preserving CSS. Use a textarea so multiline content can be
entered without inventing a fourth field type.

Normal request encoding and resource limits still apply. Keep the existing
1 MiB text-part limit in `multipart_reader.cpp`. This is an application-wide
transport bound, not a configurable field rule. Reject invalid UTF-8 or NUL
in text inputs rather than truncating them. Apply equivalent transport
handling to definition forms and card forms.

### Choice

An option is a nonempty UTF-8 string. Matching is exact, case-sensitive,
and does not normalize or trim text. `Home` and `home` are different values.
Empty strings are reserved for the unset control and cannot be options.

Use a select whose empty entry says `Not set`. The other option values are
the actual strings, escaped by Inja; decode form encoding once before
comparison. A tampered or obsolete value produces a field-specific 422
error. HTML select controls are assistance, not the enforcement boundary.

Every choice definition must have at least one option. Duplicate strings
within a field are rejected. Options have explicit display order; their
strings do not encode order or identity for other fields.

### Missing and unknown inputs

Card forms submit a complete replacement of custom values for their selected
game. A missing or empty known input becomes unset. Existing nonempty values
must be rendered back into an edit form to avoid unintended clearing.
Reject unknown field keys and duplicate parameters before constructing an
unordered map. Reject all custom values for loose cards and values belonging
to a different game. Do not silently ignore stale controls.

## 4. Data model

Keep models in `game.h` or a new `game_field.h`; remove the compiled interface
as part of this implementation. All public items need intent
comments and must follow the repository naming conventions.

| Type | Required members |
| --- | --- |
| `Game` | short_name, display_name, description, revision |
| `GameFieldType` | INTEGER, STRING, CHOICE |
| `GameField` | id, game_short_name, key, label, type, position, options |
| `GameChoice` | value, position |
| `GameDefinitionSnapshot` | game and ordered fields with options |
| `GameFieldValue` | field_id and variant of int64_t or string |
| `SubmittedGameFields` | unique key-to-text entries after duplicate checks |

Absence is represented by no `GameFieldValue` entry. A string variant can
represent STRING or CHOICE; the field definition determines which, and the
decoder checks it. Persistence also enforces the discriminator.

Read a complete definition as a consistent snapshot, under one data-source
lock. Do not query options later after releasing that lock. Return owning
values, not pointers into a mutable global registry. Avoid caching in this
version; this makes administration changes visible on the next request.

## 5. Fresh database schema

The following new tables use SQLite STRICT storage like the current schema.
SQL type names are fixed application code. User strings are bound values.

```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;

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;

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)
) STRICT;

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)
    )
) STRICT;

CREATE INDEX card_field_values_by_field ON card_field_values(field_id);
CREATE INDEX card_field_values_by_choice
    ON card_field_values(field_id, choice_value);
CREATE INDEX game_fields_by_game
    ON game_fields(game_short_name, position, id);
```

Update fresh creation SQL for `cards`, `series`, and `game_sequences` to add
`REFERENCES games(short_name) ON DELETE RESTRICT` to their existing
`game_short_name` columns. Keep nullable game identity only for loose cards.
Retain the existing common columns, indexes, checks, and membership triggers.
Create `games` before its referencing tables. No table rebuild is needed.

Also install these fixed invariant triggers:

1. Before insert or update of a value, reject unless the card's non-null
   game matches the field's game. Use `NOT EXISTS` with an equality join so
   a loose card or missing parent cannot pass through SQL NULL semantics.
2. Before insert or update of an option, reject unless its field is CHOICE.
3. Prevent updates of game short names and field game/key/type. Prevent
   changing an option's field/value pair. Only labels and positions mutate.
4. Preserve card game identity and number on update, including transitions
   between NULL and non-NULL game identities. Preserve series game identity.

The service enforces at least one option per CHOICE field at transaction
completion. Temporary intermediate states while adding a field are allowed
inside that transaction. Definition writes are only exposed by the service;
direct hand edits to the database are not a supported administration API.

Positions need not have a uniqueness constraint. Reordering a list should
not fail halfway through due to swapped positions. Services write dense
zero-based positions; reads sort by position and then stable ID (or option
value as a deterministic tie-breaker).

Foreign keys must be enabled on every connection, with child lookup indexes
as above. Composite keys provide membership and discriminator enforcement;
see [SQLite foreign keys](https://www.sqlite.org/foreignkeys.html).

## 6. Administration and definition evolution

Only administrators mutate games, fields, and options. Creators can select
games and edit custom values on cards they own under existing authorization.
Players only see the values on cards they may view. Access checks remain
server-side even if a link or control is hidden.

### Game operations

Create a game and its sequence row in the same transaction. Short names
must match `[a-z0-9]+`, consistent with existing registry/public-ID parsing.
Display names must be nonblank; duplicates are allowed because short names
identify games. Trim game names and field labels, but never card strings or
choice strings. Game descriptions use the existing Markdown renderer.

Editing changes only the display name and description. Delete is permitted
only if the game has no cards, no series, and its sequence has never issued
a number. Keeping a game after all its cards are deleted preserves the
never-reused public-ID guarantee. Delete unused fields/options explicitly
within the game-delete transaction, then the unused sequence and game.

### Field operations

Create a field with a key matching `[a-z][a-z0-9_]*`, nonblank label, type,
and its ordered options when applicable. Keys are unique within a game.
Use numeric IDs in administration routes and keys in card input names.
Keys never become SQL identifiers. Adding a field leaves existing cards
unset for that field.

Edit a field's label, and reorder fields. Do not expose a type/key editor.
Deleting an unused field deletes its options. Deleting a field with any
stored values returns 409 and shows the number of affected cards; there is
no force-delete checkbox. Users can clear card values individually first.

### Choice operations

Allow adding and reordering strings, and deleting strings not referenced
by any card. Deletion of a used string returns 409 with its usage count.
Never cascade choice deletion into card data. No rename operation is exposed
because it would change the displayed value on every referencing card.

Use a list of individually editable inputs on the creation form, not
comma-separated or newline-separated syntax. This avoids ambiguities for
commas, newlines, and whitespace inside option strings. Existing option
strings are read-only; add/remove controls express actual supported changes.

### Concurrency

`games.revision` is the aggregate definition revision. Increment it for any
game, field, option, or ordering mutation, including label changes. Every
administration POST includes the revision from its GET. A stale revision
returns 409 before changing anything.

Card forms also include the selected game's revision as `game_revision`.
Recheck it in the card transaction, alongside `cards.revision` for updates.
This prevents an old form from erasing newly introduced values or accepting
a removed option. A conflict asks the user to reload; retain text where
feasible and explain that browser-selected files may need reselecting.

Use the existing transaction lock and immediate transaction model. Re-read
authorization, definition, usage counts, and revisions while that lock is
held. Do not call ordinary data-source methods that acquire the same mutex
from inside a transaction. SQLite documents immediate writer acquisition
in [transaction behavior](https://www.sqlite.org/lang_transaction.html).

## 7. Service and persistence interfaces

Add `GameService` in `game_service.h/.cpp`, owning a reference to
`DataSourceInterface`. It supplies create/update/remove operations for games,
fields, choices, and ordering. Each accepts `actor_user_id`; mutations of an
existing game also accept `expected_revision`. Return `mw::E<T>` throughout.
Game creation returns the short name, and field creation returns a numeric ID.

Add read operations:

- `getGames()` returns name-sorted games, tie-broken by short name.
- `getGameDefinition(short_name)` returns an optional complete snapshot.
- `getCardFieldValues(card_id)` returns ordered values with definitions for
  display/edit under one lock, so labels and values cannot be mismatched.

Add transaction operations:

- `getGameDefinitionForUpdate(short_name)` and game usage queries.
- `insertGame`, `updateGame`, `deleteGame`, and sequence deletion.
- `insertGameField`, `updateGameFieldLabel`, `deleteGameField`.
- `insertGameChoice`, `deleteGameChoice`, and field/option position updates.
- `countFieldUsage`, `countChoiceUsage`, and conditional revision increment.
- Card insert/update overloads accepting decoded generic values in place
  of `GameDefinition*` and `GameCardMetadata*`.

All concrete data sources, fakes, and mocks implement the new contract.
Persistence methods receive already checked domain values but still have
database constraints protecting against programmer errors. Game mutations
do not flow through `App` directly to arbitrary SQL.

The generic decoder takes a snapshot and submitted text and returns typed
values or structured field errors. Keep it independent of HTTP and images.
Its errors contain field keys and messages; the handler maps them to form
controls and HTTP 422. Do not put user data into raw HTML error strings.

Update `CardService::createGameCard` and `updateGameCard` to accept a short
name, expected game revision, submitted fields, and series IDs. Keep image
processing outside the writer lock. Within the transaction:

1. Re-read and authorize the actor and current card if editing.
2. Read the current definition and compare its revision.
3. Decode values against that definition; reject unknown fields/options.
4. Check series ownership against that same game inside the transaction.
5. Allocate a game number for creation, or preserve identity for edits.
6. Write common card data, complete replacement custom values, and series
   membership together. Increment the card revision for updates.
7. Perform existing asset publication/replacement and commit using the
   existing compensation/reconciliation behavior. Clean staging on errors.

Do not erase old values until all submitted values are valid. A failure
must roll back card data, values, membership, and numbering together. Loose
cards use the same pipeline with an empty custom-value set and no game
revision. Metadata-only updates retain artwork and still advance card revision.

`SeriesService` drops its registry reference and verifies game existence
through transaction reads. It continues enforcing unique names per game and
the rule that series deletion does not delete cards.

## 8. Routes and form contracts

Routes are relative to the configured base URL and are named in `ROUTES`.
Use `urlFor`/`url_for` everywhere. Preserve `/admin/games` as the entry point.
The following route names are proposed additions; existing card routes stay.

| Name | Method | Path | Purpose |
| --- | --- | --- | --- |
| admin-games | GET | /admin/games | List games and create action |
| game-new | GET | /admin/games/new | Game creation form |
| games | POST | /admin/games | Create game and sequence |
| game-edit | GET | /admin/games/{short}/edit | Game and field editor |
| game-update | POST | /admin/games/{short} | Update name/description |
| game-delete | GET, POST | /admin/games/{short}/delete | Confirm/delete |
| game-field-new | GET | /admin/games/{short}/fields/new | Field form |
| game-fields | POST | /admin/games/{short}/fields | Create field |
| game-field-edit | GET | /admin/games/{short}/fields/{id}/edit | Edit label/options |
| game-field-update | POST | /admin/games/{short}/fields/{id} | Save label/options |
| game-field-delete | GET, POST | /admin/games/{short}/fields/{id}/delete | Confirm/delete |
| game-field-order | POST | /admin/games/{short}/field-order | Save field order |

Register literal `new` routes before parameter routes where needed. Check
that the numeric field belongs to the route's game; otherwise return 404.
Route helpers currently support multiple segments, so extend their tests.

Game creation posts `csrf_token`, `short_name`, `display_name`, `description`.
Existing-game mutations post `csrf_token` and `game_revision` as well.
Field creation posts `key`, `label`, `type`, and repeated `choice` parameters
in display order. Repeated `choice` is intentional; duplicate scalar keys
are invalid. Non-CHOICE fields must not submit choice parameters.

Field editing posts `label` and, for CHOICE fields, the complete ordered list
of `choice` strings. The service computes additions/removals, checks usage
and at least one remaining option, then saves everything atomically. An
apparent rename of a used option is therefore rejected as a used deletion.
Reorder fields with repeated `field_id` parameters containing every current
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 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
inactive game controls and replace the revision token with the selected
snapshot's revision. Loose-card selection disables all custom controls and
the game revision. Editing never changes a card's game.

No JSON API is introduced. Existing server-rendered forms and progressive
enhancement remain the public interface. Success returns 303 to a relevant
GET page, so browser refresh does not repeat a mutation.

## 9. User interface and styling

Read and follow [styling.md](../styling.md) during implementation. Reuse the
existing `clay-action`, `clay-action-secondary`, `form-field`, and form-submit
components rather than creating another visual treatment for management links.
Keep responsive full-width content gutters and floating navigation.

The Games page gains `Create game`. Each game has an `Edit game` action and
a summary of its fields. Remove compiled/deployment messaging. A game with
no custom fields says `Uses standard card fields` and still supports series.

The game editor presents identity/name/description and a field list. Show
type, label, key, and edit/delete controls. Type and key are visibly read-only
after creation. Add-field forms expose only the three types; choice inputs
appear only for CHOICE. Do not show a validation-rules section.

Provide labelled move-up/down controls for fields and choices; drag-and-drop
may enhance them but must not be the only reorder mechanism. Add/remove
choice rows can be implemented with a small local script; submit the full
definition through normal forms. Preserve keyboard focus after row changes.

Card forms render generic controls from trusted type mappings, never from
stored HTML or a stored input-type string. Hidden inactive controls are also
disabled. Associate errors with controls through `aria-describedby`; provide
an error summary on failed submissions. Read-only card views omit unset
custom values and show set values in field order using current labels.

On conflict, show an explanatory page with a link to reload the affected
editor. For multipart submissions, explain that files need reselecting if
the response cannot safely retain the staging upload. Do not claim values
were saved if the transaction rolled back.

## 10. Error handling and bounds

| Condition | Result |
| --- | --- |
| Missing session on a GET | Existing sign-in redirect |
| Player/creator accesses game administration | 403 |
| Invalid CSRF token | Existing 403 behavior |
| Missing game/field or mismatched route ownership | 404 |
| Duplicate scalar parameter or malformed request structure | 400 |
| Invalid integer, choice, field type, key, or empty label | 422 |
| Unknown custom field in a complete card submission | 422 |
| Stale game/card revision | 409 |
| Duplicate short name/key/option, used deletion | 409 |
| Application request size exceeded | 413 |
| Unexpected persistence failure | Logged server error, generic 500 |

Apply one aggregate text budget to both admin and card requests in addition
to the existing per-part limit: proposed initial value 8 MiB, excluding
image bytes governed by existing limits. Never silently truncate metadata.
This bounds resource use without adding business rules to strings/integers.
Implement consistent payload accounting for URL-encoded and multipart forms.

String/choice values and labels must be escaped when rendered. Bind all SQL
values. Do not log uploaded text or complete payloads for validation failures.
Integer failures should identify the field without echoing an arbitrary
unbounded submitted value.

## 11. Initialization and startup

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.

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 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 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 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 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.
5. Refactor card and series services to database snapshots. Preserve the
   current image staging, asset reconciliation, and card revision behavior.
6. Replace game registry calls in `App` and add generic controls and revision
   tokens. Exercise creation/editing with every field type and with zero fields.
7. Implement administrator routes and styled game/field/choice editors.
8. Remove obsolete compiled interfaces, fixtures, includes, and CMake source
   references. Remove tests whose only purpose was compiled-game migration.
9. Run targeted service, persistence, form-logic, and live HTTP tests, then
   the full existing suite. Perform browser checks on desktop and mobile.

## 13. Acceptance and test plan

### Decoding

- Integer: zero, negative, endpoints, leading zeros, empty, overflow,
  fractions, exponents, whitespace, and suffixes.
- String: empty, whitespace-only, multiline, Unicode, markup-looking text;
  prove storage preserves content and HTML output escapes it.
- Choice: each allowed string, unset, unknown value, case differences,
  whitespace differences, duplicate options, and options containing `&` or `+`.
- Reject duplicate fields, foreign game fields, and all metadata on loose
  cards. A string larger than a game designer might prefer still succeeds
  when it is within the application transport budget.

### Persistence and concurrency

- A choice value from another field fails even if its text is similar.
- A field from another game or wrong value discriminator fails insertion.
- A used option/field cannot be deleted; deleting a card cascades its values.
- A new field does not insert default rows for old cards.
- Stale definition edits and stale card forms return 409 without partial
  writes. Test an option deletion racing with a new card selecting it.
- Force a metadata failure during card creation/update and verify common
  data, values, membership, numbering, and staged assets remain consistent.
- Definition snapshots cannot combine a new field list with old options.

### Initialization

- 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.
- A deleted high-number card does not let the sequence decrease.

### HTTP and visual behavior

- Administrators create a game and all three field types, then create,
  edit, view, and delete a card and series using that game.
- Game/field edits are visible without a restart.
- Players and creators cannot mutate definitions; creator card permissions
  remain unchanged. Every mutation requires CSRF.
- 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.
- 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, while a
fresh database starts with no game definitions.