feat: productionize paid modes and PvP capture

This commit is contained in:
jwinterm
2026-07-21 20:41:27 -04:00
parent 8fb284f8ed
commit 89398335c7
230 changed files with 31755 additions and 2777 deletions
+3 -11
View File
@@ -62,17 +62,9 @@ block deadline, so the house edge is a simulation problem. `src/sim/` drives the
headless bots; `NETWORK_TUNING` + multi-level depth are the calibrated levers. See
[BALANCE_SIM.md](BALANCE_SIM.md) and [MULTI_LEVEL.md](MULTI_LEVEL.md).
## Regenerating the API reference (autodocs)
The code is heavily JSDoc-commented. To regenerate a Markdown API reference from those comments:
```bash
cd src && npm install # first time, pulls jsdoc-to-markdown (devDependency)
npm run docs:api # writes docs/API/*.md
```
Autodocs cover the **reference** layer (what each function/param is). The **why** — the seams and
flows in the pages above — is hand-authored and lives here in `docs/`. Keep both.
The code is heavily JSDoc-commented, while the hand-authored pages here explain subsystem seams and
operator intent. A docs-only generator is intentionally not part of the production dependency lock;
add one in a dedicated documentation toolchain if generated API pages become a release artifact.
## Conventions worth knowing
+79
View File
@@ -0,0 +1,79 @@
# Disposable clone migration gate
`npm run db:migrate:clone` applies the release's normal ordered migrations to an existing,
disposable PostgreSQL clone. It is the migration step for a restored production dump before an
immutable release is eligible for activation. It never creates, restores, drops, audits, or
activates a database.
## Hard target contract
The runner refuses to open a connection unless all of these conditions hold:
- `DB_HOST` is explicit loopback (`127.0.0.1`, `::1`, or `localhost`) or the standard local Unix
socket directory `/run/postgresql` or `/var/run/postgresql`;
- `DB_NAME` starts with `monerogue_`, `wownerogue_`, or `wowngeon_` and contains a complete
underscore-delimited `clone`, `restore`, or `canary` token;
- the name contains no live marker beginning with `prd`, `prod`, `production`, `live`, `mainnet`,
`master`, `primary`, `current`, or `active`;
- `CLONE_MIGRATION_EXPECT_DATABASE` exactly equals `DB_NAME`;
- `CLONE_MIGRATION_CONFIRM` exactly equals `MIGRATE_DISPOSABLE_CLONE:<DB_NAME>`;
- `DB_PORT` and `DB_USER` are explicit, `DATABASE_URL` is absent, and the process receives no
command-line arguments.
After connecting, the runner checks PostgreSQL's `current_database()` before any DDL. It then calls
the same `DatabaseManager.runMigrations()` used by application startup: lexical filename order,
the `schema_migrations` ledger, and one transaction per newly applied file. Finally, it requires the
ledger to equal the complete repository migration manifest, in order, with no missing or extra
entry. The manifest is discovered from the candidate release and must be contiguous from 001; the
current floor is migration 040. A successful current release reports:
```text
clone_migration_status=ok
database=monerogue_restore_20260721t220048z_28d513b29b36
migration_count=40
latest_migration=040_paid_match_entropy_precommit.sql
```
## Invocation
Create and restore the clone with the reviewed backup procedure first. Keep the target variables in
a protected operator-owned environment file and database credentials in a mode-0600 `PGPASSFILE`;
do not type a password, connection URL, or target as a command argument. For example, the inherited
non-secret target environment is:
```dotenv
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=monerogue_restore_20260721t220048z_28d513b29b36
DB_USER=monerogue
PGPASSFILE=/run/wowngeon/clone-migration.pgpass
CLONE_MIGRATION_EXPECT_DATABASE=monerogue_restore_20260721t220048z_28d513b29b36
CLONE_MIGRATION_CONFIRM=MIGRATE_DISPOSABLE_CLONE:monerogue_restore_20260721t220048z_28d513b29b36
```
Database names use lowercase letters, digits, and underscores only. Normalize a release ID into a
separate safe tag rather than copying a hyphenated artifact ID into `DB_NAME`.
Load that protected environment through the reviewed service/wrapper, change into the extracted
candidate's `src` directory, and run only:
```bash
npm run db:migrate:clone
```
Do not append `--` arguments. The runner never logs host, role, password, connection URL, or
`PGPASSFILE` contents. `DB_PASSWORD` is supported only for an already-protected inherited service
environment and is kept in memory; `PGPASSFILE` is preferred.
## Required follow-on gates and limits
Run `scripts/deploy/financial-audit.sql` and the restored-data constraint validation gate against
the same clone after migration. Compare the result with the pre-migration audit, then test the
candidate and predecessor against the migrated clone as required by the fleet runbook.
This runner does not prove that a database with a disposable-looking name is operationally
disposable, does not prevent another application process from connecting, and does not acquire an
activation-wide drain lock. The operator must create the clone from the intended fresh dump, keep
it outside public service configuration, and destroy it separately after the release decision. A
successful result authorizes only the next audit/test gate; it does not authorize a symlink switch,
wallet action, payout, or traffic restoration.
+103 -176
View File
@@ -20,7 +20,10 @@ This guide covers deploying Wownerogue to a production environment.
- [ ] Set secure database credentials
- [ ] Configure wallet-rpc with authentication
- [ ] Set `ADMIN_API_KEY` for admin endpoints
- [ ] Firewall: expose only 80/443
- [ ] Run `npm run preflight` with the final environment
- [ ] Keep `PAYOUTS_ENABLED=false` on real-money prestige instances
- [ ] Complete a stagenet payment and payout before enabling a payout instance
- [ ] Firewall: allow Node 3000/3001 only from the reverse proxy; keep wallet/daemon RPC loopback-only
- [ ] Protect `/admin.html` with basic auth
- [ ] Set up database backups (see [LOGS_AND_BACKUP.md](./LOGS_AND_BACKUP.md))
- [ ] Configure log rotation
@@ -30,71 +33,35 @@ This guide covers deploying Wownerogue to a production environment.
## Create Dedicated User
Create an isolated system user for security:
Create an isolated, non-login service identity. Release directories and the `current` link remain
root-owned; the service identity receives read/traverse access only and must never own its code:
```bash
# Create system user with no login shell and dedicated home
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --create-home wownerogue
# Create .ssh directory for deploy key
sudo mkdir -p /var/www/wownerogue/.ssh
sudo chmod 700 /var/www/wownerogue/.ssh
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --no-create-home wownerogue
sudo install -d -m 0750 -o root -g wownerogue /var/www/wownerogue
sudo install -d -m 0750 -o root -g wownerogue /var/www/wownerogue/releases
sudo install -d -m 0750 -o root -g wownerogue /etc/wownerogue
# Create log directory
sudo mkdir -p /var/log/wownerogue
sudo chown wownerogue:wownerogue /var/log/wownerogue
sudo install -d -m 0750 -o wownerogue -g wownerogue /var/log/wownerogue
```
---
## Setup Deploy Key and Clone Repository
## Stage reviewed artifacts, not a repository clone
Generate an SSH deploy key for the wownerogue user:
Build the runtime artifact from a clean, reviewed commit as described in
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). The production host needs no Git checkout, deploy key,
or write-capable source identity. The reviewed fleet
`playbooks/wowngeon-stage-candidate.yml` role transfers exactly one tarball to a newly selected
root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the independently
recorded literal digest. Follow the fleet Wowngeon runbook; the role is a one-shot custody boundary,
not deployment.
```bash
# Generate deploy key (as root, since user has no shell)
sudo ssh-keygen -t ed25519 -C "wownerogue-deploy" -f /var/www/wownerogue/.ssh/id_ed25519 -N ""
# Set correct ownership and permissions
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/id_ed25519*
sudo chmod 600 /var/www/wownerogue/.ssh/id_ed25519
sudo chmod 644 /var/www/wownerogue/.ssh/id_ed25519.pub
# Display the public key
sudo cat /var/www/wownerogue/.ssh/id_ed25519.pub
```
Add the deploy key to GitHub:
1. Go to your repository → **Settings** → **Deploy keys**
2. Click **Add deploy key**
3. Paste the public key from the command above
4. Leave "Allow write access" unchecked (read-only is sufficient)
5. Click **Add key**
Configure SSH for GitHub:
```bash
# Create SSH config for the wownerogue user
sudo tee /var/www/wownerogue/.ssh/config << 'EOF'
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes
StrictHostKeyChecking accept-new
EOF
sudo chown wownerogue:wownerogue /var/www/wownerogue/.ssh/config
sudo chmod 600 /var/www/wownerogue/.ssh/config
```
Clone the repository:
```bash
# Clone as the wownerogue user (replace YOUR_USERNAME with your GitHub username)
sudo -u wownerogue git clone git@github.com:YOUR_USERNAME/wownerogue.git /var/www/wownerogue/app
```
The current fleet repository deliberately has no executable extraction/activation helper yet.
Until that helper exists, the safe host boundary is blob-only staging: do not manually
extract beneath `releases/`, install dependencies there, change `current`, restart an application,
or run migrations merely because the blob has arrived.
---
@@ -112,77 +79,62 @@ node --version
npm --version
```
Install dependencies and audit for vulnerabilities:
```bash
cd /var/www/wownerogue/app/src
sudo -u wownerogue npm install
# Check for known vulnerabilities
sudo -u wownerogue npm audit
# Auto-fix vulnerabilities where possible
sudo -u wownerogue npm audit fix
```
The eventual reviewed extraction/activation helper installs exactly the locked production graph in its private,
writable staging directory with `npm ci --omit=dev --ignore-scripts --no-audit --no-fund`, verifies
it with `npm ls --omit=dev`, and seals the finished release root-owned and non-writable before it can
be selected. Never make a release tree writable by `wownerogue`. Online advisory lookup is a
separate explicitly authorized source-review operation; never run `npm audit fix` in staging or on
a live release.
---
## Set File Permissions
```bash
# Set ownership of application files
sudo chown -R wownerogue:wownerogue /var/www/wownerogue
# Restrict permissions (owner only, no world access)
sudo chmod 750 /var/www/wownerogue
sudo chmod 640 /var/www/wownerogue/app/src/.env
# Releases and selector are immutable to the service identity.
sudo chown root:wownerogue /var/www/wownerogue /var/www/wownerogue/releases
sudo chmod 0750 /var/www/wownerogue /var/www/wownerogue/releases
sudo chmod 0640 /etc/wownerogue/app.env
```
**Database permissions** - create a restricted PostgreSQL role:
Do not use recursive ownership changes on `/var/www/wownerogue`; they can hand the service process
write access to immutable code or the rollback selector. The reviewed extraction/activation helper must verify
every individual release's ownership and modes before activation.
**Database ownership** - the application currently runs its own migrations at startup, so its
database role must own the database/schema (or otherwise retain DDL rights). Create it this way:
```sql
-- Create role with minimal privileges
CREATE USER wownerogue WITH PASSWORD 'secure-password-here';
CREATE DATABASE wownerogue OWNER wownerogue;
-- Revoke dangerous permissions
REVOKE CREATE ON SCHEMA public FROM wownerogue;
-- Grant only what's needed
GRANT CONNECT ON DATABASE wownerogue TO wownerogue;
GRANT USAGE ON SCHEMA public TO wownerogue;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO wownerogue;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO wownerogue;
```
**Filesystem isolation** (optional, for extra hardening):
```bash
# Prevent the user from accessing other users' home directories
sudo chmod 700 /home/*
# Mount application directory with noexec for uploads (if applicable)
# Add to /etc/fstab if using separate partition
```
---
Do not revoke schema `CREATE` from this role while application startup owns migration execution;
that makes a fresh release fail partway through startup. A separate one-shot migrator role is the
follow-up path if runtime DDL privileges need to be removed.
## Database Setup
### Run Migrations
Database migrations run automatically on server start (via `databaseManager.runMigrations()`), but you can also run them manually:
Database migrations run automatically, in filename order, inside one transaction per file and are
recorded in `schema_migrations`. Do not apply migration files directly with `psql`: bypassing the
ledger makes startup attempt them again. Before a release, take a restricted PostgreSQL backup and
test the complete migration set against a restored copy of each production database using the
fail-closed [disposable clone migration gate](CLONE_MIGRATIONS.md).
```bash
cd /var/www/wownerogue/app/src
The migration ledger does not prove that rows predating a `NOT VALID` constraint are clean. Run
the read-only historical audit and the rollback-only native validation proof described in
[`FINANCIAL_CONSTRAINT_VALIDATION.md`](FINANCIAL_CONSTRAINT_VALIDATION.md) for each restored
database. The native `VALIDATE CONSTRAINT` gate deliberately refuses live database names.
# Source environment variables
source .env
# Run migrations manually if needed
psql -h $DB_HOST -U $DB_USER -d $DB_NAME -f migrations/001_initial_schema.sql
# ... repeat for each migration file in order
```
Install the reviewed `wowngeon-db-backup.sh`, `.service`, and `.timer` through the hash-pinned fleet
operations change—not from the runtime artifact—and create
`/var/backups/wowngeon/daily` as `postgres:postgres` mode `0700`, then enable the timer. Each run
writes custom-format dumps atomically, verifies their catalogs with `pg_restore --list`, and records
a SHA-256 sidecar. Configure retention/remote replication according to the operator's recovery
policy; the supplied job deliberately does not delete backups.
### Database Reset (Development Only)
@@ -195,41 +147,21 @@ npm run db:create # Recreate schema
## systemd Service
Save to `/etc/systemd/system/wownerogue.service`:
```ini
[Unit]
Description=Wownerogue Game Server
After=network.target postgresql.service
[Service]
Type=simple
User=wownerogue
Group=wownerogue
# Adjust paths if you cloned to a different location
WorkingDirectory=/var/www/wownerogue/app/src
EnvironmentFile=/var/www/wownerogue/app/src/.env
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/www/wownerogue /var/log/wownerogue
[Install]
WantedBy=multi-user.target
```
Install reviewed application/firewall templates through the hash-pinned fleet operations change;
the runtime artifact intentionally has no service units. Wallet-RPC replacement remains subject to
the explicit NO-GO gates in [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md). Application units read secrets from
`/etc/wownerogue/app.env` or `/etc/monerogue/app.env`, not from an immutable release directory.
They run `npm run preflight` before every start and use `/var/www/<instance>/current/src` so rollback
is an atomic symlink switch.
Enable and start:
```bash
# Create .env from example first!
sudo -u wownerogue cp /var/www/wownerogue/app/src/.env.example /var/www/wownerogue/app/src/.env
sudo vim /var/www/wownerogue/app/src/.env # Edit with your settings
# Install the correct profile outside the release and replace every placeholder.
sudo install -d -m 0750 -o root -g wownerogue /etc/wownerogue
# Use the separately reviewed source/fleet copy; environment examples are not in the runtime archive.
sudo install -m 0640 -o root -g wownerogue <reviewed-source>/src/.env.mainnet.example /etc/wownerogue/app.env
sudoedit /etc/wownerogue/app.env
# Enable and start service
sudo systemctl daemon-reload
@@ -243,7 +175,7 @@ The systemd hardening options:
- `ProtectSystem=strict` - mounts filesystem read-only except allowed paths
- `ProtectHome=yes` - hides /home, /root, /run/user
- `PrivateTmp=yes` - isolates /tmp
- `ReadWritePaths` - whitelists writable directories
- `ReadWritePaths` - whitelists the wallet state directory in wallet-RPC units
---
@@ -258,6 +190,10 @@ If using Nginx Proxy Manager:
- Forward Port: `3000`
- Enable "Websockets Support" toggle
Record the proxy's fixed source address in `/etc/wowngeon/firewall.env` as
`NPM_SOURCE_IPV4=<address>`. Direct access to 3000/3001 must be rejected; this also makes the
single-hop `TRUST_PROXY_HOPS=1` setting safe.
2. **SSL Tab**
- Request a new SSL certificate
- Enable "Force SSL"
@@ -281,13 +217,15 @@ location /socket.io/ {
## Updating the Deployment
```bash
cd /var/www/wownerogue/app
sudo -u wownerogue git pull
cd src && sudo -u wownerogue npm install
sudo -u wownerogue npm audit fix
sudo systemctl restart wownerogue
```
Run the full suite from the clean source commit, then build and verify the runtime artifact using
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Today, use the fleet blob-staging playbook and stop at
the independently hash-verified root-owned candidate blob because the reviewed extraction/activation
helper does not yet exist. When that helper implements the documented contract, it—not an application service identity or a
manual shell session—will build a new immutable `/var/www/<instance>/releases/<release-id>`, install
the locked graph, run the clone/preflight gates, seal ownership, and atomically select `current`.
Never run `git pull`, `npm install`, or `npm audit fix` inside the active release. Keep the previous
release and database backup until the new version has passed public health, WebSocket,
payment-intake, and (stagenet only) payout smoke tests.
---
@@ -299,10 +237,11 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por
```
/var/www/
├── wownerogue/ # Wownero instance (port 3000)
│ └── app/src/.env
└── monerogue/ # Monero instance (port 3001)
└── app/src/.env
├── wownerogue/ # releases/<id> + current symlink (port 3000)
└── monerogue/ # releases/<id> + current symlink (port 3001)
/etc/
├── wownerogue/app.env # root:wownerogue, 0640
└── monerogue/app.env # root:monerogue, 0640
```
### Configuration Differences
@@ -312,31 +251,15 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por
| `PORT` | 3000 | 3001 |
| `CRYPTO_TYPE` | WOW | XMR |
| `MONERO_NETWORK` | mainnet | stagenet |
| `DB_NAME` | wownerogue | monerogue_stagenet |
| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38070 |
| `DB_NAME` | wownerogue | monerogue |
| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38083 |
| `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 |
### Separate systemd Service
Create `/etc/systemd/system/monerogue.service`:
```ini
[Unit]
Description=Monerogue Game Server (Stagenet)
After=network.target postgresql.service
[Service]
Type=simple
User=monerogue
WorkingDirectory=/var/www/monerogue/app/src
EnvironmentFile=/var/www/monerogue/app/src/.env
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Install the hash-pinned fleet copy of `monerogue.service`; it is intentionally absent from the
runtime artifact. Do not hand-maintain a second, weaker unit. The reviewed template contains the
same preflight, graceful-stop, immutable-release path, and systemd sandbox as the mainnet service.
---
@@ -374,10 +297,13 @@ journalctl -u wownerogue -f # Follow logs
### Health Endpoint
```bash
curl http://localhost:3000/health
curl http://localhost:3000/health/live
curl --fail http://localhost:3000/health/ready
```
Returns server uptime, memory usage, wallet status, and balance.
`/health/live` checks the process. `/health/ready` returns 503 until PostgreSQL, the chain daemon,
and (for paid instances) wallet RPC are ready. Public probes do not expose wallet balances or RPC
endpoints; those remain in the authenticated admin dashboard.
### Admin Dashboard
@@ -397,8 +323,9 @@ Access `/admin.html` with your `ADMIN_API_KEY` for:
# Check logs for errors
journalctl -u wownerogue -n 50
# Verify environment file
sudo -u wownerogue cat /var/www/wownerogue/app/src/.env
# Validate the protected environment without printing it
cd /var/www/wownerogue/current/src
sudo -u wownerogue bash -c 'set -a; . /etc/wownerogue/app.env; set +a; npm run preflight'
# Test database connection
psql -h localhost -U wownerogue -d wownerogue -c "SELECT 1;"
@@ -408,17 +335,17 @@ psql -h localhost -U wownerogue -d wownerogue -c "SELECT 1;"
```bash
# Test wallet RPC directly
curl -u user:password -X POST http://127.0.0.1:34570/json_rpc \
curl --digest -u user:password -X POST http://127.0.0.1:34570/json_rpc \
-d '{"jsonrpc":"2.0","id":"0","method":"get_balance"}' \
-H 'Content-Type: application/json'
```
### Database Migrations
If migrations fail, check the `migrations` table:
If migrations fail, check the `schema_migrations` ledger:
```sql
SELECT * FROM migrations ORDER BY id;
SELECT filename, applied_at FROM schema_migrations ORDER BY filename;
```
Migrations are tracked by name to prevent duplicate execution.
+102 -65
View File
@@ -5,38 +5,40 @@ Two independent instances of the same secured codebase, behind Nginx Proxy Manag
| Instance | Domain | Port | Network | Money | Payouts | Leaderboards |
|----------|--------|------|---------|-------|---------|--------------|
| **wownerogue** (existing) | play.wowne.ro | 3000 | Wownero **mainnet** | real WOW | **OFF** | Pleb + Hall of Champions |
| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet** | fake XMR | **ON** | Pleb + Hall of Champions |
| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet** | fake XMR | **ON (target; wallet-gated)** | Pleb + Hall of Champions |
Stagenet payouts are a technical test surface, not authorization to offer real-money wagering.
`ALLOW_MAINNET_PAYOUTS=true` is only an engineering interlock; obtain jurisdiction-specific legal,
age/access-control, responsible-play, and operational review before attaching redeemable value.
NPM already forwards both domains to these ports + handles TLS — no reverse-proxy work here.
> ⚠️ Nothing below has been executed. Run only after review. Step 0 is the urgent one
> (the live mainnet instance still runs pre-fix code with the underpayment exploit + payouts).
Server facts (verified): root+sudo; `monerod --stagenet` RPC on `127.0.0.1:38081`;
`monero-wallet-rpc` at `/usr/bin`; stagenet wallet `…/monero-…-v0.18.4.4/test(.keys)`;
Postgres has `wownerogue`,`wowrace`; ports `3001` and `38083` free; code at
`/var/www/wownerogue/app` on branch `main` (commit 82fdcc0).
> ⚠️ This is an operator runbook, not a record of actions already executed. Deploy only a reviewed,
> tagged release after the full test suite and both preflights pass.
---
## Step 0 — Get the secured branch onto the server
## Step 0 — Stage an immutable reviewed release
```bash
# Local: push the remediation branch (or merge to main first)
git push origin security/phase-0-critical-fixes
Build and verify one runtime artifact using [RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md), then
transfer only that blob into a new root-owned mode-0700 candidate inbox. Compare the host's SHA-256
with the independently recorded literal digest; the transferred sidecar is not an independent
provenance source.
# Server (mainnet instance):
sudo -u wownerogue git -C /var/www/wownerogue/app fetch origin
sudo -u wownerogue git -C /var/www/wownerogue/app checkout security/phase-0-critical-fixes
cd /var/www/wownerogue/app/src && sudo -u wownerogue npm ci
# Migrations run automatically on boot; or apply manually with the app's runner.
```
There is currently no reviewed fleet extraction/activation helper. Blob-only staging is allowed;
manual extraction under `/var/www/<instance>/releases`, dependency installation, clone migration,
preflight, service restart, and `current` changes remain blocked until that helper implements the
complete ownership/path/hash/rollback contract. Never make a release directory writable by either
application service identity.
---
## Step 1 — Reconfigure the MAINNET instance: free-to-play, NO payouts
Edit `/var/www/wownerogue/app/src/.env` — change these keys (keep everything else):
Install the reviewed source profile `src/.env.mainnet.example` as `/etc/wownerogue/app.env` (mode
0640, `root:wownerogue`), replace every placeholder, and review these money gates. Environment
examples are intentionally not present in the runtime artifact; provision this file through the
hash-pinned fleet/source change, never from the application release directory.
```ini
FREE_PLAY_ENABLED=true # offer free play as a choice (Pleb board)
@@ -45,11 +47,16 @@ DIRECT_PAYOUTS_ENABLED=false
CREDITS_PAYOUTS_ENABLED=false
DIRECT_REQUIRES_ADDRESS=false # no payout = no payout address needed
CREDITS_REQUIRES_ADDRESS=false
TRUST_PROXY=true # we are behind NPM; read real client IP from X-Forwarded-For
TRUST_PROXY=true
TRUST_PROXY_HOPS=1 # exactly one NPM hop
```
Players still buy credits / pay entry (→ Hall of Champions prestige); free players → Pleb
board. No payouts = not gambling.
board. This profile sends no winnings, but legal classification still depends on the product,
jurisdiction, and access controls; obtain counsel before treating that as a legal conclusion.
The following are post-activation verification commands, not authorization to restart the live
service while the wallet/preflight and fleet activation gates remain open:
```bash
sudo systemctl restart wownerogue.service
@@ -57,46 +64,60 @@ journalctl -u wownerogue.service -n 50 --no-pager # verify: "Payment system: E
curl -s localhost:3000/api/game-modes | jq '{freePlayEnabled, PAID_SINGLE:.PAID_SINGLE.payoutMultiplier}'
```
**MAINNET WALLET NO-GO:** this application release does not authorize changing, copying, stopping,
or replacing the Wownero wallet, its key file, password, unit, or RPC authentication. Keep payouts
off. A later wallet change requires independently recoverable keyfile/password and mnemonic custody,
an off-host recovery proof, an isolated rehearsal, exact address/balance verification, and a
separately reviewed maintenance/rollback plan. Root-level wallet templates are operations source,
not files in the runtime artifact and not approval to install them.
---
## Step 2 — Stand up the MONERO STAGENET wallet-rpc
## Step 2 — MONERO STAGENET wallet boundary: NO-GO
Do **not** install a replacement unit, create wallet identities/directories, copy wallet or key
files, capture a password, provision RPC credentials, stop/restart the wallet, or change the live
application endpoint as part of this release. The current wallet, unit, and application dependency
remain unchanged.
The only retained design is
[`README-monero-stagenet-parallel-candidate.md`](../scripts/deploy/README-monero-stagenet-parallel-candidate.md).
It currently approves only a disposable synthetic fixture and, after independent review, a fixed
read-only inventory preflight. Even its manual candidate service is not approved for installation.
Before any host mutation, its complete crash/failure-injection gates, stopped-copy restart proof,
offline address match, keyfile/password restore, mnemonic/off-host custody, and isolated unfunded
rehearsal must all pass against an immutable reviewed hash.
Until a separate wallet promotion is approved, a stagenet application candidate must use an
isolated database with payments, payouts, and match money disabled. Paid intake and the payout
canary remain blocked; do not bypass application preflight to make them start.
## Step 3 — Create the monerogue identity, immutable layout, and DB
```bash
sudo cp scripts/deploy/monero-wallet-rpc.service /etc/systemd/system/
sudoedit /etc/systemd/system/monero-wallet-rpc.service # set REPLACE_WITH_WALLET_PASSWORD
sudo systemctl daemon-reload
sudo systemctl enable --now monero-wallet-rpc.service
sudo systemctl status monero-wallet-rpc.service --no-pager
# sanity: wallet answers + is the stagenet wallet that holds the coins
curl -s http://127.0.0.1:38083/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"get_balance"}' -H 'Content-Type: application/json'
sudo useradd --system --home /var/www/monerogue --no-create-home --shell /usr/sbin/nologin monerogue
sudo install -d -m 0750 -o root -g monerogue /var/www/monerogue
sudo install -d -m 0750 -o root -g monerogue /var/www/monerogue/releases
sudo install -d -m 0750 -o root -g monerogue /etc/monerogue
sudo -u postgres createuser --pwprompt monerogue
sudo -u postgres createdb --owner=monerogue --template=template0 monerogue
```
> Needs the wallet password (not in the repo). Confirm `…/v0.18.4.4/test` is the stagenet
> wallet with the coins, or point `--wallet-file` at the right one.
## Step 3 — Create the monerogue user, code checkout, and DB
```bash
sudo useradd --system --home /var/www/monerogue --shell /usr/sbin/nologin monerogue
sudo mkdir -p /var/www/monerogue && sudo chown monerogue:monerogue /var/www/monerogue
sudo -u monerogue git clone git@github.com:jwinterm/wownerogue.git /var/www/monerogue/app
sudo -u monerogue git -C /var/www/monerogue/app checkout security/phase-0-critical-fixes
cd /var/www/monerogue/app/src && sudo -u monerogue npm ci
sudo -u postgres createdb monerogue
sudo -u postgres psql -c "CREATE USER monerogue WITH PASSWORD 'CHANGE_ME';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE monerogue TO monerogue;"
```
The service identity owns its database role, not its application files. Do not clone source onto
the production host or recursively `chown` `/var/www/monerogue` to the service user.
## Step 4 — The monerogue `.env`
Write `/var/www/monerogue/app/src/.env` (owned by `monerogue`, `chmod 600`). Stagenet/fake
money, so payouts are fine. Amounts are XMR atomic units (1 XMR = 1e12):
This is the eventual paid stagenet profile, not permission to activate it while Step 2 is NO-GO.
Write `/etc/monerogue/app.env` as `root:monerogue` mode `0640`. Amounts are XMR atomic units
(1 XMR = 1e12):
```ini
NODE_ENV=production
PORT=3001
TRUST_PROXY=true
TRUST_PROXY_HOPS=1
CRYPTO_TYPE=XMR
MONERO_NETWORK=stagenet
HOSTED_BY=https://monerogue.app
@@ -127,19 +148,19 @@ CREDITS_PACKAGES=[{"id":"small","credits":10,"price":"90000000000","bonus":0},{"
ALLOW_MIXED_MODE=true
PREFER_CREDITS_FIRST=true
# --- payouts --- (bankroll: ~0.3797 sXMR in the "test" wallet)
# --- payouts --- (verify the unlocked stagenet bankroll before every rollout)
PAYOUTS_ENABLED=true
PAYOUT_MIN_AMOUNT=1000000000 # 0.001 XMR
PAYOUT_MAX_PER_GAME=50000000000 # 0.05 XMR safety cap (real max is 3x*0.01 = 0.03)
PAYOUT_BATCH_INTERVAL=300
PAYOUT_MAX_RETRIES=3
LOW_BALANCE_THRESHOLD=50000000000 # alert/halt when unlocked balance drops below 0.05 XMR
BALANCE_WARN=100000000000 # warn below 0.10 XMR unlocked
BALANCE_CRITICAL=50000000000 # stop accepting new payout liability below 0.05 XMR
# Tip: lower DIRECT_GAME_PRICE/CREDITS_PAYOUT_BASE to 0.001 XMR (1000000000) for many more
# demo plays per the small bankroll; refill from a stagenet faucet as needed.
# --- wallet RPC (monero stagenet) ---
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083
# wallet-rpc started with --disable-rpc-login, so no WALLET_RPC_USER/PASSWORD
# --- daemon RPC (monerod stagenet) ---
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081
@@ -160,33 +181,49 @@ ADMIN_API_KEY=GENERATE_WITH_openssl_rand_hex_32
SMIRK_ENABLED=false
```
The application unit separately requires `/etc/monerogue/wallet-rpc.env`, owned
`root:monerogue` mode `0640`, containing only `WALLET_RPC_USER` and `WALLET_RPC_PASSWORD`. Provision
it through the approved secrets workflow only after the wallet gate clears. Never place either
password in a command argument, unit `ExecStart`, shell history, or this runbook.
Production startup proves that wallet RPC actually enforces Digest authentication: an intentional
unauthenticated, read-only probe must receive a usable `401` Digest challenge before the
authenticated identity probe is accepted. Merely setting application credentials is insufficient;
a wallet RPC that still accepts the unauthenticated request makes preflight/readiness fail closed.
```bash
sudo chmod 600 /var/www/monerogue/app/src/.env
sudo chown monerogue:monerogue /var/www/monerogue/app/src/.env
sudo chmod 0640 /etc/monerogue/app.env
sudo chown root:monerogue /etc/monerogue/app.env
cd /var/www/monerogue/releases/<reviewed-release-id>/src
sudo -u monerogue bash -c 'set -a; . /etc/monerogue/app.env; . /etc/monerogue/wallet-rpc.env; set +a; npm run preflight'
```
## Step 5 — Install + start the monerogue instance
```bash
sudo cp scripts/deploy/monerogue.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now monerogue.service
journalctl -u monerogue.service -n 60 --no-pager # expect: listening on :3001, payments ENABLED
curl -s localhost:3001/health | jq '{network:.network, wallet:.wallet.status, mode:.gameMode}'
```
The runtime artifact contains no service units. Install or update `monerogue.service` only through
the separately reviewed, hash-pinned `~/src/such-fleet` change after the wallet and application
gates pass. Do not copy a unit from the application release or start this paid profile while Step 2
is NO-GO.
Install the hash-pinned fleet copies of `wowngeon-firewall.sh` and
`wowngeon-firewall.service`, then set the fixed proxy source as `NPM_SOURCE_IPV4=<address>` in
`/etc/wowngeon/firewall.env`. Verify direct access to ports 3000/3001 is rejected while both
domains, websocket upgrades, and local readiness probes still work.
## Step 6 — Verify end to end
- `monerogue.app` loads; `play.wowne.ro` still healthy.
- Stagenet: buy credits / pay entry with a stagenet wallet → game starts → win → payout
arrives (this is the real `wownero-wallet-rpc`/`monero-wallet-rpc` integration test that
TODO.md has always wanted — now on fake money).
- Both boards populate: free game → Pleb, paid game → Hall of Champions, on each instance.
- After a separate wallet GO and isolated canary: buy credits / pay entry with a stagenet wallet →
game starts → win → payout arrives. This check is blocked while Step 2 is NO-GO.
- After the corresponding gates clear, both boards populate: free game → Pleb, paid game → Hall of
Champions, on each instance.
- Mainnet: confirm a winning paid game does NOT send a payout (no crypto out).
---
## Rollback
- `sudo systemctl stop monerogue monero-wallet-rpc` (stagenet instance is independent; stopping it can't affect mainnet).
- Mainnet: `git checkout main && systemctl restart wownerogue` to revert the reconfig.
- Stop only the affected instance, atomically repoint `/var/www/<instance>/current` to the previous
immutable release, and restart it. Keep the pre-deploy database backup for an operator-reviewed
restore if a data rollback is required.
- Never overwrite either legacy dirty checkout; preserve it until the new release has passed its soak.
+69
View File
@@ -0,0 +1,69 @@
# Historical financial constraint release gate
Several migrations add `CHECK ... NOT VALID`. PostgreSQL enforces those constraints for new and
changed rows, but it does not scan rows that existed before the constraint was added. A migration
even a current migration ledger therefore does not, by itself, prove that all historical financial rows obey
the current constraints.
Two repository-owned gates close that evidence gap without changing a live database.
## 1. Read-only historical audit
`scripts/deploy/financial-constraint-audit.sql` checks the exact connected database name, verifies
the complete 18-constraint catalog inventory, and reports a named count for every historical
predicate. It uses one repeatable-read, read-only transaction and rolls it back. Connection and
credential handling remain operator-specific; do not put a database password in command history.
```bash
psql -X -v ON_ERROR_STOP=1 \
--set=expected_database=EXACT_DATABASE_NAME \
--file=scripts/deploy/financial-constraint-audit.sql
```
Exit status `0` means all required constraints exist and every old row satisfies them. Any missing
constraint or nonzero category exits `3`. Reconcile violations with a separately reviewed,
backup-backed repair; this audit intentionally has no repair mode.
This check may be run read-only against production and should also run against every restored
release candidate database. It reports how many constraints remain catalog-marked unvalidated;
that count is informational because the second gate proves PostgreSQL's validator itself.
## 2. Explicit validation on a disposable restore
`scripts/deploy/financial-constraint-validate-restore.sql` includes the read-only audit, then issues
an explicit `ALTER TABLE ... VALIDATE CONSTRAINT` for all 18 constraints inside a transaction. It
checks `pg_constraint.convalidated` and rolls the entire transaction back, so no catalog flag is
durably changed.
`VALIDATE CONSTRAINT` still scans tables, takes locks, and transiently updates PostgreSQL catalogs.
For that reason this script fails before `BEGIN` unless all three conditions hold:
- the connected name exactly equals `expected_database`;
- the database name contains `restore` or `scratch`;
- the exact disposable-restore confirmation is supplied.
Run it only against a freshly restored, isolated database with no application traffic:
```bash
psql -X -v ON_ERROR_STOP=1 \
--set=expected_database=monerogue_release_restore \
--set=confirm_disposable=I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE \
--file=scripts/deploy/financial-constraint-validate-restore.sql
```
A pass proves both the explicit historical predicates and PostgreSQL's native validation path on
that restored data. It does not mark live constraints validated. A later durable live
`VALIDATE CONSTRAINT` operation requires its own reviewed maintenance plan, backup/restore proof,
lock budget, and rollback decision; do not fold that mutation into application startup.
## Static coverage
The focused test inventories every migration occurrence of `NOT VALID` and fails if either gate
omits a constraint:
```bash
npm --prefix src test -- --runTestsByPath ../test/financialConstraintValidation.test.js
```
Adding another `NOT VALID` constraint requires adding its exact historical predicate, its explicit
restore validation statement, and its table/name entry to both gates.
+42
View File
@@ -0,0 +1,42 @@
# Financial reconciliation
The admin dashboard has separate **Payouts** and **Refunds** views. Start with the
**Needs Attention** filter and compare the row, application logs, and wallet history.
The overview and each list show `processing` and `needs_review` counts; these are
liabilities, not failed transfers that can safely be resent.
## State rules
- A payout in `processing` or `needs_review` may already have broadcast. Never use the
retry action for it.
- The payout retry action is available only for a hashless `failed` or
`permanently_failed` row. Use it only after proving the recorded error happened before
broadcast and confirming that wallet history contains no matching transfer. The UI
sends the API's required explicit confirmation.
- A refund in `requested` has not yet been claimed by the refund worker. A refund in
`processing` may have broadcast. A refund in `needs_review` is quarantined. There is no
dashboard resend action for any refund state.
- `recorded` means entitlements/payment state was reversed without an on-chain transfer;
`completed` requires transaction-hash evidence.
## Reconciliation checklist
1. Preserve the database row, service logs, wallet files, and current backups before
changing anything.
2. Match currency/network, destination, exact atomic amount, creation/processing time,
transaction hash (if present), and wallet outgoing-transfer history.
3. If a matching wallet transfer exists, record its hash and confirmation evidence in an
audited remediation. Do not create a replacement transfer.
4. If no transfer appears, treat that as inconclusive until the wallet is synchronized and
the relevant time window and mempool are checked. Escalate ambiguous cases instead of
retrying.
5. Re-run the read-only financial audit after any reviewed database remediation and retain
the before/after evidence with the incident record.
Periodic alerts use the same aggregate states. `FINANCIAL_REVIEW_STALE_MS` controls when a
payout `processing` row or refund `requested`/`processing` row is considered stale; the
default is 15 minutes. Alerts are read-only and never move or retry a transfer.
There is intentionally no generic "mark resolved" endpoint. A reviewed state transition
needs a case-specific, audited remediation because the correct result depends on wallet
evidence and whether value was actually transferred.
+99 -39
View File
@@ -21,21 +21,32 @@ starts.
## Lifecycle: a race every block
The existing solo `queueHandler` already reacts to new crypto blocks. `MatchScheduler` subscribes to
the same block event and drains per-economy queues:
the same block-count event. Free and value-bearing queues intentionally have different starts:
- If a queue has ≥2 players at block H, all queued players are placed into a new `MatchRoom` and the
race starts immediately.
- If a queue has 1 player, they carry over to block H+1 and may leave at any time.
- A free queue with ≥2 players drains into a `MatchRoom` immediately on the current block event.
- A paid queue with ≥2 players at canonical header H atomically creates a `pending` match and links
the exact FIFO queue rows and entrants. That transaction records a deterministic entrant-freeze
commitment and fixes canonical header H+the configured entropy delay as its target; it does not
read a block hash or create a playable seed. A fresh post-commit count is persisted while the
target is still in the future.
- Only a later, sufficiently confirmed block-count event can resolve that pending match. The
scheduler twice requests the canonical hash for the exact target, verifies the frozen IDs again,
derives the seed, and changes the match from `pending` to `starting` before collection/countdown.
It immediately checks the hash again. Another height and server randomness are not substitutes.
- If a queue has 1 player, they carry over and may leave until a freeze claims their row.
- If a queue is empty, nothing happens.
A race ends when:
1. The first player reaches the exit.
2. The next block is detected (with a `MATCH_MIN_DURATION_MS` floor to prevent degenerate short
races).
A match ends when:
1. Its selected ruleset resolves (for example, first exit, last alive, or all players resolved).
2. If `ruleset.timing.blockDeadline` is enabled, the first advancing canonical header after both
the match's start header and `MATCH_MIN_DURATION_MS` of active play is observed. A duplicate
block poll, the start header itself, or a block arriving before the floor cannot end the match;
after an early block, the match waits for the next advancing header.
3. The `MATCH_HARD_CEILING_MS` absolute ceiling expires.
If no one escaped, living players are ranked by proximity to the exit; dead players are ranked after
them by progress, treasure, and moves.
Final placement and `winnerId` come from the selected ruleset's deterministic rank strategy. The
classic race uses exit/proximity ranking; last-alive and score-attack use their own competitive
rankings.
## Architecture
@@ -63,7 +74,8 @@ them by progress, treasure, and moves.
See migration `src/migrations/022_match_mode.sql`:
- `matches` — one race, provably-fair seed, economy, pot/fee, winner.
- `matches` — one race, including the durable `pending` entrant-freeze envelope and later
verifiable seed commitment, economy, pot/fee, winner.
- `match_entrants` — per-player state, placement, score, payment link.
- `match_events` — replay / spectator / audit feed.
- `match_queue_entries` — persisted queue for restart safety.
@@ -71,11 +83,35 @@ See migration `src/migrations/022_match_mode.sql`:
- `users.race_entries` — hot-read ticket balance.
- `payouts.match_id` — links winner payout to match.
### Provably fair
### Match seed verification
Each match commits to a SHA-256 `seed_hash` at creation. The `seed` is revealed on finish so anyone
can regenerate the exact dungeon and verify the hash. This mirrors the existing solo
`games.dungeon_seed` / `games.seed` pattern.
Paid matches use `future-block-freeze-v2` / `future-chain-block-v2`. At canonical header H the
server commits the exact economy, ruleset, sorted durable FIFO queue-entry IDs, configured delay,
and target `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` in a `pending` row before requesting any hash.
After that transaction commits, a fresh strict daemon count must prove that the target does not yet
exist; the witnessed tip and verification timestamp are durable and immutable. A delayed commit
that reaches the target is cancelled and every exact escrow anchor is refunded without reading the
target hash. Legacy v1 or otherwise unverified pending freezes are also refunded at startup.
Activation waits until the target itself has the configured confirmation depth (the target counts
as confirmation one). The scheduler reads count/header/count/header and requires both exact-height
headers to have the same canonical hash. It re-reads that target immediately after the activation
transaction and before ticket collection, gameplay, or client notification; an unavailable or
changed hash aborts and refunds the match. The playable seed is SHA-256-derived only from the v2
freeze commitment and exact target hash. The persisted/public proof records the delay, required
confirmations, minimum activation tip, and post-commit witness. Confirmation settings are safety
metadata rather than extra seed material.
Monero-family `getblockcount` is a count, while `get_block(height)` uses zero-based header heights.
The scheduler normalizes the event exactly once (`observedHeaderHeight = blockCount - 1`). Every
persisted/disclosed `blockHeight` is the actual header height passed unchanged to `get_block`, so the
published height resolves to the published hash without an off-by-one translation.
This proves the documented seed derivation and deterministic dungeon; it does not prove honest
input handling, block-source independence, payout delivery, or resistance to a malicious chain
producer/operator. Free development matches may use a server-random commitment and are labelled
`server commitment only`. Do not describe match mode as equivalent to the solo two-party fairness
protocol without an independent review.
## Configuration
@@ -85,12 +121,21 @@ Add to `src/.env` (documented in `src/.env.example`):
# Enable match mode (default false)
MATCH_ENABLED=false
# Max players per race (2–32)
# Max players per match (also bounded by the selected ruleset; built-ins max at 8)
MATCH_MAX_PLAYERS=4
# Server tick interval in ms
MATCH_TICK_MS=250
# Server-selected gameplay ruleset; clients cannot override it
# race | last-alive | score-attack | coop-escape
MATCH_RULESET_ID=race
# Paid freeze target distance and activation confirmation depth.
# Production requires explicit safe integers from 2 through 100.
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
# Minimum race duration before the next block can end it
MATCH_MIN_DURATION_MS=20000
@@ -100,20 +145,25 @@ MATCH_HARD_CEILING_MS=240000
# Credits cost for prestige-only races
MATCH_CREDITS_COST=1
# Crypto race house fee percent (default 5)
# Crypto race house fee percent (required; 0 <= fee < 100)
MATCH_HOUSE_FEE_PERCENT=5
# Optional: override per-player entry fee atomic amount.
# If unset, uses the existing per-game price (singleGamePrice / DIRECT_GAME_PRICE).
# MATCH_ENTRY_FEE_ATOMIC=5000000000
# Required per-player funded ticket value in atomic units.
MATCH_ENTRY_FEE_ATOMIC=5000000000
# Enable the crypto race-entry ticket economy
# Crypto admission requires both explicit match gates and an outer atomic-unit payout cap
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
MATCH_PAYOUT_MAX=50000000000
```
Crypto races also require:
- A paid instance (`GAME_MODE=PAID_SINGLE` / `PAID_CREDITS` or equivalent `PAYMENT_MODES`).
- Race-entry ticket products in the credit/cosmetic catalog with `grants.race_entries: N`.
- At least one race-entry product with `grants.race_entries: N` and
`grants.race_entry_value_atomic` exactly equal to `MATCH_ENTRY_FEE_ATOMIC`. The product price
must cover `N * MATCH_ENTRY_FEE_ATOMIC`; confirmation records a durable lot per payment.
- A competitive single-winner ruleset. `coop-escape` is rejected until split-payout semantics are
implemented.
## Client surfaces
@@ -125,7 +175,7 @@ Crypto races also require:
## Socket events
Server emits:
- `game_mode_info` — now includes `modes.match { enabled, economies, maxPlayers }`.
- `game_mode_info` — includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`.
- `match_queue_joined` / `match_queue_left` — queue responses.
- `match_joined` / `match_start` / `match_tick` / `match_end` — race lifecycle for players.
- `tavern_match_list` / `tavern_match_tick` / `tavern_match_end` — public race feed for tavern
@@ -139,11 +189,17 @@ Client sends:
## Leaderboards
- **Free races** create synthetic `games` rows with `game_mode = 'FREE'` for the Pleb board.
- **Credits/prestige races** populate the `prestige_leaderboard` view and are served by
`GET /api/leaderboard?board=prestige`.
- **Crypto races** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` for the Hall of
Champions board.
- **Free competitive matches** create synthetic `games` rows with `game_mode = 'FREE'` for the
Pleb board.
- **Credits/prestige competitive matches** are served from authoritative `matches` and
`match_entrants` rows by `GET /api/leaderboard?board=prestige`.
- **Crypto competitive matches** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'`
for the Hall of Champions board.
An individual win requires the durable match winner and placement #1 to agree; reaching the exit
alone is never treated as a win in last-alive or score-attack. `coop-escape` is collective, records
no individual `winnerId`, and keeps its results in `matches` / `match_entrants`; it is intentionally
excluded from individual boards until a dedicated team leaderboard exists.
## Tests
@@ -154,15 +210,8 @@ cd src
npx jest ../test/match
```
Current result: **40 passing** across 7 suites:
- `matchRoom.test.js`
- `matchQueue.test.js`
- `matchScheduler.test.js`
- `matchLeaderboard.test.js`
- `matchPayoutService.test.js`
- `tavernMatchBridge.test.js`
- `matchReconnect.test.js`
The suite includes block-deadline, authoritative leaderboard-result, co-op exclusion, durable
finish, and finished-room reconnect regressions in addition to the core room/queue/payout tests.
## Files added / modified
@@ -188,7 +237,18 @@ Modified:
## Operational notes
- Match mode is **opt-in** and inert unless `MATCH_ENABLED=true`.
- `MATCH_RULESET_ID` is trusted server configuration; unknown/solo-only values fall back to `race`.
- Crypto race payouts reuse the existing `payouts` table, batch processor, and retry service.
- The unique partial index `idx_payouts_one_per_match` prevents double payouts.
- Race-entry tickets are held in escrow on queue join and consumed only when a match starts.
- `MATCH_PAYOUTS_ENABLED=false` stops new admission and refunds queued ticket escrow. Matches whose
pot/liability was already accepted still create their durable payout; the global payout master
switch may pause dispatch without erasing it.
- The unconditional unique index `idx_payouts_one_per_match` prevents a replacement payout in every
status, including `failed` and `needs_review`.
- Only confirmed-payment ticket lots with the current exact entry value can enter a crypto race;
legacy/admin-granted aggregate tickets cannot create a payout liability. The exact lot is held
on queue join and consumed in the same transaction that accepts the immutable match liability.
- Players who disconnect during a race have a 30-second grace period to reconnect.
- Paid freeze/start block events are serialized. A crash before activation leaves a resumable
`pending` freeze; a crash after activation is handled by abandoned-match recovery. Orderly
shutdown stops the scheduler, waits for its block task, and transactionally cancels/refunds every
still-pending freeze before shutting down match transport.
+85
View File
@@ -0,0 +1,85 @@
# Production disclosures and paid-action gate
This implementation makes the running server describe its actual free, paid-prestige, payout,
test-network, and PvP switches. It does **not** claim that the product is licensed or lawful in a
particular place. Obtain jurisdiction-specific advice before accepting assets with real value.
## Required production settings
Every production instance with `PAYMENTS_ENABLED=true` fails preflight/startup unless all of these
are explicit and valid:
```dotenv
OPERATOR_NAME=Truthful responsible operator name
OPERATOR_CONTACT_URL=mailto:support@example.com
OPERATOR_CONTACT_LABEL=Support
LEGAL_POLICY_VERSION=2026-07-21-v1
TERMS_EFFECTIVE_DATE=2026-07-21
MINIMUM_AGE=18
PAID_ACKNOWLEDGEMENT_REQUIRED=true
```
Production match mode also requires explicit paid-fairness timing, even if only prestige credits
are presently exposed:
```dotenv
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
```
Both are bounded safe integers from 2 through 100. Paid PvP persists the actual delay,
confirmation requirement, activation threshold, and post-commit daemon-tip witness in its public
proof; changing these settings does not reinterpret an already-frozen match.
`RESTRICTED_LOCATIONS_NOTICE` is optional application copy. Add it only after legal review; text is
not geofencing. Changing the terms or material disclosures requires a new `LEGAL_POLICY_VERSION`,
which invalidates prior browser-session acknowledgements.
## Enforcement contract
- `/api/disclosures` returns non-secret, no-store policy data derived from runtime switches.
- `/terms`, `/privacy`, and `/responsible-play` render the active mode and operator metadata.
- Before a payment invoice, paid solo/credit entry, or paid PvP queue join, the browser requires
unselected age/reading/risk boxes. Test networks require an additional mainnet/test-coin box.
- Acceptance is a five-field canonical record (policy version plus four booleans), scoped to the
current browser tab and policy version, and cleared on rejection or socket disconnect.
- The server validates the exact current version and booleans before consuming a fairness offer,
opening an invoice, consuming a credit, or escrowing a paid PvP entry. A modified client cannot
bypass this check.
- Free entry remains available without a paid acknowledgement where the instance offers it.
The acknowledgement is a player statement, not proof of identity, age, capacity, or location.
## Mode language
| Runtime mode | Player-facing contract |
|---|---|
| Free | No entry payment; free leaderboard; no free payout |
| Paid, payouts off | Entry/credits/product purchase and paid prestige leaderboard; no crypto prize |
| Paid, solo payouts on | Full entry can be lost; only pre-entry recorded outcomes/amounts qualify |
| Crypto PvP on | Paid ticket escrow and configured winner/fee contract; separate from free/prestige PvP |
| XMR stagenet/testnet | Valueless test currency; explicit warning never to send mainnet XMR |
## Unresolved launch decisions
Technical copy and checkboxes do not resolve these. Keep real-value modes gated until the responsible
operator records decisions for:
1. entity/operator identity, licences/registrations, permitted and excluded locations;
2. age and location verification appropriate to those locations;
3. consumer terms: refunds, late/under/overpayments, complaints, dispute venue, and downtime;
4. privacy roles, processors, retention/deletion schedule, user-rights procedure, breach response,
and whether a privacy-minimized durable acknowledgement record is legally required;
5. responsible-play features such as account-level deposit/time/loss limits and durable self-exclusion;
6. tax, sanctions/AML, accounting, and cryptocurrency reporting obligations;
7. an independent review of game rules, advertised multipliers, house fee, bankroll, and provable-fair claims.
Stagenet can be used for end-to-end mechanics without representing test coins as money. Mainnet
payouts remain a separate, explicit operational and legal decision.
## Front-end dependency note
The same-origin jQuery copy is 3.7.1 from the Ubuntu `libjs-jquery` package; exact provenance and
SHA-256 are in `html/js/lib/jquery-3.7.1.PROVENANCE.md`. The CSP still permits inline script/style
because current pages depend on them. A nonce migration must move every inline handler/script before
removing `unsafe-inline`; do not partially tighten it and strand payment/game controls.
+150
View File
@@ -0,0 +1,150 @@
# Deterministic PvP capture harness
`src/scripts/pvp-capture.js` creates a clean, reproducible multiplayer match for trailers, ads,
camera work, and gameplay review. It supports the built-in Escape Race, Last Alive, Score Attack,
and Co-op Escape rulesets. Each take uses the authoritative `MatchRoom` and `MatchEngine`, drives
deterministic bots through the installed Socket.IO client, and records the existing browser render
kit with the installed Playwright Chromium.
This is intentionally not a production feature or an admin endpoint. The harness:
- starts a separate HTTP/Socket.IO process bound to `127.0.0.1` on an ephemeral port;
- accepts no remote target URL and cannot attach to a normal Wowngeon instance;
- creates one in-memory `free` match with zero entry fee, pot, house fee, or payout;
- imports no application, database, wallet, queue, provider, or payout service;
- requires `NODE_ENV=development` or `NODE_ENV=test` and the explicit
`--confirm-local-free-only` flag;
- refuses to run when a payment, credits, payout, crypto-race, mainnet-payout, or wallet endpoint
is enabled/configured; and
- refuses inherited crypto, game-mode, difficulty, dungeon, monster, treasure, or network-tuning
overrides so a committed seed cannot silently mean different footage in another shell; and
- loads only repository assets. CDN/3D renderers are unavailable in this tool.
Those guards make setting an environment variable in the live service insufficient to enable the
harness. Someone must deliberately start this separate loopback-only CLI with its confirmation
flag. On a staging machine, run it as a separate development/test process; it never uses the
staging app or staging database.
## Record a video
From `src/`:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--ruleset race \
--players 4 \
--mode tiles \
--output /tmp/wowngeon-pvp-capture.webm \
--screenshot /tmp/wowngeon-pvp-final.png
```
The repository already declares `playwright-core`; the command uses its existing local Chromium
and FFmpeg bundle. It never downloads a browser or package. If that browser bundle is missing, the
CLI fails with a direct explanation. The deterministic control harness remains usable without a
browser:
```bash
NODE_ENV=test npm run capture:pvp -- \
--confirm-local-free-only \
--no-video \
--ticks 120 \
--trace /tmp/wowngeon-pvp-trace.json
```
Run `npm run capture:pvp -- --help` for every option. `--ruleset` accepts `race`, `last-alive`,
`score-attack`, or `coop-escape`; this is separate from `--mode`, whose offline renderer choices
are `tiles`, `ascii`, and `iso`. Every ruleset accepts 2–8 bots in this harness.
The default 1280×720 spectator camera anchors on `bot-1`; choose another player with `--focus
bot-N`. In the default `--camera action` mode, deaths, treasure pickups, and exits temporarily take
focus before the camera returns to that anchor. `--camera focus` disables those cuts. Camera zoom
adjusts to the active pack: it pushes in for close action and pulls back as players spread out.
Portrait viewports begin with a tighter follow crop so 9:16 footage fills the frame. A separate
screen-space roster keeps all 2–8 player names and states legible without stacking labels on one
dungeon tile.
## Capture each built-in mode
Use the same safety acknowledgement for every take:
| `--ruleset` | Bot objective | Result card |
|---|---|---|
| `race` | Treasure route plus staggered sprints to the exit | Competitive race winner |
| `last-alive` | Seek and strike the nearest active rival | Last survivor |
| `score-attack` | Build progress/treasure score, then bank an exit | Top score and points |
| `coop-escape` | Staggered team pathing to the exit | Team escape count, never a fake competitive winner |
For example, record a Last Alive take:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--ruleset last-alive \
--players 6 \
--camera action \
--viewport 1920x1080 \
--output /tmp/wowngeon-last-alive-ad.webm \
--trace /tmp/wowngeon-last-alive-ad.json
```
Swap only `--ruleset` and output names to capture Score Attack or Co-op Escape. Headlines, active
player terminology, roster states, footer copy, and final results all follow the selected ruleset.
Score Attack closes the local exhibition deadline as soon as every bot has died or banked an exit,
avoiding a motionless tail without changing the authoritative scoring/finalization path.
For a short 9:16 ad take, the curated seed produces a complete six-racer arc in about nine seconds.
Follow the eventual winner so the finish remains legible on a phone screen:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--ruleset race \
--players 6 \
--focus bot-4 \
--viewport 1080x1920 \
--output /tmp/wowngeon-pvp-ad-vertical.webm \
--screenshot /tmp/wowngeon-pvp-ad-vertical.png
```
Use the emitted JSON trace as the authoritative replay record. The video is marketing footage; it
does not create a leaderboard row, credit, payment, ticket, or payout.
For a crowded 16:9 arena take, the same seed supports the built-in eight-player maximum:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--ruleset race \
--players 8 \
--focus bot-4 \
--viewport 1920x1080 \
--output /tmp/wowngeon-pvp-ad-8player.webm \
--screenshot /tmp/wowngeon-pvp-ad-8player.png \
--trace /tmp/wowngeon-pvp-ad-8player.json
```
The recorded eight-player proof and its no-video control both ended with `bot-4` at tick 49 and
the same final state hash. This checks that browser recording does not alter the authoritative race.
## Reproducibility
The default seed is fixed. A custom seed must be exactly 64 hexadecimal characters:
```bash
NODE_ENV=development npm run capture:pvp -- \
--confirm-local-free-only \
--seed beb270f3806a97e9ef73c8f83a6eae19a92f90ab38af9ad8b365cb74c41b2702
```
Each bot receives a numbered tick and the same authoritative state over Socket.IO. The server waits
for every bot response, queues inputs in stable bot order, and lets the engine resolve them using its
committed per-tick action priority before advancing. Bot goals are ruleset-aware but contain no
randomness beyond the committed dungeon seed. Missing or malformed input aborts the run instead of
silently changing the replay. Every run writes a versioned JSON trace beside the video containing
the ruleset, presentation metadata, seed, exact inputs, events, camera configuration, state hashes,
placements, scores, and mode-correct outcome.
The gameplay trace is deterministic, and records that gameplay environment overrides were refused.
Encoded WebM bytes can differ across Chromium/FFmpeg versions
or host timing, so use the JSON state hashes—not the video checksum—to compare replays.
+111
View File
@@ -0,0 +1,111 @@
# Immutable release artifacts
`scripts/deploy/build-release-artifact.sh` is the local, non-activating boundary between a reviewed
Git commit and the two production application instances. It produces a **runtime artifact**, not a
source or operations bundle. It does not read an environment file, install a package, connect to
`deb`, or move either `current` symlink.
## Build gate
Commit every intended source and documentation change first. Review that commit and confirm that a
secret scanner has not found credentials embedded in otherwise legitimate source files; filename
policy alone cannot detect a hard-coded secret. Then run:
```bash
git status --short # must print nothing
./scripts/deploy/build-release-artifact.sh # defaults to git-<12-character HEAD prefix>
cd dist/releases
sha256sum --check <release-id>.tar.gz.sha256
```
The builder fails on any tracked or non-ignored untracked worktree change, tracked symlink/gitlink,
runtime `.env` or credential-capable dotfile, unsafe release ID, or existing output. Ignored local
files are never read because the archive comes only from the selected commit. The archive contains
only `LICENSE`, `html/**`, the runtime subset of `src/**`, and deterministic release metadata.
It intentionally contains no `docs/`, `test/`, root `scripts/`, deployment service units, wallet
migration tools, environment examples, ad-capture bot, stagenet transfer canary, or development
database/smoke helpers. Simulation/Jest sources are also excluded, and the staged package manifest
retains only the runnable `start`, `preflight`, and `db:migrate:clone` commands. The guarded
`src/scripts/preflight.js` and
`src/scripts/migrate-disposable-clone.js` remain available for staging checks.
`.release-source-sha256` identifies the prefix-free,
runtime-allowlisted `git archive`; the external sidecar identifies the final compressed artifact;
`.release-files.sha256` verifies every extracted runtime/metadata file without covering the later
`src/node_modules` install.
Run the complete test suite from the clean source commit **before** building. Tests and operator
tools are source/control-plane inputs; they are not production runtime files:
```bash
cd src
npm ci --ignore-scripts --no-audit --no-fund
npm test
cd ..
git status --short # must still print nothing
./scripts/deploy/build-release-artifact.sh
```
Then test the runtime bytes that will be shipped. In a disposable directory, verify the artifact
sidecar and manifest and install only the locked production dependency graph:
```bash
cd <directory-containing-artifact>
sha256sum --check <release-id>.tar.gz.sha256
tar -xzf <release-id>.tar.gz -C <private-test-directory>
cd <private-test-directory>/<release-id>
sha256sum --check .release-files.sha256
cd src
npm ci --omit=dev --ignore-scripts --no-audit --no-fund
npm ls --omit=dev
```
Advisory lookup is a separate, explicitly authorized network operation. Do not use `npm audit fix`
to mutate a candidate or live release.
## Production staging gate
Use the same runtime artifact SHA-256 for `monerogue` and `wownerogue`. Transfer it into a
root-controlled staging path and verify the operator-pinned 64-hex digest before extraction. The
activation boundary must then:
1. reject an existing release ID, links/special files, an unexpected top-level directory, or unsafe
ownership/path components;
2. install only locked production dependencies with
`npm ci --omit=dev --ignore-scripts --no-audit --no-fund` while staging is writable;
3. run `npm ls --omit=dev`, recheck `.release-files.sha256`, then make the complete release
`root:root` and non-writable before it can become `current`;
4. restore each fresh pre-deploy dump into a separately named clone database, run
`npm run db:migrate:clone` under the exact disposable-target contract in
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), require its exact manifest proof (currently through
migration 040), and run the separately reviewed and hash-pinned fleet copy of
`financial-audit.sql` there—the SQL is intentionally not inside the runtime artifact;
5. run `npm run preflight` from the candidate with each final protected environment. Preflight is
intentionally non-mutating and does not replace database, daemon, or wallet health checks.
The clone runner is deliberately non-activating; there is still no reviewed activation script. Do
not point `current` at a candidate until the clone proof and the complete
drain/audit/symlink/health/rollback sequence in `~/src/such-fleet/RUNBOOK-wowngeon.md` are satisfied.
## Source and operations boundary
The Git checkout remains the review/test source. `~/src/such-fleet` is the deployment control plane
for hash-pinned SQL, service units, backup/firewall jobs, clone validation, activation, and rollback.
Neither source-tree deployment scripts nor fleet tools are copied beneath the application release
directory. A runtime artifact hash does not attest a separately delivered operations file; record
and verify an independent hash for every such file in the fleet change plan.
## Activation and rollback gate
Drain public ingress and require zero active and queued games. Take and validate fresh dumps; run the
financial audit before and after stopping only the affected app. Preserve and validate the exact
predecessor target, switch `current` atomically, then start stagenet first. Require bounded local
`/health/live` and `/health/ready`, exact network/money-mode disclosures, public TLS, WebSocket, and
leaderboard/intake smoke tests before traffic is restored. The stagenet payout canary is a later,
one-shot gate and is never a mainnet command.
Application rollback is safe only if the predecessor was tested against the *post-migration* clone
schema. Migrations are forward-only and run one transaction per file at application startup; a
mid-sequence failure can leave earlier files committed. On activation failure, stop the candidate,
atomically restore the validated predecessor symlink, start it, and require both the exact target and
readiness. Do not automatically restore the database after new writes; keep the dump for an
operator-reviewed recovery decision.
+1 -1
View File
@@ -46,7 +46,7 @@ Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker
- [x] **4.1 L — Break up `index.js` (substantially).** ✅ Extracted the verify HTML → `views/verifyPage.js` (now HTML-escaped), all 15 admin endpoints → `routes/admin.js`, and the 3 smirk endpoints → `routes/auth.js`, each as DI factories with smoke tests. **index.js: 2029 → 882 lines (-57%).** _Remaining user/public routes left inline — they're interleaved with bootstrap and have no route tests, so further slicing is diminishing-return/higher-risk; the structure + pattern are established for later._
- [~] **4.2 M — Kill duplicates (partial).** ✅ Deleted the dead `handlePlayerMove` handler (a footgun that double-stepped the monster + bypassed escape/collision detection) and its orphaned `playerMoveTimestamps` map/cleanup — the live path is `MovementManager`. _Remaining (lower-value/higher-effort): merge `rpcService`+`rpccalls`, unify `payout_address` column widths, address the `QueryValidator` false-positive — deferred._
- [⏸] **4.3 M — Config snapshots — DEFERRED (risk > value).** The `process.env` config write-back is ugly but **load-bearing**: it's how DB-persisted hot-reload config reaches the env-based readers — `difficultyConfig` selects the difficulty preset from `process.env.GAME_MODE`, and `debugManager`'s simulated-blocks **safety gate** reads it. A proper `ConfigService` means rewiring those sensitive readers; with no config-reload tests and zero correctness/safety upside, the risk isn't justified right now. Documented rather than rewritten.
- [~] **4.4 M — Frontend de-spaghetti (safe wins done).** ✅ Shared `escapeHtml` already added in 0.6. Removed the duplicate `_gameMode` declaration (the later `null` silently shadowed the earlier `'free'`). Removed the dead `SocketHandlers._appendMessage` calls in `smirkAuth.js` (the method never existed; always fell through to the working `.text()` append). Gated `broadcastManager`'s high-volume per-event logs (user-count, chat-message-with-PII, game-update, game-start) behind debug mode. _Deferred: jQuery 3.4.1 upgrade and the nested-`setTimeout` init-ordering rewrite (larger, no frontend tests, higher risk)._
- [~] **4.4 M — Frontend de-spaghetti (safe wins done).** ✅ Shared `escapeHtml` already added in 0.6. Removed the duplicate `_gameMode` declaration (the later `null` silently shadowed the earlier `'free'`). Removed the dead `SocketHandlers._appendMessage` calls in `smirkAuth.js` (the method never existed; always fell through to the working `.text()` append). Gated `broadcastManager`'s high-volume per-event logs (user-count, chat-message-with-PII, game-update, game-start) behind debug mode. Upgraded the same-origin vendored jQuery from 3.4.1 to distro-sourced 3.7.1 with a recorded package version and SHA-256. _Deferred: the nested-`setTimeout` init-ordering rewrite and a complete nonce-based CSP migration (both require broader browser regression coverage)._
- [x] **4.5 S — Delete stale `test/` scratch files.** ✅ Removed 14 non-jest leftovers (5 `.js` scratch scripts + 9 `.html` debug pages) — confirmed they contained no jest constructs and were referenced nowhere. `test/` is now exclusively the 21 real jest suites.
## Phase 5 — Test & ops overhaul (🟠 — the launch gate)
+23 -6
View File
@@ -31,7 +31,7 @@ three decision points:
```
onExit(room, id) a player reached the exit — end the match? set a winner?
onDeath(room) a player died — is the match over?
rank(room) final ordering (best-first) for placement + winnerId
rank(room) final ordering (best-first) for placement; competitive modes also set winnerId
```
- **`FIRST_TO_EXIT`** is a *verbatim port* of the original race logic, so existing matches are
@@ -39,8 +39,11 @@ rank(room) final ordering (best-first) for placement + winnerId
- **`LAST_ALIVE`** (PvP): reaching the exit means you *survived* (safe, not an instant win); the match
ends when ≤1 contender remains; `rank` orders last-standing → escaped → died-later. Combined with
`entities.pvpCombat`, stepping onto a living rival **strikes them down** (attacker holds position).
Same-tick intents use a seed-derived priority over stable entrant slots, never packet arrival order;
an entrant eliminated earlier in that committed order cannot move or attack post-mortem.
- **`HIGH_SCORE`**: no instant win; rank by score at the end.
- **`ALL_ESCAPE`** (co-op): ends when everyone alive has escaped.
- **`ALL_ESCAPE`** (co-op): ends when everyone alive has escaped, records placements as progress
metadata, and deliberately leaves the individual `winnerId` null.
## Where it plugs in
@@ -51,9 +54,20 @@ and `entities.pvpCombat`, and takes timing from the ruleset. `MatchEngine` is a
(`room.resolveTick()` on an interval).
**Scope note:** the *live single-player* engine (`src/game/game.js`) is intentionally **not** refactored to
consume rulesets (too risky to touch the deployed path) — `solo-classic` is a catalog descriptor only. PvP
modes are opt-in via `rulesetId`; the match scheduler still builds `race`, and match mode is gated by
`MATCH_ENABLED` (inert by default), so nothing changes until wired.
consume rulesets (too risky to touch the deployed path) — `solo-classic` is a catalog descriptor only.
Multiplayer rulesets are selected by the server with `MATCH_RULESET_ID`; unknown or solo-only values fail
closed to `race`. The active ruleset and safe catalog metadata are advertised to clients, but clients cannot
select executable rules. Match mode remains gated by `MATCH_ENABLED` (inert by default).
When `timing.blockDeadline` is true, `MatchScheduler` offers each advancing canonical header to
`MatchManager`. An active room expires only when that header is strictly later than its start
header and its active-play `minDurationMs` floor has elapsed. Duplicate polls and same-header
starts are idempotent; `blockDeadline: false` leaves the hard ceiling as the only clock-based end.
For paid multiplayer, the selected ruleset ID is part of the durable entrant freeze at canonical
header H. The seed uses only the exact configured future-header hash after that freeze and its
required confirmation wait; changing the active
ruleset cannot reinterpret a pending entrant set or trigger a server-random fallback.
## Economy per mode
@@ -73,8 +87,11 @@ game-balance change, not just wiring.
```
src/game/rulesets/Ruleset.js defineRuleset + WIN/ECONOMY enums
src/game/rulesets/winConditions.js FIRST_TO_EXIT (race) / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
src/game/rulesets/registry.js built-ins + getRuleset/listRulesets/rulesetFromMatchOpts
src/game/rulesets/registry.js built-ins + safe match resolver/catalog helpers
src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank
src/multiplayer/MatchEngine.js tick driver
src/network/matchScheduler.js advancing-header deadline dispatch
src/network/matchManager.js active-play floor + idempotent expiry
src/game/difficultyConfig.js world presets (the ruleset's `world` half)
src/migrations/029_match_ruleset_id.sql persists the selected ruleset on each match
```
+210
View File
@@ -0,0 +1,210 @@
# XMR stagenet financial canary
`src/scripts/stagenet-financial-canary.js` is the release-gate harness for Monerogue's two paid solo settlement paths. It is intentionally narrow: one new anonymous user, one invoice, at most one funding transfer, one bounded game, and one payout. It supports these scenarios:
| `E2E_SCENARIO` | Entry | Required outcome | Exact payout |
|---|---|---|---|
| `direct-2x` | one `single_game` invoice | escape without treasure | invoice amount × 2 |
| `credits-3x` | one pure credits-package invoice, then one `PAID_CREDITS` entry | collect treasure, then escape | configured credits payout base × 3 |
Stagenet XMR is valueless test currency. Never send mainnet XMR to a stagenet address. This harness refuses mainnet-facing application state and funding-wallet addresses, but that guard is not a substitute for checking the wallet and daemon commands yourself.
## Hard safety properties
The harness fails closed unless all of these are true:
- The application URL explicitly names `127.0.0.1`, `localhost`, or `::1` on dedicated port `3102`. It cannot target the public router, LAN hostname, or Nebula address.
- Readiness, public disclosures, REST mode data, and Socket.IO mode data all identify real XMR stagenet with payments and payout dispatch enabled.
- Both paid solo modes advertise exact 2× escape and 3× treasure multipliers.
- The current `/api/disclosures` response requires the canonical five-field acknowledgement. The harness refetches it immediately before the value-bearing action and echoes exactly `policyVersion`, `ageEligible`, `termsRead`, `riskAccepted`, and `testnetUnderstood`.
- The selected PostgreSQL database name contains `canary`, `e2e`, and the selected scenario (`direct` or `credits`). Its connection must be localhost, the harness session is read-only, current financial migrations must exist, and the database must contain only the migration-seeded admin with no game or financial rows.
- Before any invoice, funding-wallet call, or transfer, a nonce challenge binds the application pool to the harness connection's exact PostgreSQL cluster identifier, database OID, and database name. A same-named database on another cluster does not pass.
- The funding wallet RPC is localhost-only and either digest-authenticated or enabled by a separate explicit unauthenticated-local-RPC acknowledgement.
- The funding wallet validates both its own address and the invoice as stagenet. `get_address_index` must prove the invoice does not belong to the funding wallet; this prevents a meaningless house-wallet self-payment.
- Live execution needs three exact confirmations plus an atomic-unit transfer ceiling. The transfer gate is marked used before the sole `transfer` RPC call, so an ambiguous transport failure is never retried.
- The bot is bounded to one dungeon depth, 3,000 moves, and eight minutes. The direct scenario treats treasure as blocked. The credits scenario treats the exit as blocked until treasure is collected.
- Wallet addresses, session tokens, proof seeds, transaction hashes, and credentials are never printed. Failure messages redact those shapes.
The harness never creates, restores, exports, migrates, or backs up a wallet. It never deploys code, changes router state, or edits fleet configuration.
## Required topology
Use two different stagenet wallets:
1. The canary application owns the normal house wallet used to create invoice subaddresses and dispatch payouts.
2. A separate, low-balance funding wallet sends the entry/package payment and receives the payout.
Expose only the separate funding wallet RPC to the harness, on loopback. Do not give the harness the house-wallet RPC URL or credentials. Keep the funding balance no larger than needed for the selected invoice plus fees.
Before live use, independently verify that both wallet files are recoverable according to the operator's wallet-backup procedure. Do not put seeds, private keys, wallet passwords, transaction keys, or mnemonic output in this runbook, shell history, screenshots, CI variables, or canary logs. This harness deliberately does not inspect or print seed material.
## Build the disposable canary instance
Run the exact release candidate intended for production, but bind it only to localhost port `3102`. Use a dedicated PostgreSQL database for each scenario; do not run both scenarios in the same database and never reuse a database after any live attempt.
Recommended game-profile overrides for the one-shot financial check are:
```dotenv
DIFFICULTY_PRESET=easy
MONSTER_SPEED=0
DUNGEON_LEVELS=1
```
`MONSTER_SPEED=0` leaves the verifiable monster placement in the committed dungeon but prevents movement during the canary. The harness verifies the one-level state; the exact `E2E_CANARY_PROFILE` confirmation below is the operator's assertion that the easy/static-monster configuration is the one actually running.
The canary instance must otherwise use the intended production money configuration, including:
- `CRYPTO_TYPE=XMR`, `MONERO_NETWORK=stagenet`, real stagenet daemon and house wallet RPC;
- direct and credits paid modes enabled together;
- direct and credits payouts enabled;
- 2× escape and 3× treasure multipliers for both modes;
- a positive `CREDITS_PAYOUT_BASE` whose 3× result is within the configured payout bounds;
- a credits-only package (default id `small`) with no race entries, packs, or premium grant;
- production paid-action acknowledgement enabled and current operator/legal disclosure metadata.
Use a fresh database name such as:
```text
monerogue_canary_direct_e2e
monerogue_canary_credits_e2e
```
First boot the release candidate against the new database with payout dispatch disabled solely to run startup migrations. Stop it after readiness. Then run database preflight. Finally boot the same release candidate and same database with the intended payout configuration on localhost port `3102`. Do not point a public router at this instance.
Enable the identity endpoint only on that final isolated canary boot:
```dotenv
NODE_ENV=production
PORT=3102
CANARY_DATABASE_HANDSHAKE=I_AM_AN_ISOLATED_XMR_STAGENET_CANARY
CANARY_EXPECT_DATABASE=monerogue_canary_direct_e2e
CANARY_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce
```
Use the corresponding `credits` database name for that scenario. If none of the three
`CANARY_DATABASE_*` variables is set, the endpoint is not registered. A partial configuration,
non-XMR-stagenet process, non-production process, wrong port, or `DB_NAME` mismatch aborts startup.
The endpoint signs its database identity and a fresh caller challenge; it never returns the nonce.
It also rejects any TCP peer that is not loopback, without trusting proxy-forwarded client headers.
## Protect local secret files
Credential files read by the harness must be regular files, not symlinks, and have no group/world permission bits (for example mode `0600` or `0400`). Prefer file-backed variables:
```text
E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user
E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password
E2E_DATABASE_URL_FILE=/run/credentials/canary/database-url
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce
```
Create a new random 32-byte nonce for each scenario and expose the same protected file to the
application (`CANARY_DATABASE_NONCE_FILE`) and harness (`E2E_DATABASE_NONCE_FILE`). Do not put its
contents in an environment variable, command argument, log, or screenshot. Delete the nonce after
the isolated canary is stopped; it is not a wallet key or recovery artifact.
Alternatively, `E2E_DATABASE_ENV_FILE` may point to a protected dotenv file containing `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD`. Set exactly one database source. `DB_HOST` and database URLs must explicitly use loopback.
## Read-only preflights
The public preflight performs no Socket.IO session registration, invoice creation, database mutation, or wallet RPC call:
```bash
E2E_MODE=preflight \
E2E_TARGET=http://127.0.0.1:3102 \
npm --prefix src run canary:stagenet
```
The database preflight opens only a read-only PostgreSQL session. Run it while the freshly migrated database still contains no canary session:
```bash
E2E_MODE=database-preflight \
E2E_SCENARIO=direct-2x \
E2E_EXPECT_DATABASE=monerogue_canary_direct_e2e \
E2E_DATABASE_URL_FILE=/run/credentials/canary/direct-database-url \
npm --prefix src run canary:stagenet
```
For the credits scenario, use `E2E_SCENARIO=credits-3x`, a database name containing `credits`, and its separate protected connection file.
## Live direct 2× escape
Review the configured direct entry atomic amount before setting `E2E_MAX_TRANSFER_ATOMIC`. The ceiling must be at least the advertised invoice amount and should equal that amount when practical.
```bash
E2E_MODE=live-stagenet \
E2E_SCENARIO=direct-2x \
E2E_TARGET=http://127.0.0.1:3102 \
E2E_EXPECT_DATABASE=monerogue_canary_direct_e2e \
E2E_DATABASE_URL_FILE=/run/credentials/canary/direct-database-url \
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce \
E2E_FUNDING_RPC_URL=http://127.0.0.1:38083 \
E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user \
E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password \
E2E_MAX_TRANSFER_ATOMIC=REPLACE_WITH_REVIEWED_ATOMIC_CEILING \
E2E_FEE_CUSHION_ATOMIC=1000000000 \
E2E_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_ONE_XMR_STAGENET_TRANSFER \
E2E_SCENARIO_CONFIRM=DIRECT_2X_ESCAPE \
E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
npm --prefix src run canary:stagenet
```
The harness binds a fresh fairness offer to the direct invoice before revealing its address, transfers the exact invoice amount, waits for receipt-backed confirmation and automatic game start, avoids treasure, escapes, and verifies an exact 2× payout.
## Live credits-package then 3× treasure escape
Review the selected package's atomic price and set the ceiling accordingly. The default package id is `small`; override it with `E2E_CREDITS_PACKAGE_ID` only after confirming it is a pure credits-only package.
```bash
E2E_MODE=live-stagenet \
E2E_SCENARIO=credits-3x \
E2E_CREDITS_PACKAGE_ID=small \
E2E_TARGET=http://127.0.0.1:3102 \
E2E_EXPECT_DATABASE=monerogue_canary_credits_e2e \
E2E_DATABASE_URL_FILE=/run/credentials/canary/credits-database-url \
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce \
E2E_FUNDING_RPC_URL=http://127.0.0.1:38083 \
E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user \
E2E_FUNDING_RPC_PASSWORD_FILE=/run/credentials/canary/funding-rpc-password \
E2E_MAX_TRANSFER_ATOMIC=REPLACE_WITH_REVIEWED_ATOMIC_CEILING \
E2E_FEE_CUSHION_ATOMIC=1000000000 \
E2E_CONFIRM=I_UNDERSTAND_THIS_BROADCASTS_ONE_XMR_STAGENET_TRANSFER \
E2E_SCENARIO_CONFIRM=CREDITS_PACKAGE_THEN_3X_TREASURE_ESCAPE \
E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
npm --prefix src run canary:stagenet
```
The harness purchases the package, verifies the exact confirmed grant and balance update, consumes a new fairness offer in `auto_start`, spends exactly one configured game credit, collects treasure before allowing the exit, and verifies an exact 3× payout based on `CREDITS_PAYOUT_BASE` (not the package price).
## Exact settlement assertions
After the funding wallet observes the incoming payout, the harness checks the read-only database connection. A passing run has exactly:
- two users: the migration-seeded admin and one canary identity;
- one confirmed native-Monero payment with `received_amount == expected_amount` and valid confirmation evidence;
- one unique confirmed `chain_output` receipt whose amount exactly equals the invoice;
- one won/escaped game with consumed entry evidence, committed 2×/3× immutable payout terms, the selected paid mode, matching fairness v2 identity, and the expected treasure flag;
- one completed payout with the exact amount, multiplier, reason, payout address, and transaction evidence;
- no refunds, late reviews, matches, match queues, race-entry ledgers, or race-entry lots.
The direct scenario additionally requires exactly the `direct_entry +1` and `game_entry -1` credit-ledger rows, a zero balance, unified purchase progress of one, the game linked to the fairness-bound payment, and no product-entitlement marker.
The credits scenario additionally requires exactly one payment-linked `package_purchase` grant, one `game_entry` debit, the exact final balance, one active payment-entitlement marker matching the advertised grant, and a `PAID_CREDITS` game with no direct `payment_id`.
## Failure handling
Before the line `one exact, non-retriable XMR stagenet transfer was broadcast`, a failure means no funding transfer was attempted. Correct the configuration, recreate a fresh scenario database if any app session was made, and repeat the preflights.
At or after that line, never rerun the command and never reuse the database. A transport timeout can be ambiguous even when the wallet accepted the transfer. Preserve the database and both wallet files, stop the isolated canary, and reconcile the invoice receipt, payment, game, payout, and wallet histories manually. The harness intentionally performs no automatic retry, refund, database cleanup, wallet cleanup, or destructive recovery.
Only after both scenarios pass on separate databases should the release be considered financially canary-tested. Passing does not authorize deployment; deployment and router/fleet changes remain separate reviewed operations.
## Local verification (no network or wallet use)
```bash
node --check src/scripts/stagenet-financial-canary.js
npm --prefix src test -- --runTestsByPath ../test/canaryDatabaseIdentity.test.js ../test/stagenetFinancialCanary.test.js
npm --prefix src run canary:stagenet -- --help
```
These commands parse the harness, run its pure/static safety tests, and print help. They do not contact the application, database, or either wallet.
+39 -38
View File
@@ -1,18 +1,18 @@
# Tavern & Multiplayer — Design & Build Plan
Match mode (multiplayer races) now has its own dedicated design doc: see `docs/MATCH_MODE.md`.
Match mode has its own dedicated design doc: see `docs/MATCH_MODE.md`.
**Status:** Tavern is complete; multiplayer race mode is implemented (see `docs/MATCH_MODE.md`).
PvP combat remains a future extension. Both modes are disabled by default and gated by config, so
existing single-player play is unaffected.
**Status:** Tavern and server-selected multiplayer rulesets (`race`, `last-alive`, `score-attack`,
`coop-escape`) are implemented (see `docs/MATCH_MODE.md`). Both surfaces are disabled by default
and gated by config, so existing single-player play is unaffected.
## Shared engine: one Room, two modes
Both modes are built on a single shared component — a real-time, multi-occupant `Room`:
- **Tavern** — a peaceful Room: players pick an avatar, walk around, chat, and watch live games.
- **Multiplayer** — the same Room engine with a dungeon, monsters, hazards, and player-vs-player
combat added.
- **Multiplayer** — the same Room engine with a dungeon, monsters, and ruleset-selected
collision/combat behavior.
The engine (`src/multiplayer/`) has no Socket.IO, database, or Wownerogue coupling. A manager
layer owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. Keeping the engine
@@ -24,35 +24,36 @@ transport-agnostic makes it unit-testable in isolation and reusable as a standal
choose an avatar, move around a shared map, and chat. A spectator camera lets them watch live
single-player or multiplayer games from inside the room.
**Multiplayer Race** — a shared match Room. Players race to escape a dungeon before the next block
and contend with a shared monster. Match rooms enable occupant collision; the Tavern does not.
PvP combat is planned for a later milestone.
**Multiplayer Match** — a shared dungeon Room using the operator-selected ruleset. Players may race
to escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end
on the first advancing header after their active-play duration floor; every match also has a hard
ceiling. Match rooms enable the collision/combat rules selected by the ruleset; the Tavern does not.
## Operator Policy
The server operator controls which modes are enabled and, for multiplayer, the economic model.
Configuration is driven through the existing `config/` layer (DB-backed, hot-reloadable) rather
than static env vars alone, so operators can adjust settings without a restart. A later extension
allows per-room settings.
Mode activation, the multiplayer ruleset, and multiplayer economy switches are startup environment
configuration. Change `SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED`, `MATCH_RULESET_ID`, or the
`MATCH_*` economy settings in the deployment configuration and restart the service. The separate
database-backed `ConfigPersistence` allowlist covers a limited set of solo payment/difficulty
values; it does not hot-reload match modes or rulesets. There is no per-room ruleset editor today.
Which modes are enabled is surfaced to clients in the `game_mode_info` event as
`modes: { solo, tavern, multiplayer }` (from `SOLO_ENABLED` / `TAVERN_ENABLED` /
`MULTIPLAYER_ENABLED`). The client shows or hides entry points from this, so any single mode can
run on its own — including a Tavern-only instance. Solo is on unless explicitly disabled;
Tavern and Multiplayer are opt-in.
`modes: { solo, tavern, match }` (from `SOLO_ENABLED`, `TAVERN_ENABLED`, and `MATCH_ENABLED`).
The client shows or hides entry points from this, so any single mode can run on its own — including
a Tavern-only instance. Solo is on unless explicitly disabled; Tavern and Match are opt-in.
Multiplayer economic models (operator-selected per instance):
| Model | Description |
|---|---|
| Free | No entry cost or payout. |
| Entry fee, leaderboard only | Players pay to enter; results affect a leaderboard, no payout. |
| Winner-take-pot | Entrants stake into a pot awarded to the winner. |
| Even-return | Entrants recover their stake; only a flat fee is deducted. |
| `free` | No entry cost or payout; competitive results use the Free/Pleb board. |
| `credits_prestige` | Spend credits; competitive results use the separate PvP Prestige board, with no crypto payout. |
| `crypto_race` | Use a backed race-entry ticket; an eligible competitive winner receives the disclosed pot less the configured fee. |
The default configuration is conservative (free, no payout), matching the current wownerogue
instance. Operators are responsible for ensuring their chosen configuration complies with
applicable laws in their jurisdiction.
The baseline defaults are conservative (free, no payout). Instance deployment configuration may
enable additional economies; operators are responsible for ensuring their selected configuration
complies with applicable laws in their jurisdiction.
## Avatars
@@ -147,7 +148,8 @@ can be swapped later without changing callers. The current backend remains the d
Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted
into a `Scene` (a tile grid + entities), and any renderer draws it. This lives in
`html/js/render/` and is used by both the Tavern and (planned) the main game.
`html/js/render/` and is used by the Tavern, multiplayer client, spectators, and the single-player
render bridge.
Render tiers:
- **ASCII** — monospace glyph grid (canvas). Always-available fallback; accessible.
@@ -201,22 +203,21 @@ Tavern:
- **T1** — `TavernManager`: Socket.IO room, server-tick timer, join/move/leave broadcasts; minimal
client (`html/tavern.html`) rendering avatars; gated behind `TAVERN_ENABLED`. (Done)
- **T2a** — `ChatProvider` seam + tavern chat. (Done)
- **T2b** — Spectator camera into live games from the Tavern.
- **T3** — Avatar picker with policy-gated unlocks.
- **T2b** — Spectator camera into live solo and multiplayer games from the Tavern. (Done)
- **T3** — Avatar picker with policy-gated unlocks. (Done)
Multiplayer:
- **M0** — Match Room with a dungeon and shared monsters; players race the block. Includes a load
test with many simulated clients to measure server-tick performance under concurrency.
- **M1** — Player-vs-player combat.
- **M0** — Match Room with a dungeon, shared monster, server ticks, and bounded lifecycle. (Done)
- **M1** — Player-vs-player combat for the `last-alive` ruleset. (Done)
- **M2** — Environmental hazards.
- **M3** — Operator Policy economic models wired in.
- **M3** — Free, credit-prestige, and gated crypto-race economies wired in. (Done)
Rendering:
- **R0** — Shared render kit: scene model + ASCII / Tiled / Fancy (PixiJS) renderers + mode
switch, wired to the Tavern. Premium-mode gating scaffolded. (Done)
- **R1** — Wire the main game to the render kit (refactor the `display/` layer behind it).
- **R1** — Wire the main game to the render kit behind the existing display layer. (Done)
- **R2** — Real tilesets / sprite sheets and the Fancy-ASCII tier.
- **R3** — Wire premium render modes to credit entitlements (Operator Policy).
- **R3** — Wire premium render modes to credit entitlements (Operator Policy). (Done)
All milestones are additive and config-gated; the single-player path is unchanged.
@@ -232,18 +233,18 @@ All milestones are additive and config-gated; the single-player path is unchange
and Socket.IO room broadcasts. Wired into `SocketHandlers` (`tavern_join` / `tavern_move` /
`tavern_leave`, plus disconnect cleanup and shutdown). Inert unless `TAVERN_ENABLED=true`.
`html/tavern.html` — a minimal self-contained client: connect, choose a name/avatar, enter the
room, walk with arrow keys / WASD, and chat. Renders the map and occupants on a canvas.
`html/tavern.html` — the browser client: connect, choose a name/avatar, enter the room, walk with
keyboard or on-screen controls, chat, watch solo/multiplayer games, or join a match.
`src/network/chat/` — the `ChatProvider` seam. `ChatProvider` (interface) + `SocketChatProvider`
(default: Socket.IO delivery + Postgres history). Both the global chat (`ChatHandler`) and tavern
chat deliver through it, so the backend can be swapped (e.g. a Nostr channel) without changing
callers. Tavern chat is room-scoped and ephemeral.
`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic scene + tavern adapter),
`asciiRenderer.js`, `tileRenderer.js`, `fancyRenderer.js` (PixiJS), and `renderModes.js`
(registry, factory with graceful fallback, entitlements, persistence). The tavern client uses it
with a mode toolbar; premium modes are marked and gated by `RK.entitlements`.
`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic adapters), ASCII, tiled,
Fancy (PixiJS), isometric, and Three.js renderers, plus `renderModes.js` (registry, factory with
graceful fallback, entitlements, persistence). Clients expose a mode toolbar where applicable;
premium modes are marked and gated by `RK.entitlements`.
Tests: `test/tavernRoom.test.js` (engine), `test/tavernManager.test.js` (manager incl. chat),
`test/chatProvider.test.js`, `test/renderScene.test.js` (scene adapter),
+5 -2
View File
@@ -28,8 +28,11 @@ Done:
Pending:
- [ ] **Multi-level calibration run**: lock per-network level counts against a target house-win (sim multi-level support is in; counts are currently starting points).
- [ ] **Block-bounded pot race** (flagship): consume `Ruleset.timing.blockDeadline`; add pot+rake economy (house = zero-risk rake-taker). Decisions pending: empty-pot rule, prize split.
- [ ] **PvP wiring**: connect the already-built `last-alive`/`score-attack` rulesets to a real queue.
- [x] **Block-bounded pot race** (flagship): `Ruleset.timing.blockDeadline` expires active matches
on the first advancing header after the play-time floor; winner-take-pot-minus-fee admission
is durable and co-op is excluded until split-payout semantics exist.
- [x] **PvP wiring**: the server selects `race`, `last-alive`, `score-attack`, or `coop-escape`
with `MATCH_RULESET_ID`; match state and persistence record the active ruleset.
- [ ] **Hazard gameplay**: generator places lava/poison/spikes (FX already renders them) + damage stakes; escalate per level.
- [ ] **Client polish**: "Level N/M" indicator + "descended" toast (server sends `depth`/`maxDepth` + `game_event: descend`).