# Card Collection
Official name TBD. Card Collection is a virtual card creation and collection
platform.
## Milestones
- Prototype: cards are stored on the server. Visitors can create, modify,
view, and organize cards through the web UI.
- MVP: users can register, have permission levels, and collect cards.
## Prototype assumptions
- The prototype runs in a trusted environment.
- The prototype has no users or authentication. Every visitor is treated as
an admin and may manage cards and series.
- No access tokens, upload rate limits, storage limits, backup facilities, or
recovery facilities are required for the prototype.
- Any browser that supports the complete required WebGL feature set is
supported. Browser-specific fallbacks are out of scope.
- Standard semantic controls and keyboard operation should be used. No
additional accessibility requirements are in scope yet.
- Normal application events and errors are logged with spdlog.
## Technology
- The backend is an HTTP application server written in C++23 using libmw.
- Structured data is stored in SQLite.
- The database schema is versioned with `PRAGMA user_version`.
- Every schema version change provides and automatically runs a migration
from the preceding version, such as `migrateSchema1To2()`.
- Descriptions are Markdown rendered on the server with MacroDown. The remote
repository is <https://git.xeno.darksair.org/macrodown.git>, and the local
development repository is `~/programs/macrodown`.
- The frontend is based on a fixed snapshot of `~/programs/foil`. Necessary
files are copied into this project rather than shared dynamically.
- External C++ dependencies should be pulled with `FetchContent`, including
ones with a local repository, which should still be pulled from the remote
repository. Basic system libraries such as libc, OpenSSL, and curl are
exceptions. Magick++ is also an explicit exception and is provided by the
operator's ImageMagick installation.
- Image validation and PNG-to-AVIF conversion use Magick++ from the local
system. The operator must install ImageMagick with Magick++ and JPEG, PNG,
WebP, and AVIF support.
- HTML is rendered on the server with Inja templates.
- Refer to `styling.md` for design directions.
## Games and series
A game is a top-level grouping representing a distinct set of cards. A card
may belong to at most one game. A card without a game is a loose card.
A game has:
- a display name;
- a globally unique, immutable short name containing only `[a-z0-9]`;
- a Markdown description; and
- hard-coded definitions and validation for its card-specific metadata.
Game definitions, including their database extensions and metadata rules,
are compiled into the backend. Existing games and metadata fields should not
change. If they must change, the change requires a database schema migration.
A series is a dynamic, second-level grouping belonging to exactly one game.
A card may belong to multiple series, but only series belonging to the card's
game. A loose card cannot belong to a series. Series names must be unique
within their game.
A series has:
- a display name;
- a Markdown description; and
- an internal integer database ID that is not part of its public identity.
Admins can create, modify, and delete series through the web UI. Every
visitor has this permission in the prototype.
## Cards and identifiers
Every card has:
- an internal SQLite primary key;
- a nullable game reference stored in SQLite;
- a card number stored in SQLite as an integer;
- an immutable public unique ID derived from its game and card number;
- a name;
- an optional short Markdown description;
- an optional long Markdown description;
- a nonnegative integer rarity, defaulting to zero;
- front artwork;
- an optional foil-control texture; and
- membership in zero or more valid series.
The formatted public ID is derived rather than stored redundantly.
The common card record stores a nullable game and an integer card number. For
a card in a game, the public ID is `${shortname}-${number}`. Numbers are
allocated automatically and sequentially within each game. Deleted numbers
are never reused. A persistent per-game sequence is incremented atomically so
concurrent creation cannot allocate the same number.
For a loose card, the stored game is null and the card number is a random
32-bit integer. Its public ID is the base36 encoding of that integer. If the
value collides with an existing loose-card ID, generation is retried.
The lowercase representation of a public ID is canonical. IDs are displayed
in uppercase. A card cannot move to another game after creation because that
would change its public ID.
## Card data model
Common card fields are stored in a common `cards` table. Each game may define
an extension table for its game-specific card fields. Series membership uses
the common card identity.
A game's additional metadata may use any value representable in SQLite. The
game's compiled C++ and SQL implementation defines field labels, validation,
requiredness, defaults, ordering, storage, and presentation. Changes to an
existing game's schema require the normal versioned migration process.
## Image inputs and storage formats
Artwork and foil-control textures accept JPEG, PNG, WebP, and AVIF. Neither
image requires an alpha channel; WebGL treats a missing alpha channel as fully
opaque.
PNG inputs are converted to AVIF using a quality setting from the
configuration file. JPEG, WebP, and AVIF inputs retain their source formats.
When an input is converted, the original is not retained. Images are assumed
to be nonanimated sRGB images.
The long side of an image must not exceed 2048 pixels. Oversized images are
rejected by both the browser and backend rather than resized. Artwork and
foil-control images do not need matching sizes or aspect ratios. The rendered
card is 5:7 width to height; WebGL stretches other input aspect ratios to fit.
The creation UI accepts local files and remote image URLs. For a remote URL,
the browser downloads the image and uploads it through the same path as a
local file. The remote server must permit the download through CORS. The same
format and size validation applies to both sources.
A card without a foil-control texture has no foil effect. The uniform foil
controls from `foil` are not retained.
## Card asset storage
Each card has a directory beneath a card-storage root configured by the
operator. The directory name is the card's derived public ID in lowercase.
It contains:
- `front-art.ext` for the artwork;
- `foil.ext` for an optional foil-control texture; and
- `thumb.ext` for the thumbnail.
Here, `ext` is the actual stored format's conventional extension.
Card assets are available at `/static-cards/<public-id>/<asset>`. Page assets
use the separate `/static/<relative path>` prefix. The backend may serve them
directly, or a reverse proxy may serve them from disk.
Creation and modification use temporary files so a failed operation leaves
the previous committed state unchanged. Temporary and orphaned files from a
failed operation are deleted. Database and filesystem state cannot be made a
single transaction, so request handling must tolerate missing or mismatched
assets. For example, a card with missing artwork still has a card view, with
an error message explaining the missing asset.
Deleting a card deletes its card directory.
## Markdown
All descriptions are rendered from Markdown by MacroDown on each request.
Rendered HTML is not stored. All MacroDown features and macros are enabled,
but raw HTML is disabled.
Before rendering, the backend examines MacroDown's syntax tree. Every link
and image URL must use HTTP or HTTPS; Markdown containing another URL scheme
is rejected. No additional sanitization is required for the prototype.
## Card creation and viewing
The creation and editing UI retains `foil`'s live WebGL card preview,
interactive tilt, local file input, and remote URL input. It does not retain
the uniform foil sliders, example reset, or shareable fragment links.
Saved cards have permanent application URLs. The card view uses the same
interactive rendering as the creation view, but replaces editing controls
with read-only card information.
All nonoptional fields are required. Rarity is prefilled with zero. When a
backend form submission fails, the backend returns an error page with the
appropriate HTTP status code.
## Thumbnail generation
The card index uses static thumbnails rather than one WebGL context per card.
For a card without a foil-control texture, its thumbnail is a reduced version
of its front artwork. The backend stretches the artwork to a 5:7 frame and
generates this thumbnail with Magick++. Thumbnails do not generally require
an alpha channel; only the rendered foil thumbnail uses transparency for edge
cropping.
For a foil card, the browser renders the default initial state of the WebGL
scene into a transparent offscreen surface. It reads the pixels, finds the
nontransparent bounds, crops to those bounds, and uploads the result with the
card. The server trusts this uploaded foil thumbnail. The thumbnail's long
side is configurable and defaults to 256 pixels. For a nonfoil thumbnail, the
long side is the height and the backend rounds `height * 5 / 7` to the nearest
integer for the width. The default output is therefore 183 by 256 pixels.
Thumbnail generation occurs when a card is created or when an edit changes a
rendering input. A metadata-only edit does not regenerate it. The browser
generates and uploads a thumbnail only when the resulting card has foil; the
backend generates it from the resulting artwork otherwise. If the applicable
thumbnail generation or upload fails, the entire creation or edit fails.
The source tree contains a default placeholder thumbnail. An operator may
replace it with a custom image for inconsistent or missing stored assets.
## Card index
The index lays cards out in a grid. It supports sorting by derived public ID
in ascending or descending natural order. Numeric components are compared
numerically, so `PKM-2` sorts before `PKM-10`. Ascending order is the default.
Filtering, searching, and pagination are not required for the prototype. An
index with no cards is displayed as an empty grid. Normal browser behavior is
sufficient while a request is in progress.
## Configuration
The server uses a TOML configuration file with these settings:
- base URL under which the website is hosted;
- listening address, which may be a Unix domain socket path;
- listening port, used only when the address is not a Unix domain socket;
- static-file root path;
- SQLite database path;
- card-storage root path;
- PNG-to-AVIF quality; and
- thumbnail long-side size, defaulting to 256 pixels.
libmw provides handling for the listening address and port.
## MVP requirements
The MVP adds passwordless user registration, a permission hierarchy, and a
daily card-pull collection loop. Creators have all player permissions, and the
single administrator has all creator permissions.
### Access and permissions
- Unregistered visitors see a welcome page containing application information
and optional static example cards. They cannot browse the live card index or
application card pages.
- Players have an index containing the distinct cards in their collection.
Each thumbnail displays the quantity owned at its bottom-right corner.
- A player may open the WebGL card view only for a card in their collection.
The card view also displays that card's current daily-pull probability.
- WebGL view access is an application-UI restriction rather than a digital
rights management boundary. The application authorizes card-view requests,
but the underlying static-card asset mount does not need per-user access
control.
- Creators may create cards and modify cards they created. They cannot delete
cards, set rarity during creation, or modify rarity later. A creator may use
the existing WebGL preview while creating or editing one of their cards even
when they do not own that card.
- Cards created by creators begin at rarity zero. Only the administrator may
change rarity, so a creator's card does not enter the daily pool until the
administrator assigns it a positive rarity.
- Only the administrator may create, modify, or delete series.
- The administrator may view and manage every card, series, and user account.
The administrator may promote a player to creator. Creator promotion is
permanent in the MVP; there is no demotion operation.
- Promotion does not modify the user's card collection.
### Registration and sessions
- Registration and login use the same passwordless flow. A visitor submits an
email address and receives an authentication link. An account row is created
only after the link is successfully confirmed, except for the administrator
bootstrap row described below.
- Authentication links contain a cryptographically random, single-use token
which expires after ten minutes. Opening the link performs no mutation. It
renders a confirmation page whose POST action consumes the token and logs in
or registers the user.
- A login session expires four weeks after the user's most recent successful
magic-link login. Ordinary authenticated requests do not extend its expiry.
Logging out revokes the current session. Revoking every session belonging to
an account is outside the MVP.
- Authentication email requests are limited to one per normalized email
address per minute, whether or not that address is already registered. The
implementation must also protect the Mailjet quota from request floods.
- Login email is sent through [Mailjet](https://www.mailjet.com)'s HTTP API.
A Mailjet failure returns an error to the requester; the MVP does not retain
an email outbox or retry failed delivery.
- Local development selects a file email transport through configuration. It
writes the generated authentication link to a configured file. There is no
general-purpose development mode and no `sendmail` transport.
- Login email addresses cannot be changed.
### Usernames and onboarding
- After first authentication, a user must choose a username before performing
any other authenticated operation, including pulling a card.
- Usernames are stored as UTF-8, normalized to Unicode NFC, limited to 32 bytes
after normalization, and compared for uniqueness with full Unicode case
folding.
- Usernames may contain Unicode characters, but cannot be empty, contain
Unicode control characters, or have leading or trailing whitespace.
- Users may change their username, subject to the same validation and
uniqueness rules.
### Administrator account
- The server has exactly one administrator. Its email address is required in
the configuration file and is immutable after the database is initialized.
- At startup, the server compares the configured normalized administrator
email with the value retained by the database. Startup fails if they differ.
- If the administrator email has no account row during initial startup, the
server creates an administrator row for it. The administrator completes the
same magic-link login and username onboarding flow as any other user.
- The administrator cannot be demoted or replaced through the web UI.
### Card collection
- A new account starts with one available card pull. At every 00:00 UTC date
boundary, one pull becomes available, up to a configurable accumulated-pull
limit whose default is three.
- Pull availability is calculated lazily when the user visits; no midnight
background task is required. Missed pulls beyond the configured limit are
lost. A user registering shortly before 00:00 UTC can use the initial pull
and receive another after the date boundary.
- The authenticated collection UI displays the number of available pulls. A
user clicks a button to consume one pull and receive one random card.
- When the daily pool is empty, the pull button is disabled and no available
pull is consumed.
- The pull uses the card pool as it exists when the user clicks. Every card
with rarity greater than zero participates, including loose cards and cards
from every compiled game and creator.
- Each individual eligible card has weight `2^(1 - rarity)`. Its displayed
probability is its weight divided by the sum of all eligible card weights at
the time the card view is rendered. Floating-point weights which are too
small to represent may round to zero in the MVP.
- A player may own multiple copies of the same card. Ownership is stored as a
quantity for each user and card rather than as individually identified
copies.
- Pulling a card atomically consumes one available pull and increments the
corresponding collection quantity. The MVP does not retain a separate daily
award history.
- Editing a card changes its presentation for every user who owns it. When the
administrator deletes a card, that card and all collection quantities for it
are deleted.