Gem Council is a production-oriented, browser-based multiplayer implementation of the original base-game rules. Version 1 adds verified accounts, secure email/password authentication, profiles and avatars, public/private rooms, and complete English and Simplified Chinese interfaces while preserving the authoritative boardgame.io rules engine and hidden-information model.
The interface uses text, CSS shapes, labels, and symbols. No official card artwork or scanned game assets are bundled.
- Complete two-to-four-player Splendor-style setup and play
- Verified email registration and purpose-separated password reset
- Argon2id password hashes and revocable 30-day server sessions
- Account usernames and 512×512 sanitized WebP avatars at every table
- Authenticated lobby, waiting room, HTTP API, and Socket.IO moves
- Public rooms listed to signed-in players and private invitation-only rooms
- One seat per internal account ID in each match, with safe credential rotation and reclaim after refresh or re-login
- English first-visit default and a persistent EN/中文 switch
- Responsive desktop/mobile board, explicit payments and mandatory resolutions
- SQLite persistence for accounts, challenges, sessions, and avatar metadata
- In-memory-only live games, with no match history, rankings, replays, or stats
- Deterministic unit, integration, Socket.IO, security, migration, and browser end-to-end tests
- Node.js 24 LTS (
>=24 <25;.nvmrcpins the verified release) - npm 12 or a compatible npm release for Node.js 24
Dependencies are pinned to exact versions. boardgame.io remains at 0.50.2.
npm ci
npm run config:local
npm run prisma:generate
npm run prisma:migrate:deploy
npm run devconfig:local adds missing variables and strong random secrets to the ignored
.env without printing or replacing existing values. It preserves an existing
Resend key and sender configuration.
The development command starts:
- React/Vite client:
http://localhost:5173 - Account, lobby, multiplayer, and Socket.IO server:
http://localhost:8000
Vite proxies /api, /games, /socket.io, and controlled avatar requests to
the server. Leave VITE_GAME_SERVER_URL blank for this same-origin development
path. Use npm run dev:client or npm run dev:server to run one process.
Development state is disposable and ignored under:
.local-data/
database/app.sqlite
avatars/
tmp/
- Choose Create account, enter an email, username, and password, then send a six-digit verification code.
- Enter the code within its configured lifetime. A successful registration verifies the email and creates a login session.
- Sign in later with email and password. Reset password sends a separate, purpose-bound code and revokes every older session when completed.
- Open Profile to change the username, upload/replace an avatar, or remove it. A generated initials/pattern avatar is used by default.
- Create a public or private two-to-four-seat room. Public rooms appear in the lobby; private rooms are reachable only by their invitation link.
- The server uses the authenticated account username and internal user ID. It ignores forged browser names and prevents one account from claiming two seats in the same room.
- Each tab keeps only its signed boardgame.io seat credential in
sessionStoragefor reconnect. The account session is a separate opaque, HTTP-only cookie and never enters web storage, game state, or action logs.
Usernames are 2–20 Unicode letters/numbers or underscores after NFKC normalization. Spaces, emoji, markup, invisible characters, and other punctuation are rejected. Matching and uniqueness are case-insensitive.
Passwords are 10–128 printable, non-space ASCII characters. They are never trimmed or otherwise transformed, and there are no artificial composition rules for uppercase, lowercase, digits, or symbols.
Production uses the provider-independent email interface through the Resend
adapter. The verified sender domain is auth.example.com; the default identity
is Gem Council <no-reply@auth.example.com>. EMAIL_FROM may use another valid
mailbox under that verified domain, and EMAIL_REPLY_TO is optional.
Set RESEND_API_KEY only in the ignored local environment or the deployment
secret manager. Verification emails contain the purpose, six-digit code,
expiry, and a warning to ignore an unsolicited message. They contain no
tracking or marketing content. Tests force the fake adapter and never perform
an outbound email request.
Registration-code requests deliberately return the same generic response for already registered and unregistered addresses. Password-reset requests do the same for existing and missing accounts. A provider failure removes the pending challenge rather than pretending a usable code was delivered.
- One JPEG, PNG, or WebP file, maximum 2 MB
- Content detected from decoded bytes; filename, extension, and browser MIME declaration are not trusted
- SVG, GIF/animation, appended polyglot data, malformed files, oversized pixel dimensions, and decompression-bomb inputs are rejected
- The image is auto-oriented, cropped to a fixed 512×512 square, stripped of metadata, and re-encoded as WebP
- Server-generated random storage keys stay beneath
AVATAR_STORAGE_DIR - Replacement writes the new file before updating metadata and removes the old file only after the database update succeeds
- Retrieval is through authenticated
/api/users/:id/avatar; the storage directory is never publicly served
Prisma uses SQLite through the current better-sqlite3 driver adapter. Committed migration history is the only production schema path:
npm run prisma:generate
npm run prisma:migrate:deployUse npm run prisma:migrate:dev only while intentionally developing a new
schema migration. Do not use schema push as a deployment substitute.
The persisted models are:
User: normalized unique email/username, Argon2id hash, verified/status dataEmailVerificationChallenge: purpose-bound HMAC code hash, expiry, resend time, durable failure count, and one-time consumptionSession: keyed opaque-token hash, CSRF secret, expiry/activity/revocationAvatarAsset: one-to-one user metadata and unique random storage key
SQLite unique indexes, foreign keys, cascade behavior, and data checks enforce integrity in addition to application validation.
For a complete start-to-finish deployment example, including Node.js 24, systemd, Nginx, HTTPS, backups, updates, and troubleshooting, see SERVER_SETUP.md.
Put all persistent paths on an external mounted volume and use absolute paths, for example:
APP_DATA_DIR=/srv/gem-council/data
DATABASE_URL=file:/srv/gem-council/data/database/app.sqlite
AVATAR_STORAGE_DIR=/srv/gem-council/data/avatars
UPLOAD_TEMP_DIR=/srv/gem-council/data/tmpNever place these paths in public/, dist/, or dist-server/. The server
validates broad, public, file, and symlink paths and confirms write access at
startup.
A safe deployment sequence is:
npm ci --include=dev
npm run prisma:generate
npm run prisma:migrate:deploy
npm run build
npm startServe dist/ as the static client and proxy /api, /games, and
/socket.io/ to the single Node process. Use exact HTTPS origins in
GAME_ALLOWED_ORIGINS; wildcard origins are incompatible with credentialed
cookies and sockets. Run the migration before switching application traffic.
Back up the SQLite database and avatar directory together. A database-only backup can leave avatar metadata without its file, while an avatar-only backup loses ownership metadata. Stop writes or use a consistent SQLite snapshot and coordinated filesystem snapshot.
See .env.example; it contains names and safe defaults only.
| Variable | Default / requirement | Purpose |
|---|---|---|
NODE_ENV |
development |
development, test, or production |
APP_BASE_URL |
http://localhost:5173 |
Exact browser origin and secure-cookie context |
VITE_GAME_SERVER_URL |
blank | Optional explicit lobby/Socket server URL |
GAME_SERVER_PORT |
8000 |
Single Node server port |
GAME_ALLOWED_ORIGINS |
app origin | Comma-separated exact HTTP(S) origins |
APP_DATA_DIR |
.local-data |
Parent runtime data directory |
DATABASE_URL |
local SQLite URL | file: URL without query/fragment |
AVATAR_STORAGE_DIR |
local avatars | Private avatar file directory |
UPLOAD_TEMP_DIR |
local tmp | Private temporary upload directory |
EMAIL_PROVIDER |
resend |
resend; fake is accepted only in tests |
RESEND_API_KEY |
required for Resend | Provider secret; never commit or log |
EMAIL_FROM |
verified default sender | Resend From identity |
EMAIL_REPLY_TO |
blank | Optional Reply-To address |
SESSION_SECRET |
strong random required | HMAC key for stored session hashes |
VERIFICATION_CODE_PEPPER |
strong random required | HMAC key for code hashes |
GAME_CREDENTIAL_SECRET |
strong random required | Seat-credential signature key |
SESSION_DURATION_DAYS |
30 |
Account-session lifetime |
VERIFICATION_CODE_TTL_MINUTES |
10 |
Code lifetime |
VERIFICATION_CODE_RESEND_SECONDS |
60 |
Durable resend cooldown |
VERIFICATION_CODE_MAX_ATTEMPTS |
5 |
Durable wrong-code limit |
AI_BOT_ENABLED |
true |
false disables bot seats and AI workers entirely (pure-human rollback) |
AI_BOT_WORKERS |
auto |
One worker per vCPU up to 8 (one core kept free above), independent of NODE_ENV; integer 0–16 overrides |
AI_BOT_QUEUE_LIMIT |
256 |
Max queued AI search jobs before fallback |
AI_BOT_HARD_MAX_MS |
80 |
Hard search compute budget per move |
AI_BOT_EXPERT_ENABLED |
true |
Expert difficulty uses the ds-search engine (PIMC-MCTS) |
AI_BOT_EXPERT_SIMS |
25k × workers |
Simulation budget per expert decision (adaptive: 8 workers = 200k; completes inside the 8s wall clock — a clean finish, not a timeout) |
AI_BOT_EXPERT_DETERMINIZATIONS |
3 × workers |
Seeded hidden-state determinizations per expert decision (adaptive) |
AI_BOT_EXPERT_MAX_MS |
8000 |
Expert search wall-clock budget per move |
AI_BOT_NEURAL_MODEL |
(ignored) | Legacy ONNX model path; the neural Expert agent is disabled |
DS_LEAF_MODE |
best2ply |
Leaf value mode: static, bestply, best2ply, best3ply, oneply |
DS_EXPLORE_C |
0.5 |
PUCT exploration weight (tuned with the best2ply leaves) |
DS_ROLLOUT_SCORE |
12 |
Either player at/above this score enables endgame rollouts |
Non-test secrets must contain at least 256 bits of random material. Rotate a secret deliberately: changing the session or game credential key invalidates the corresponding active credentials.
Room hosts can add Easy, Normal, or Hard bots to empty seats in 2–4 player games. Bot moves are computed server-side with a shared bounded worker pool, submitted through the same authoritative boardgame.io update path as human moves, and never trained or tuned inside the running site. All training commands below run offline on a development machine.
Design constraints:
- AI input comes only from the filtered
playerView: real deck order and opponents' blind reservation card IDs are never visible to a bot. - Each decision is bounded by CPU time, node count, and queue depth; timeouts and overload degrade to the cheap Normal policy so a game never stalls.
- Expert = ds-search-v1 (pure search + simulation, no neural networks).
Each decision determinizes the hidden deck several times, runs a PUCT MCTS
tree over both players' moves in every determinization, extends leaves with
exact 1–3-ply tuned lookahead (and fast greedy rollouts once the game nears
the 15-prestige race), and picks the root move with the best mean value
across determinizations. Determinizations are split across the worker pool,
so
AI_BOT_WORKERS=3on a 3-vCPU Railway service uses all cores for one 2-player bot decision within theAI_BOT_EXPERT_MAX_MS=8000budget. Expert decisions start computing during the presentation "thinking" delay, so a move takes ~max(delay, search) instead of the sum. The legacy neural Expert (ONNX PUCT) is fully disabled; its files remain in the repo but are never loaded at runtime. - The heuristic model is a single versioned JSON file:
ai_bot/models/heuristic-v1.json. At startup the server verifies its rules fingerprint against the deployed rule sources and logs a warning on mismatch; a missing/corrupt model falls back to built-in hand-tuned weights without breaking human play. These weights seed the search's leaf evaluation and priors.
/bot/?match=<room code> shows how the Expert bot thinks, move by move —
designed for humans: a live game snapshot (scores, tokens, bonuses, pending
steps), one card per bot decision with the chosen move in plain language, the
ranked candidate actions as visit/mean-value bars, and an auto-generated
"why this move" paragraph (simulations, determinizations, timeouts,
fallbacks). Players and spectators of the match can read it; others get 403.
The data is public-information only — deck order and blind reservations are
never exposed.
Rollback options (no database migration involved):
AI_BOT_ENABLED=falsedisables bot seats, controllers, and worker threads at the next restart.- Revert
ai_bot/models/heuristic-v1.jsonto the previous release's file. - Revert the whole application with the normal Git rollback procedure.
Observability: authenticated clients can read aggregate AI metrics at
GET /api/diagnostics/ai (decision counts, duration percentiles, timeouts,
fallbacks, no-legal/stale counters, peak queue depth, worker restarts, model
version and fingerprint status). Logs only ever contain a short hash of a
match ID, never full match state, hidden card IDs, tickets, or credentials.
The repo ships a Railway-ready container (Dockerfile +
railway-entrypoint.sh + nginx template). For the Expert search budget
(8 s per move on 8 vCPU / 8 GB RAM), provision the service with 8 vCPU /
8 GB. Worker count, simulation budget and determinizations auto-scale with
the plan (8 workers · 200k sims · 24 determinizations); everything is baked
in, and each value can still be overridden per service:
AI_BOT_WORKERS=8
AI_BOT_EXPERT_MAX_MS=8000
AI_BOT_EXPERT_SIMS=200000
AI_BOT_EXPERT_DETERMINIZATIONS=24With 8 workers, one 2-player expert decision splits its 24
determinizations across all cores and reaches the 200k-simulation budget in
~6 s (inside the 8 s wall-clock safety net, so completed searches show no
time-limit flag); the search runs during the bot's presentation delay, so
each bot move takes ~6 s wall time. Memory stays within the 8 GB limit
(~2–3 GB peak observed with 3 workers; ~8 workers scales linearly). The
worker count auto-scales with the plan's vCPUs (Dockerfile), so no manual
Railway variable is needed; override per service only if you want different
tuning. This branch does not perform the actual railway up; deploy it from
the Railway dashboard or CLI as usual.
- Passwords use salted, parameterized Argon2id hashes.
- Verification codes use a cryptographic generator and a challenge/email/ purpose-bound HMAC; plaintext codes exist only during delivery.
- Account sessions use random opaque tokens. Only keyed hashes are stored, and
cookies are
HttpOnly,SameSite=Lax,Secureunder HTTPS/production, and scoped to/. - Every authenticated mutation requires an exact allowed
Originand matching per-sessionX-CSRF-Token; protection does not rely on SameSite alone. - Structured schemas, strict body limits, controlled multipart handling, security headers, exact credentialed CORS, and centralized stable error codes prevent raw provider/SQL/stack output from reaching clients.
- In-memory limits cover code requests, verification, login, and avatar upload; SQLite also preserves verification failure counts.
- Seat credentials are independently signed and bound to account ID, session ID, match ID, player ID, expiry, and authoritative seat metadata. Revoked or expired sessions cannot submit moves.
- Deck order and blind reservations remain player-view filtered. Login tokens,
seat credentials, signing secrets, and decoded credential contents never
enter
Gor the public action log.
| Command | Purpose |
|---|---|
npm run config:local |
Safely add missing ignored local environment values |
npm run storage:prepare |
Validate and prepare private storage paths |
npm run dev |
Start client and server together |
npm run dev:client |
Start only Vite on port 5173 |
npm run dev:server |
Start only the Node server |
npm run data:generate |
Validate CSV inputs and regenerate typed game data |
npm run prisma:generate |
Generate the Prisma client |
npm run prisma:migrate:dev |
Develop a new migration intentionally |
npm run prisma:migrate:deploy |
Apply committed migrations |
npm run typecheck |
Strict client and server TypeScript checks |
npm test |
Full deterministic Vitest unit/integration/Socket suite |
npm run test:e2e |
Isolated two-browser-context Chrome journey |
npm run test:watch |
Vitest watch mode |
npm run build |
Generate data, typecheck, bundle client, compile server |
npm start |
Start the compiled production Node server |
npm run ai:smoke |
One-command offline AI smoke: small 2/3/4-player self-play + disable-flag check |
npm run ai:self-play |
Deterministic offline self-play runs |
npm run ai:benchmark |
Candidate vs frozen-baseline 2/3/4-player win-rate benchmark |
npm run ai:tune |
Offline coordinate-search weight tuning |
npm run ai:validate |
Holdout validation before model promotion |
npm run ai:load-test |
Concurrent match load test for the worker pool |
Each server-focused test suite creates its own temporary SQLite database and
data directories outside .local-data, applies the committed migration, uses
deterministic test secrets, and injects the fake email provider. The E2E suite
does the same and requires an installed Google Chrome channel.
prisma/ Schema and committed SQLite migration
scripts/ Data, environment, storage, and E2E helpers
src/client/ Auth state, i18n, lobby, profile, and game UI
src/game/ boardgame.io game integration and playerView
src/server/auth/ Registration, login, reset, and sessions
src/server/database/ Prisma adapter lifecycle
src/server/email/ Provider interface, Resend, fake, bilingual content
src/server/http/ Koa integration, routes, CORS/CSRF/error boundary
src/server/multiplayer/ In-memory store, secure lobby, seat credentials
src/server/profile/ Avatar decode/re-encode/storage pipeline
src/server/storage/ Private-path preparation and cleanup
src/server/validation/ Strict request and identity validation
src/shared/ Pure rules, generated data, and shared types
tests/ Unit and HTTP/Socket integration tests
e2e/ Isolated browser journey
The supplied card_data/*.csv files remain the source of truth. The
deterministic converter validates every row and writes
src/shared/data/generated-game-data.ts; do not edit that generated file.
Live boardgame.io state is deliberately held only in MemoryMatchStore.
Restarting the Node process removes every room and active game. This version
does not persist matches and includes no match history, replay storage,
ranking, ELO, leaderboard, match statistics, moderation workflow, AI player,
spectator workflow, or expansion content. Account/SQLite backups cannot restore
an interrupted live match.
See RULES_IMPLEMENTATION.md for the exact base-game rule mapping and documented digital adaptations.