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
+27 -15
View File
@@ -1,14 +1,15 @@
# Wownerogue
A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block timing. Built with Node.js/Express and Socket.IO for real-time play, optional crypto payments, and automated payouts.
A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block timing. The same codebase runs the original solo dungeon, the social Tavern, and operator-selected PvP/race rulesets, with separate free and paid leaderboards.
## Features
- **Provably fair** gaming with pre-game hash commitments
- **Provably fair** solo runs with two-party, pre-entry commitments and per-depth fingerprints
- **Live spectator mode** - watch active games in real-time
- **Persistent chat** with 30-day history
- **Transaction history** - view payment and payout records
- **Multiple game modes**: Free play, per-game payments, or credit bundles
- **Social Tavern** with chat, live solo spectating, and in-place race queues
- **Real-time multiplayer races**: free, prestige-credit, or crypto winner-take-pot races every block
- Configurable difficulty with house edge tuning
- Automatic wallet RPC integration for payments and payouts
@@ -30,13 +31,16 @@ Open http://localhost:3000 to play.
## Game Modes
| Mode | Cost | Payout | Description |
| Mode | Cost | Payout | Leaderboard |
|------|------|--------|-------------|
| **FREE** | None | None | No payments required |
| **PAID_SINGLE** | 1 WOW/game | 2× escape, 3× with treasure | Per-run payment |
| **PAID_CREDITS** | 10 credits/5 WOW | Optional | Buy credit bundles |
| **FREE** | None | None | Pleb |
| **PAID_SINGLE** | Per-run chain payment | Profile-controlled | Paid prestige |
| **PAID_CREDITS** | Purchased credit | Profile-controlled | Paid prestige |
When both modes are enabled (`ALLOW_MIXED_MODE=true`), players with credits can start games instantly without the payment modal.
The shipped mainnet profile offers free plus paid-prestige play with every payout switch off. The XMR
stagenet profile offers the same modes with 2× escape and 3× treasure-escape test payouts. See
`src/.env.mainnet.example` and `src/.env.stagenet.example`; payout switches are independent from
payment intake and fail closed.
## Match Mode (Multiplayer Races)
@@ -51,6 +55,10 @@ crypto block as long as at least two players are queued. Supported economies:
See `docs/MATCH_MODE.md` for full architecture, configuration, and API details.
For deterministic multi-bot playtesting and 16:9/9:16 ad footage, use the loopback-only,
free-only capture harness documented in [`docs/PVP_CAPTURE.md`](docs/PVP_CAPTURE.md). It drives the
real match engine over Socket.IO but cannot connect to a deployment, database, wallet, or payout path.
## Difficulty & Pacing
Difficulty has two levers, both keyed to the crypto network so `cryptoType` actually shapes the
@@ -95,10 +103,11 @@ disable with `NETWORK_TUNING_DISABLED=true`. The counts are sim-derived starting
### Provably Fair Gaming
1. Server generates a random seed and shows its SHA-256 hash before the game
2. The seed deterministically generates the dungeon layout and positions
3. After the game, the seed is revealed for verification
4. Players can verify `hash(seed) = commitment` to prove no manipulation
1. The server publishes a one-use, socket-bound SHA-256 commitment before paid entry.
2. The browser adds an independent WebCrypto client seed; neither side alone selects the final seed.
3. The effective seed deterministically generates each dungeon depth and its audit fingerprint.
4. After completion, the server seed and depth manifest are revealed at `/verify/:gameId` and
`/api/verify/:gameId`; active-game server seeds remain private.
### Spectator Mode
@@ -127,13 +136,16 @@ A warning appears if localStorage is unavailable.
| Endpoint | Description |
|----------|-------------|
| `GET /health` | Server health, uptime, memory, wallet status |
| `GET /health` | Redacted dependency/readiness summary |
| `GET /health/live` | Process liveness probe |
| `GET /health/ready` | Dependency readiness probe (503 while degraded) |
| `GET /api/game-modes` | Current modes and pricing |
| `GET /api/user/:socketId/credits` | User credit balance |
| `GET /api/user/:socketId/payments` | Payment history (paginated) |
| `GET /api/user/:socketId/payouts` | Payout history (paginated) |
| `POST /api/user/:socketId/address` | Set payout address |
| `GET /verify/:gameId` | Provably fair verification page |
| `GET /verify/:gameId` | Human-readable fairness verification page |
| `GET /api/verify/:gameId` | Machine-readable fairness proof |
### Admin Endpoints
@@ -141,7 +153,7 @@ Require `X-Admin-Key` header matching `ADMIN_API_KEY` env variable.
| Endpoint | Description |
|----------|-------------|
| `POST /api/admin/refund/payment` | Refund a payment, deduct credits if applicable |
| `POST /api/admin/refund/payment` | Request an idempotent refund; consumed grants require review |
| `POST /api/admin/credits/adjust` | Add or remove credits from a user |
| `GET /api/admin/users/search` | Search users by socket ID or address |
@@ -208,7 +220,7 @@ DIRECT_PAYOUT_TREASURE=3.0
# Wallet RPC (required for paid modes)
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
WALLET_RPC_USER=user
WALLET_RPC_PASSWORD=password
WALLET_RPC_PASSWORD=use-a-distinct-strong-rpc-secret
# Database
DB_HOST=localhost
+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`).
+216 -41
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Wowngeon</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
@@ -162,8 +161,13 @@
.status-badge { padding: 4px 8px; border-radius: 4px; font-size: 0.85em; font-weight: 500; }
.status-pending { background: #f59e0b20; color: #f59e0b; }
.status-requested { background: #f59e0b20; color: #f59e0b; }
.status-processing { background: #3b82f620; color: #60a5fa; }
.status-failed { background: #ef444420; color: #ef4444; }
.status-needs_review { background: #ef444440; color: #fca5a5; border: 1px solid #ef4444; }
.status-completed { background: #22c55e20; color: #22c55e; }
.status-recorded { background: #6b728020; color: #9ca3af; }
.status-batched { background: #8b5cf620; color: #a78bfa; }
.status-permanently_failed { background: #7f1d1d; color: #fca5a5; }
.status-won { background: #22c55e20; color: #22c55e; }
.status-lost { background: #ef444420; color: #ef4444; }
@@ -270,6 +274,16 @@
<div id="statPending" class="value">-</div>
<div id="statFailed" class="subtext">0 failed</div>
</div>
<div class="stat-card">
<h3>Payout Review</h3>
<div id="statPayoutReview" class="value">-</div>
<div id="statPayoutProcessing" class="subtext">0 processing</div>
</div>
<div class="stat-card">
<h3>Refund Attention</h3>
<div id="statRefundAttention" class="value">-</div>
<div id="statRefundBreakdown" class="subtext">0 requested · 0 processing</div>
</div>
<div class="stat-card">
<h3>Wallet Balance</h3>
<div id="statBalance" class="value">-</div>
@@ -282,6 +296,7 @@
<button class="tab-btn active" data-tab="overview">Overview</button>
<button class="tab-btn" data-tab="queue">Queue</button>
<button class="tab-btn" data-tab="payouts">Payouts</button>
<button class="tab-btn" data-tab="refunds">Refunds</button>
<button class="tab-btn" data-tab="games">Games</button>
<button class="tab-btn" data-tab="users">Users</button>
<button class="tab-btn" data-tab="chat">Chat</button>
@@ -330,12 +345,19 @@
<h3>Payouts</h3>
<select id="payoutStatusFilter">
<option value="">All Status</option>
<option value="attention">Needs Attention</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="failed">Failed</option>
<option value="needs_review">Needs Review</option>
<option value="completed">Completed</option>
<option value="permanently_failed">Permanently Failed</option>
<option value="batched">Batched</option>
</select>
</div>
<p id="payoutTotals" style="color:#888; margin: 10px 15px; font-size:12px;">
Loading payout totals...
</p>
<table>
<thead>
<tr>
@@ -344,11 +366,52 @@
<th>Address</th>
<th>Status</th>
<th>TX Hash</th>
<th>Last Error</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="payoutsTable">
<tr><td colspan="8" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Tab: Refunds -->
<div id="tab-refunds" class="tab-content">
<div class="table-container">
<div class="table-header">
<h3>Payment Refund Outbox</h3>
<select id="refundStatusFilter">
<option value="attention">Needs Attention</option>
<option value="">All Status</option>
<option value="requested">Requested</option>
<option value="processing">Processing</option>
<option value="needs_review">Needs Review</option>
<option value="recorded">Recorded (no transfer)</option>
<option value="completed">Completed</option>
</select>
</div>
<p id="refundTotals" style="color:#888; margin: 10px 15px; font-size:12px;">
Loading refund totals...
</p>
<p style="color:#f59e0b; margin: 10px 15px; font-size:12px;">
Processing and needs-review transfers may already have broadcast. Never resend them from this dashboard.
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Payment</th>
<th>Amount</th>
<th>Address</th>
<th>Status</th>
<th>TX / Error</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="refundsTable">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
@@ -470,6 +533,7 @@
// Payout filter
document.getElementById('payoutStatusFilter').addEventListener('change', loadPayouts);
document.getElementById('refundStatusFilter').addEventListener('change', loadRefunds);
// User search on enter
document.getElementById('userSearch').addEventListener('keypress', (e) => {
@@ -535,6 +599,7 @@
// Load data for tab
if (tab === 'queue') loadQueue();
else if (tab === 'payouts') loadPayouts();
else if (tab === 'refunds') loadRefunds();
else if (tab === 'games') loadGames();
else if (tab === 'users') loadUsers();
else if (tab === 'chat') loadChat();
@@ -558,11 +623,27 @@
document.getElementById('statWinRate').textContent = `${winRate}% win rate`;
const pending = overview.pendingPayouts || 0;
const processing = overview.processingPayouts || 0;
const failed = overview.failedPayouts || 0;
const needsReview = overview.payoutsNeedsReview || 0;
const permanentlyFailed = overview.permanentlyFailedPayouts || 0;
document.getElementById('statPending').textContent = pending;
document.getElementById('statPending').className = 'value' + (pending > 5 ? ' warning' : '');
document.getElementById('statFailed').textContent = `${failed} failed`;
document.getElementById('statFailed').className = 'subtext' + (failed > 0 ? ' danger' : '');
document.getElementById('statFailed').textContent = `${failed} failed · ${permanentlyFailed} permanent`;
document.getElementById('statFailed').className = 'subtext' + (failed + permanentlyFailed > 0 ? ' danger' : '');
document.getElementById('statPayoutReview').textContent = needsReview;
document.getElementById('statPayoutReview').className = 'value' + (needsReview > 0 ? ' danger' : '');
document.getElementById('statPayoutProcessing').textContent = `${processing} processing`;
document.getElementById('statPayoutProcessing').className = 'subtext' + (processing > 0 ? ' warning' : '');
const refundRequested = overview.refunds?.requested || 0;
const refundProcessing = overview.refunds?.processing || 0;
const refundReview = overview.refunds?.needsReview || 0;
const refundAttention = refundRequested + refundProcessing + refundReview;
document.getElementById('statRefundAttention').textContent = refundAttention;
document.getElementById('statRefundAttention').className = 'value' + (refundAttention > 0 ? ' danger' : '');
document.getElementById('statRefundBreakdown').textContent =
`${refundRequested} requested · ${refundProcessing} processing · ${refundReview} review`;
// Update divisor and symbol from server
if (overview.atomicDivisor) atomicDivisor = overview.atomicDivisor;
@@ -615,31 +696,69 @@
}
function renderOutcomesChart(summary) {
const ctx = document.getElementById('outcomesChart').getContext('2d');
const canvas = document.getElementById('outcomesChart');
const ctx = canvas.getContext('2d');
if (outcomesChart) outcomesChart.destroy();
const labels = ['Won/Escaped', 'Caught', 'Expired', 'Other'];
const values = [
(summary.won || 0) + (summary.escaped || 0),
summary.caught || 0,
summary.expired || 0,
Math.max(0, (summary.total || 0) - (summary.won || 0) - (summary.escaped || 0) - (summary.caught || 0) - (summary.expired || 0))
];
const colors = ['#22c55e', '#ef4444', '#f59e0b', '#6b7280'];
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssWidth = Math.max(320, canvas.clientWidth || 480);
const cssHeight = Math.max(200, canvas.clientHeight || 240);
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssWidth, cssHeight);
outcomesChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Won/Escaped', 'Caught', 'Expired', 'Other'],
datasets: [{
data: [
(summary.won || 0) + (summary.escaped || 0),
summary.caught || 0,
summary.expired || 0,
Math.max(0, (summary.total || 0) - (summary.won || 0) - (summary.escaped || 0) - (summary.caught || 0) - (summary.expired || 0))
],
backgroundColor: ['#22c55e', '#ef4444', '#f59e0b', '#6b7280']
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'right', labels: { color: '#888' } }
}
}
const total = values.reduce((sum, value) => sum + value, 0);
const centerX = Math.min(cssWidth * 0.34, cssHeight * 0.5);
const centerY = cssHeight * 0.5;
const radius = Math.max(45, Math.min(cssHeight * 0.36, centerX - 12));
let angle = -Math.PI / 2;
values.forEach((value, index) => {
if (value <= 0 || total <= 0) return;
const next = angle + (value / total) * Math.PI * 2;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, angle, next);
ctx.closePath();
ctx.fillStyle = colors[index];
ctx.fill();
angle = next;
});
ctx.beginPath();
ctx.arc(centerX, centerY, radius * 0.58, 0, Math.PI * 2);
ctx.fillStyle = '#111827';
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.font = '600 18px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(total), centerX, centerY - 5);
ctx.fillStyle = '#9ca3af';
ctx.font = '11px system-ui, sans-serif';
ctx.fillText('games', centerX, centerY + 14);
const legendX = Math.max(centerX + radius + 28, cssWidth * 0.62);
labels.forEach((label, index) => {
const y = centerY - 54 + index * 34;
ctx.fillStyle = colors[index];
ctx.fillRect(legendX, y - 6, 12, 12);
ctx.fillStyle = '#9ca3af';
ctx.textAlign = 'left';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText(`${label}: ${values[index]}`, legendX + 20, y);
});
outcomesChart = {
destroy: () => ctx.clearRect(0, 0, cssWidth, cssHeight)
};
}
// Queue management functions
@@ -729,37 +848,50 @@
const data = await res.json();
const tbody = document.getElementById('payoutsTable');
const totals = data.totals || {};
document.getElementById('payoutTotals').textContent =
`${totals.pending || 0} pending · ${totals.processing || 0} processing · ` +
`${totals.failed || 0} failed · ${totals.needsReview || 0} needs review · ` +
`${totals.permanentlyFailed || 0} permanently failed · ${totals.completed || 0} completed`;
if (!data.payouts?.length) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">No payouts found</td></tr>';
tbody.innerHTML = '<tr><td colspan="8" class="loading">No payouts found</td></tr>';
return;
}
tbody.innerHTML = data.payouts.map(p => `
<tr>
<td>${p.id}</td>
<td>${(p.amount / atomicDivisor).toFixed(6)}</td>
<td title="${escapeHtml(p.address || '')}">${p.address ? escapeHtml(p.address.substring(0, 12)) + '...' : '-'}</td>
<td><span class="status-badge status-${escapeHtml(p.status)}">${escapeHtml(p.status)}</span></td>
<td class="tx-hash">${p.txHash ? escapeHtml(p.txHash.substring(0, 16)) + '...' : '-'}</td>
<td>${new Date(p.createdAt).toLocaleString()}</td>
<td>
${p.status === 'failed' || p.status === 'permanently_failed' ?
`<button class="btn btn-primary" onclick="retryPayout(${p.id})">Retry</button>` : '-'}
</td>
</tr>
`).join('');
tbody.innerHTML = data.payouts.map(p => {
const retryable = (p.status === 'failed' || p.status === 'permanently_failed') && !p.txHash;
const lastError = p.lastError || '';
return `
<tr>
<td>${p.id}</td>
<td title="${escapeHtml(String(p.amount))} atomic">${(p.amount / atomicDivisor).toFixed(6)}</td>
<td title="${escapeHtml(p.address || '')}">${p.address ? escapeHtml(p.address.substring(0, 12)) + '...' : '-'}</td>
<td><span class="status-badge status-${escapeHtml(p.status)}">${escapeHtml(p.status)}</span></td>
<td class="tx-hash">${p.txHash ? escapeHtml(p.txHash.substring(0, 16)) + '...' : '-'}</td>
<td title="${escapeHtml(lastError)}">${lastError ? escapeHtml(lastError.substring(0, 48)) : '-'}</td>
<td>${new Date(p.createdAt).toLocaleString()}</td>
<td>
${retryable ? `<button class="btn btn-primary" onclick="retryPayout(${p.id})">Retry</button>` : '-'}
</td>
</tr>
`;
}).join('');
} catch (e) {
console.error('Failed to load payouts:', e);
}
}
async function retryPayout(id) {
if (!confirm('Queue this payout for retry?')) return;
if (!confirm('Only retry after confirming the recorded failure was pre-broadcast and wallet history contains no matching transfer. Queue this payout?')) return;
try {
const res = await fetch(`/api/admin/payouts/${id}/retry`, {
method: 'POST',
headers: { 'X-Admin-Key': apiKey }
headers: {
'X-Admin-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({ confirm: true })
});
if (res.ok) {
@@ -774,6 +906,49 @@
}
}
async function loadRefunds() {
const status = document.getElementById('refundStatusFilter').value;
const url = `/api/admin/stats/refunds?limit=50${status ? `&status=${status}` : ''}`;
try {
const res = await fetch(url, { headers: { 'X-Admin-Key': apiKey } });
const data = await res.json();
const tbody = document.getElementById('refundsTable');
const totals = data.totals || {};
document.getElementById('refundTotals').textContent =
`${totals.requested || 0} requested · ${totals.processing || 0} processing · ` +
`${totals.needsReview || 0} needs review · ${totals.recorded || 0} recorded · ` +
`${totals.completed || 0} completed`;
if (!data.refunds?.length) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">No refunds found</td></tr>';
return;
}
tbody.innerHTML = data.refunds.map(refund => {
const evidence = refund.txHash
? `TX ${refund.txHash.substring(0, 16)}...`
: (refund.error || refund.reason || '-');
const updated = refund.updatedAt || refund.createdAt;
return `
<tr>
<td>${refund.id}</td>
<td>${refund.paymentId}</td>
<td title="${escapeHtml(String(refund.amount))} atomic">${(refund.amount / atomicDivisor).toFixed(6)}</td>
<td title="${escapeHtml(refund.address || '')}">${refund.address ? escapeHtml(refund.address.substring(0, 12)) + '...' : '-'}</td>
<td><span class="status-badge status-${escapeHtml(refund.status)}">${escapeHtml(refund.status)}</span></td>
<td title="${escapeHtml(evidence)}">${escapeHtml(evidence.substring(0, 56))}</td>
<td>${new Date(updated).toLocaleString()}</td>
</tr>
`;
}).join('');
} catch (e) {
console.error('Failed to load refunds:', e);
document.getElementById('refundsTable').innerHTML =
'<tr><td colspan="7" class="loading">Failed to load refunds</td></tr>';
}
}
async function loadGames() {
try {
const res = await fetch('/api/admin/stats/games?period=24h&limit=50', {
+2 -2
View File
@@ -43,7 +43,7 @@
</div>
<!-- Socket.io -->
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<!-- Initialize socket -->
<script>
@@ -51,7 +51,7 @@
</script>
<!-- Core libraries -->
<script src="js/lib/jquery-3.4.1.min.js"></script>
<script src="js/lib/jquery-3.7.1.min.js"></script>
<script src="js/lib/rot.min.js"></script>
<script src="js/core/options.js"></script>
<script src="js/ui/teletype.js"></script>
+27 -13
View File
@@ -2,13 +2,14 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wownerogue - A Wownero Roguelike</title>
<link rel="stylesheet" type="text/css" href="styles/stylesheet.css">
<link rel="stylesheet" type="text/css" href="styles/commerce-consent.css">
<link rel="icon" type="image/x-icon" href="favicon.ico" id="favicon">
<!-- Socket.io -->
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<!-- Initialize socket with resume token (for persistent anonymous session) -->
<script>
@@ -24,8 +25,7 @@
if (existing) {
// Send the session token in the handshake `auth` payload, NOT the URL
// query string. Query strings leak into proxy/access/referer logs;
// auth is carried in the connection body. (Server keeps a query-string
// fallback for older clients during rollout.)
// auth is carried in the connection body.
opts.auth = { resumeToken: existing };
}
} catch(_) {
@@ -46,9 +46,14 @@
if (data.hostedBy && data.hostedBy.url) {
var container = document.getElementById('hosted-by-container');
if (container) {
container.innerHTML = ' | Hosted by: <a href="' +
data.hostedBy.url + '" target="_blank" rel="noopener" style="color:#888;">' +
(data.hostedBy.name || data.hostedBy.url) + '</a>';
container.textContent = ' | Hosted by: ';
var hostedLink = document.createElement('a');
hostedLink.href = data.hostedBy.url;
hostedLink.target = '_blank';
hostedLink.rel = 'noopener';
hostedLink.style.color = '#888';
hostedLink.textContent = data.hostedBy.name || data.hostedBy.url;
container.appendChild(hostedLink);
}
}
})
@@ -58,9 +63,10 @@
</script>
<!-- Core libraries -->
<script src="js/lib/jquery-3.4.1.min.js"></script>
<script src="js/lib/jquery-3.7.1.min.js"></script>
<script src="js/lib/rot.min.js"></script>
<script src="js/core/escapeHtml.js"></script>
<script src="js/legal/commerceConsent.js"></script>
<script src="js/core/options.js"></script>
<script src="js/ui/teletype.js"></script>
@@ -81,6 +87,7 @@
<script src="js/render/charSprites.js"></script>
<script src="js/render/packRegistry.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="/runtime-config.js"></script>
<script src="js/render/renderModes.js"></script>
<script src="js/render/charCustomize.js"></script>
<script src="js/render/avatarVisuals.js"></script>
@@ -165,7 +172,7 @@
<button id="helpButton" class="hud-chip hud-chip--help hud-chip--push" title="How to play and payment information">❓ Help</button>
<button id="historyButton" class="hud-chip hud-chip--history" title="View your payment and payout history">📜 History</button>
<button id="leaderboardButton" class="hud-chip hud-chip--scores" title="View high scores">🏆 Scores</button>
<button id="manageAddressButton" class="hud-chip hud-chip--address" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
<button id="manageAddressButton" class="hud-chip hud-chip--address" style="display:none;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
<span id="sessionWarning" style="display:none;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
</div>
</div>
@@ -181,6 +188,12 @@
<div class="game-modes" style="margin: 15px 0;">
<div style="color: #888; text-align: center; padding: 20px;">Loading payment options...</div>
</div>
<p style="font-size:11px;color:#aaa;line-height:1.45;">
Paid actions require a current acknowledgement. Review the <a href="/terms" target="_blank" rel="noopener">Terms</a>,
<a href="/privacy" target="_blank" rel="noopener">Privacy Notice</a>, and
<a href="/responsible-play" target="_blank" rel="noopener">Responsible Play</a> guidance.
</p>
<div id="credits-display" style="margin: 10px 0; display: none;">
<strong>Credits remaining: <span id="credits-count">0</span></strong>
@@ -273,6 +286,7 @@
<div class="footer">
<span id="footer-title">Wownero Roguelike v0.1.0 | Use WASD or arrow keys to move</span>
<span id="hosted-by-container"></span>
<span> | <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> · <a href="/responsible-play">Responsible Play</a></span>
</div>
</div>
@@ -341,9 +355,9 @@
</div>
</div>
<div style="margin-bottom:20px;">
<h3 style="color:#4ade80; font-size:1em; margin-bottom:8px;">🎁 Payouts</h3>
<p style="color:#ccc; font-size:0.9em; line-height:1.4;">Set your payout address using the "Manage Payout Address" button. When you escape the dungeon, your winnings will be sent automatically. Escaping with the treasure bag earns a higher multiplier!</p>
<div id="helpPayoutSection" style="margin-bottom:20px;">
<h3 id="helpPayoutTitle" style="color:#4ade80; font-size:1em; margin-bottom:8px;">🎁 Rewards</h3>
<p id="helpPayoutCopy" style="color:#ccc; font-size:0.9em; line-height:1.4;">Reward policy is loading…</p>
</div>
<div style="margin-bottom:15px;">
@@ -352,7 +366,7 @@
<span style="color:#60a5fa;">@</span><span>You (the player)</span>
<span style="color:#4ade80;">></span><span>Exit - escape here to win</span>
<span style="color:#ef4444;">~</span><span>Monster - avoid at all costs</span>
<span style="color:#fbbf24;">$</span><span>Treasure - collect for bonus payout</span>
<span style="color:#fbbf24;">$</span><span id="helpTreasureLegend">Treasure - collect for a score bonus</span>
</div>
</div>
+15 -3
View File
@@ -48,7 +48,11 @@ const InputHandler = {
// If the message is "enter", always send it to server
// The server will handle queueing logic and timing
if (msg.toLowerCase() === 'enter') {
socket.emit('chat message', msg);
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('join_queue')) return false;
} else {
socket.emit('chat message', msg);
}
// Don't automatically switch to waiting screen - let the server control screen state
// The server will send appropriate events (payment_created, waiting_status, etc.)
@@ -157,7 +161,11 @@ const InputHandler = {
window.location.hostname === '127.0.0.1' ||
window.location.protocol === 'file:';
socket.emit('chat message', 'enter');
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('join_queue')) return;
} else {
socket.emit('chat message', 'enter');
}
if (isDebugMode || ScreenManager.canEnterGame()) {
// Check if we should show waiting screen
@@ -217,7 +225,11 @@ const InputHandler = {
}
// Default: attempt immediate start (payment-required modes go through their own flow)
socket.emit('auto_start');
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('auto_start')) return;
} else {
socket.emit('auto_start');
}
// Check if we should show waiting screen
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
+255
View File
@@ -0,0 +1,255 @@
(function (root) {
'use strict';
var policy = null;
var policyPromise = null;
var memoryAcceptance = null;
var STORAGE_KEY = 'wowngeon_paid_ack';
var ACCEPTANCE_KEYS = [
'policyVersion',
'ageEligible',
'termsRead',
'riskAccepted',
'testnetUnderstood'
];
function clearAcceptance() {
memoryAcceptance = null;
try { sessionStorage.removeItem(STORAGE_KEY); } catch (_) {}
}
function loadPolicy(options) {
options = options || {};
if (options.force === true) {
policy = null;
policyPromise = null;
}
if (policy) return Promise.resolve(policy);
if (policyPromise) return policyPromise;
var request = fetch('/api/disclosures', {
credentials: 'same-origin',
cache: 'no-store',
headers: { Accept: 'application/json' }
}).then(function (response) {
if (!response.ok) throw new Error('Disclosure endpoint returned ' + response.status);
return response.json();
}).then(function (value) {
if (!value
|| typeof value.policyVersion !== 'string'
|| value.policyVersion.length < 1
|| value.policyVersion.length > 64
|| !value.service) throw new Error('Disclosure response is incomplete');
// A forced refresh can supersede an older request. Only the latest in-flight request
// may publish its result into the cache.
if (policyPromise === request) policy = value;
return value;
}).catch(function (error) {
if (policyPromise === request) policyPromise = null;
throw error;
});
policyPromise = request;
return request;
}
function canonicalAcceptance(value) {
if (!value || !policy || typeof value !== 'object' || Array.isArray(value)) return null;
var keys = Object.keys(value);
if (keys.length !== ACCEPTANCE_KEYS.length
|| keys.some(function (key) { return ACCEPTANCE_KEYS.indexOf(key) === -1; })) return null;
if (typeof value.policyVersion !== 'string'
|| value.policyVersion.length < 1
|| value.policyVersion.length > 64
|| value.policyVersion !== policy.policyVersion) return null;
if (value.ageEligible !== true || value.termsRead !== true || value.riskAccepted !== true) return null;
if (typeof value.testnetUnderstood !== 'boolean'
|| value.testnetUnderstood !== (policy.service.isTestNetwork === true)) return null;
return {
policyVersion: policy.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: policy.service.isTestNetwork === true
};
}
function readAcceptance() {
var value = memoryAcceptance;
try { value = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null') || value; } catch (_) {}
var canonical = canonicalAcceptance(value);
if (!canonical) {
clearAcceptance();
return null;
}
memoryAcceptance = canonical;
return canonical;
}
function acknowledgement() {
var accepted = readAcceptance();
return accepted ? Object.assign({}, accepted) : null;
}
function rememberAcceptance() {
memoryAcceptance = {
policyVersion: policy.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: policy.service.isTestNetwork === true
};
try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(memoryAcceptance)); } catch (_) {}
return acknowledgement();
}
function reject(details) {
details = details || {};
var reportedVersion = typeof details.policyVersion === 'string'
&& details.policyVersion.length > 0
&& details.policyVersion.length <= 64
? details.policyVersion
: null;
var changed = reportedVersion && (!policy || reportedVersion !== policy.policyVersion);
clearAcceptance();
if (changed || details.code === 'PAID_ACK_VERSION') {
return loadPolicy({ force: true });
}
return policy ? Promise.resolve(policy) : loadPolicy();
}
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function checkbox(id, labelText) {
var label = el('label', 'commerce-check');
var input = document.createElement('input');
input.type = 'checkbox'; input.id = id;
label.appendChild(input);
label.appendChild(el('span', '', labelText));
return { label: label, input: input };
}
function showModal(resolvedPolicy, onAccept) {
var existing = document.getElementById('commerceConsentOverlay');
if (existing) existing.remove();
var overlay = el('div', 'commerce-consent-overlay');
overlay.id = 'commerceConsentOverlay';
overlay.setAttribute('role', 'presentation');
var dialog = el('section', 'commerce-consent-dialog');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'commerceConsentTitle');
var title = el('h2', '', 'Review paid-play disclosures');
title.id = 'commerceConsentTitle';
dialog.appendChild(title);
var service = resolvedPolicy.service;
var summary;
if (service.isTestNetwork) {
summary = service.currencyLabel + ' is valueless test-network currency. Never send mainnet ' + service.cryptoType + '.';
} else if (service.paidPrestigeOnly) {
summary = 'This paid mode purchases entry, credits, or products; this server currently offers no cryptocurrency prize.';
} else if (service.anyPayoutsEnabled) {
summary = 'This server has reward-enabled modes. A paid entry can lose its entire entry value; displayed rewards apply only to qualifying outcomes.';
} else {
summary = 'Blockchain payments may be irreversible. Verify the asset, network, amount, and address before sending.';
}
dialog.appendChild(el('p', 'commerce-summary', summary));
var age = checkbox('commerceAge', 'I confirm I am at least ' + resolvedPolicy.minimumAge + ' and may lawfully use this paid mode where I am located.');
var terms = checkbox('commerceTerms', 'I have read the Terms of Use and Privacy Notice, including the anonymous-session and blockchain risks.');
var risk = checkbox('commerceRisk', service.paidPrestigeOnly
? 'I understand this purchase does not currently pay a cryptocurrency prize and a blockchain transfer may be irreversible.'
: 'I understand paid play can lose the entire entry value, and previous outcomes do not predict future results.');
var required = [age.input, terms.input, risk.input];
dialog.appendChild(age.label);
dialog.appendChild(terms.label);
dialog.appendChild(risk.label);
if (service.isTestNetwork) {
var testnet = checkbox('commerceTestnet', 'I understand test coins have no intended monetary value and I will not send mainnet funds.');
required.push(testnet.input);
dialog.appendChild(testnet.label);
}
var links = el('p', 'commerce-links');
[
['Terms', resolvedPolicy.links.terms],
['Privacy', resolvedPolicy.links.privacy],
['Responsible play', resolvedPolicy.links.responsiblePlay]
].forEach(function (entry, index) {
if (index) links.appendChild(document.createTextNode(' · '));
var link = el('a', '', entry[0]);
link.href = entry[1]; link.target = '_blank'; link.rel = 'noopener';
links.appendChild(link);
});
dialog.appendChild(links);
var status = el('p', 'commerce-status', 'All boxes are required. This statement does not verify identity, age, or location.');
status.setAttribute('aria-live', 'polite');
dialog.appendChild(status);
var actions = el('div', 'commerce-actions');
var cancel = el('button', 'commerce-cancel', 'Cancel'); cancel.type = 'button';
var accept = el('button', 'commerce-accept', 'Acknowledge and continue'); accept.type = 'button'; accept.disabled = true;
actions.appendChild(cancel); actions.appendChild(accept); dialog.appendChild(actions);
overlay.appendChild(dialog); document.body.appendChild(overlay);
function update() { accept.disabled = !required.every(function (input) { return input.checked; }); }
required.forEach(function (input) { input.addEventListener('change', update); });
cancel.addEventListener('click', function () { clearAcceptance(); overlay.remove(); });
accept.addEventListener('click', function () {
if (accept.disabled) return;
var ack = rememberAcceptance();
overlay.remove();
onAccept(ack);
});
age.input.focus();
}
function requireAcknowledgement(onAccept) {
loadPolicy().then(function (resolvedPolicy) {
var current = acknowledgement();
if (current) {
onAccept(current);
return;
}
showModal(resolvedPolicy, onAccept);
}).catch(function () {
clearAcceptance();
var existing = document.getElementById('commerceConsentOverlay');
if (existing) existing.remove();
var overlay = el('div', 'commerce-consent-overlay');
overlay.id = 'commerceConsentOverlay';
var dialog = el('section', 'commerce-consent-dialog');
dialog.setAttribute('role', 'alertdialog');
dialog.appendChild(el('h2', '', 'Paid play is unavailable'));
dialog.appendChild(el('p', 'commerce-summary', 'The current disclosures could not be loaded. No payment or paid entry was requested. Try again after the connection recovers.'));
var close = el('button', 'commerce-cancel', 'Close'); close.type = 'button';
close.addEventListener('click', function () { overlay.remove(); });
dialog.appendChild(close); overlay.appendChild(dialog); document.body.appendChild(overlay);
});
}
function attach(payload) {
var result = Object.assign({}, payload || {});
var ack = acknowledgement();
if (ack) result.legalAcknowledgement = ack;
return result;
}
var api = {
acknowledgement: acknowledgement,
attach: attach,
clear: clearAcceptance,
loadPolicy: loadPolicy,
reject: reject,
require: requireAcknowledgement
};
root.CommerceConsent = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
})(typeof window !== 'undefined' ? window : globalThis);
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
# jQuery 3.7.1 provenance
- Upstream: jQuery 3.7.1, OpenJS Foundation, MIT licence (`jquery.org/license`)
- Local source: Ubuntu package `libjs-jquery` version `3.7.1+dfsg+~3.5.33-1build1`
- Source file: `/usr/share/javascript/jquery/jquery.min.js`
- Vendored file SHA-256: `3289f31657004339ed678f16c4e626987e57303f2107577ed4973f6c9e105260`
- Vendored on: 2026-07-21
The application serves this same-origin copy. No runtime CDN or package-registry request is needed.
File diff suppressed because one or more lines are too long
+333 -85
View File
@@ -1,88 +1,285 @@
(function () {
'use strict';
const socket = io({ transports: ['websocket', 'polling'] });
var socketOpts = { transports: ['websocket', 'polling'] };
try {
var resumeToken = localStorage.getItem('wownerogue_token');
if (resumeToken) socketOpts.auth = { resumeToken: resumeToken };
} catch (_) {}
var socket = io(socketOpts);
var RK = window.RK || {};
const els = {
var els = {
economies: document.getElementById('economies'),
status: document.getElementById('status'),
queueBtn: document.getElementById('queueBtn'),
leaveBtn: document.getElementById('leaveBtn'),
queuePanel: document.getElementById('queuePanel'),
racePanel: document.getElementById('racePanel'),
raceMode: document.getElementById('raceMode'),
raceEconomy: document.getElementById('raceEconomy'),
raceTimer: document.getElementById('raceTimer'),
racePlace: document.getElementById('racePlace'),
stage: document.getElementById('stage')
raceResult: document.getElementById('raceResult'),
stage: document.getElementById('stage'),
renderModes: document.getElementById('renderModes'),
modeTitle: document.getElementById('modeTitle'),
modeDescription: document.getElementById('modeDescription'),
modeBadge: document.getElementById('modeBadge'),
rulesetCatalog: document.getElementById('rulesetCatalog'),
economyHelp: document.getElementById('economyHelp'),
objectiveHint: document.getElementById('objectiveHint'),
dpad: document.getElementById('matchDpad')
};
let gameModeInfo = null;
let selectedEconomy = 'free';
let inQueue = false;
let inRace = false;
let mySocketId = null;
let renderer = null;
let mode = 'tiles';
let keyState = {};
var ECONOMIES = {
free: { label: 'Free / Pleb', description: 'No entry cost · separate free board for competitive modes', available: 'FREE ENTRY' },
credits_prestige: { label: 'Prestige', description: 'Spend credits · PvP Prestige board only', available: 'PAID CREDITS' },
crypto_race: { label: 'Stagenet Stakes', description: 'Race-entry ticket · Champions board and the disclosed pot payout', available: 'STAGENET / CRYPTO' }
};
var MatchCopy = window.MatchUiCopy;
function setStatus(msg) {
els.status.textContent = msg;
var gameModeInfo = null;
var activeRuleset = MatchCopy.normalize({ id: 'race' });
var selectedEconomy = 'free';
var inQueue = false;
var inRace = false;
var mySocketId = socket.id || null;
var renderer = null;
var rendererPending = false;
var rendererToken = 0;
var camera = null;
var mode = (RK.loadMode && RK.loadMode('tiles')) || 'tiles';
var keyState = {};
var lastState = null;
var tickMs = 250;
var countdownTimer = null;
var resultTimer = null;
function setStatus(msg) { els.status.textContent = msg; }
function verifyMatchReveal(fairness) {
if (!fairness || !fairness.seed || !/^[0-9a-f]{64}$/i.test(fairness.seedHash || '')) {
return Promise.resolve(null);
}
if (!window.crypto || !window.crypto.subtle || typeof TextEncoder === 'undefined') {
return Promise.resolve(null);
}
return window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(fairness.seed))
.then(function (digest) {
var actual = Array.from(new Uint8Array(digest))
.map(function (byte) { return byte.toString(16).padStart(2, '0'); }).join('');
return actual === String(fairness.seedHash).toLowerCase();
}).catch(function () { return null; });
}
function matchInfo() {
return gameModeInfo && gameModeInfo.modes && gameModeInfo.modes.match;
}
function economyLabel(id) { return (ECONOMIES[id] && ECONOMIES[id].label) || id || 'Match'; }
function normalizedRuleset(raw) {
return MatchCopy.normalize(raw);
}
function applyRuleset(raw) {
activeRuleset = normalizedRuleset(raw);
els.modeTitle.textContent = activeRuleset.label;
els.modeDescription.textContent = activeRuleset.description;
els.modeBadge.textContent = activeRuleset.badge || 'LIVE MATCH';
els.objectiveHint.textContent = 'Arrow keys / WASD · ' + activeRuleset.objective + ' · drag or pinch the camera';
els.economyHelp.textContent = activeRuleset.cooperative
? 'Co-op is a team result and does not create an individual leaderboard winner.'
: 'Free, Prestige, and Champions results remain on separate boards.';
}
function renderRulesets() {
var info = matchInfo() || {};
applyRuleset(info.activeRuleset || { id: info.rulesetId || 'race' });
els.rulesetCatalog.innerHTML = '';
var catalog = Array.isArray(info.rulesets) && info.rulesets.length ? info.rulesets : [activeRuleset];
catalog.forEach(function (raw) {
var r = normalizedRuleset(raw);
var chip = document.createElement('span');
chip.className = 'tag' + (r.id === activeRuleset.id ? ' active' : '');
chip.textContent = r.label;
chip.title = r.id === activeRuleset.id ? 'Active on this server' : 'Available to the operator';
els.rulesetCatalog.appendChild(chip);
});
}
function renderEconomies() {
if (!gameModeInfo || !gameModeInfo.modes || !gameModeInfo.modes.match || !gameModeInfo.modes.match.enabled) {
var info = matchInfo();
if (!info || !info.enabled) {
els.economies.innerHTML = '';
setStatus('Match mode is not enabled on this server.');
setStatus('Multiplayer mode is not enabled on this server.');
els.queueBtn.disabled = true;
return;
}
const available = gameModeInfo.modes.match.economies || {};
const labels = {
free: 'Free',
credits_prestige: 'Prestige Credits',
crypto_race: 'Crypto Race'
};
var available = info.economies || {};
var availableKeys = Object.keys(ECONOMIES).filter(function (key) { return !!available[key]; });
if (!available[selectedEconomy] && availableKeys.length) selectedEconomy = availableKeys[0];
els.economies.innerHTML = '';
Object.keys(labels).forEach(function (key) {
const locked = !available[key];
const el = document.createElement('div');
el.className = 'economy' + (key === selectedEconomy ? ' active' : '') + (locked ? ' locked' : '');
el.textContent = labels[key];
if (!locked) {
el.addEventListener('click', function () {
selectedEconomy = key;
renderEconomies();
});
}
els.economies.appendChild(el);
Object.keys(ECONOMIES).forEach(function (key) {
var def = ECONOMIES[key];
var locked = !available[key];
var button = document.createElement('button');
button.type = 'button';
button.className = 'economy' + (key === selectedEconomy && !locked ? ' active' : '') + (locked ? ' locked' : '');
button.dataset.economy = key;
button.disabled = locked;
button.setAttribute('aria-pressed', key === selectedEconomy && !locked ? 'true' : 'false');
var title = document.createElement('strong'); title.textContent = def.label;
var desc = document.createElement('small'); desc.textContent = def.description;
var avail = document.createElement('small'); avail.className = 'availability'; avail.textContent = locked ? 'NOT ENABLED' : def.available;
button.appendChild(title); button.appendChild(desc); button.appendChild(avail);
if (!locked) button.addEventListener('click', function () {
selectedEconomy = key;
renderEconomies();
});
els.economies.appendChild(button);
});
els.queueBtn.disabled = availableKeys.length === 0;
}
els.queueBtn.disabled = false;
function renderModeButtons() {
if (!els.renderModes || !RK.RENDER_MODES) return;
els.renderModes.innerHTML = '';
RK.RENDER_MODES.forEach(function (meta) {
var availability = RK.modeAvailability
? RK.modeAvailability(meta.id)
: { usable: !RK.canUseMode || RK.canUseMode(meta.id), runtimeAvailable: true };
var usable = availability.usable;
var b = document.createElement('button');
b.type = 'button'; b.textContent = meta.label
+ (!availability.runtimeAvailable ? ' ⛔' : (meta.premium ? ' ★' : ''));
b.className = (meta.id === mode ? 'active ' : '') + (usable ? '' : 'locked');
b.disabled = !usable;
if (!availability.runtimeAvailable) {
b.title = 'Unavailable on this server: optional renderer dependency disabled.';
} else if (!usable) b.title = 'Unlock a compatible render pack with credits.';
b.addEventListener('click', function () { switchRenderMode(meta.id); });
els.renderModes.appendChild(b);
});
}
function defaultZoomForMode(name) { return name === 'iso' ? 0.82 : (name === '3d' ? 1.15 : 1.45); }
function ensureCamera() {
if (!camera && RK.attachCamera) camera = RK.attachCamera(els.stage, { zoom: defaultZoomForMode(mode), min: 0.5, max: 3.2 });
return camera;
}
function destroyRenderer() {
rendererToken++;
rendererPending = false;
if (renderer && renderer.destroy) { try { renderer.destroy(); } catch (_) {} }
renderer = null;
}
function ensureRenderer() {
if (renderer || rendererPending) return;
rendererPending = true;
var token = ++rendererToken;
var done = function (next) {
if (token !== rendererToken) { if (next && next.destroy) next.destroy(); return; }
rendererPending = false;
renderer = next;
ensureCamera();
if (camera) camera.setZoom(defaultZoomForMode(renderer.name || mode));
renderState(lastState);
};
if (RK.createRendererAsync) RK.createRendererAsync(mode, els.stage, { cell: 26 }, done);
else if (RK.createRenderer) done(RK.createRenderer(mode, els.stage, { cell: 26 }));
}
function switchRenderMode(next) {
if (next === mode || (RK.canUseMode && !RK.canUseMode(next))) return;
mode = next;
if (RK.saveMode) RK.saveMode(mode);
renderModeButtons();
destroyRenderer();
if (inRace && lastState) ensureRenderer();
}
function renderState(state) {
if (!state || !renderer) return;
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, {
viewerId: mySocketId,
cryptoType: gameModeInfo && gameModeInfo.cryptoType
}) : null;
if (!scene) return;
renderer.render(scene);
if (camera) camera.update(renderer);
}
function clearCountdown() {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = null;
}
function showCountdown(data) {
clearCountdown();
var ruleset = MatchCopy.fromPayload(data, activeRuleset);
applyRuleset(ruleset);
var ends = Date.now() + Math.max(0, Number(data.countdownMs) || 0);
function update() {
var left = Math.max(0, ends - Date.now());
var count = data.players ? data.players.length : 0;
setStatus('Match found · ' + count + ' ' + MatchCopy.plural(ruleset, count) + ' · starts in ' + (left / 1000).toFixed(1) + 's');
if (left <= 0) clearCountdown();
}
update();
countdownTimer = setInterval(update, 100);
}
function startRace(data, initialState) {
clearCountdown();
if (resultTimer) clearTimeout(resultTimer);
inQueue = false;
inRace = true;
applyRuleset(MatchCopy.fromPayload({ ruleset: data && data.ruleset, state: initialState }, activeRuleset));
tickMs = Math.max(50, Number(data && data.tickMs) || tickMs || 250);
els.queuePanel.classList.add('hidden');
els.racePanel.classList.remove('hidden');
els.raceResult.classList.add('hidden');
els.raceResult.textContent = '';
els.raceResult.title = '';
els.raceMode.textContent = activeRuleset.label;
els.raceEconomy.textContent = economyLabel((initialState && initialState.economy) || selectedEconomy);
els.raceTimer.textContent = '0.0s';
els.racePlace.textContent = 'Waiting for first move';
renderModeButtons();
lastState = initialState || null;
ensureRenderer();
if (lastState) renderState(lastState);
}
socket.on('connect', function () {
mySocketId = socket.id;
setStatus('Connected. Waiting for server info…');
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
socket.emit('match_reconnect', {});
setStatus(inRace ? 'Reconnected to the arena.' : 'Connected. Loading server modes…');
});
socket.on('session_token', function (data) {
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
});
socket.on('session_resumed', function (data) {
try { if (data && data.token) localStorage.setItem('wownerogue_token', data.token); } catch (_) {}
});
socket.on('game_mode_info', function (info) {
gameModeInfo = info;
gameModeInfo = info || {};
if (RK.setEntitlementSnapshot && info && info.entitlements) RK.setEntitlementSnapshot(info.entitlements);
renderRulesets();
renderEconomies();
if (gameModeInfo?.modes?.match?.enabled) {
setStatus('Ready to queue. Choose an economy and click Join Queue.');
renderModeButtons();
if (matchInfo() && matchInfo().enabled && !inQueue && !inRace) {
setStatus('Ready. Choose an entry mode and join the next ' + activeRuleset.label.toLowerCase() + '.');
}
});
socket.on('match_queue_joined', function (res) {
if (res.success) {
els.queueBtn.disabled = false;
if (res && res.success) {
inQueue = true;
els.queueBtn.classList.add('hidden');
els.leaveBtn.classList.remove('hidden');
setStatus('Queued for ' + selectedEconomy + ' (position ' + (res.position || '?') + '). Waiting for next race…');
setStatus('Queued for ' + economyLabel(selectedEconomy) + ' · position ' + (res.position || '?') + ' · waiting for the next block.');
} else {
setStatus('Queue failed: ' + (res.reason || 'unknown'));
setStatus('Queue failed: ' + ((res && (res.reason || res.message)) || 'unknown error'));
}
});
@@ -90,86 +287,137 @@
inQueue = false;
els.queueBtn.classList.remove('hidden');
els.leaveBtn.classList.add('hidden');
setStatus(res.success ? 'Left queue.' : 'Leave failed: ' + (res.reason || 'unknown'));
setStatus(res && res.success ? 'Left the queue. Any held entry was returned.' : 'Could not leave: ' + ((res && res.reason) || 'unknown error'));
});
socket.on('match_joined', function (data) {
setStatus('Race starting! Seed: ' + data.seedHash.slice(0, 12) + '…');
if (!data) return;
selectedEconomy = data.economy || selectedEconomy;
showCountdown(data);
});
socket.on('match_start', function (data) {
inQueue = false;
inRace = true;
els.queuePanel.classList.add('hidden');
els.racePanel.classList.remove('hidden');
els.raceEconomy.textContent = 'Economy: ' + selectedEconomy;
els.raceTimer.textContent = 'Timer: 0s';
els.racePlace.textContent = 'Place: —';
if (!renderer) {
renderer = RK.createRenderer(mode, els.stage, {});
}
socket.on('match_start', function (data) { startRace(data || {}, null); });
socket.on('match_rejoined', function (data) {
if (!data || !data.state) return;
selectedEconomy = data.state.economy || selectedEconomy;
startRace({ tickMs: tickMs }, data.state);
});
socket.on('match_tick', function (data) {
if (!renderer || !data.state) return;
const scene = RK.sceneFromGameState ? RK.sceneFromGameState(data.state, mySocketId) : null;
if (scene) renderer.render(scene);
const me = data.state.players.find(function (p) { return p.you; });
if (me) {
els.racePlace.textContent = 'Place: ' + (me.placement || '—') + (me.alive ? '' : ' 💀');
}
if (!data || !data.state) return;
if (!inRace) startRace({ tickMs: tickMs }, data.state);
lastState = data.state;
ensureRenderer();
renderState(lastState);
var tick = Number(data.tick != null ? data.tick : lastState.tick) || 0;
els.raceTimer.textContent = (tick * tickMs / 1000).toFixed(1) + 's';
els.racePlace.textContent = MatchCopy.liveSummary(lastState, mySocketId, activeRuleset);
});
socket.on('match_end', function (data) {
data = data || {};
inRace = false;
const me = data.players.find(function (p) { return p.id === mySocketId; });
setStatus('Race over! ' + (data.reason || '') + ' — your placement: ' + (me ? me.placement : '—'));
setTimeout(function () {
if (!inRace) return;
var result = MatchCopy.finalResult(data, mySocketId, activeRuleset);
applyRuleset(result.mode);
els.raceResult.textContent = result.text;
if (data.fairness && data.fairness.seed) {
els.raceResult.title = 'Seed: ' + data.fairness.seed
+ '\nCommitment: ' + data.fairness.seedHash
+ '\nSource: ' + (data.fairness.scope || data.fairness.version || 'commitment');
verifyMatchReveal(data.fairness).then(function (valid) {
if (valid === true) els.raceResult.textContent += ' · seed proof ✓';
else if (valid === false) els.raceResult.textContent += ' · seed proof FAILED';
else els.raceResult.textContent += ' · seed revealed';
});
}
els.raceResult.classList.remove('hidden');
resultTimer = setTimeout(function () {
if (inRace) return;
els.racePanel.classList.add('hidden');
els.queuePanel.classList.remove('hidden');
els.raceResult.classList.add('hidden');
els.queueBtn.classList.remove('hidden');
els.leaveBtn.classList.add('hidden');
lastState = null;
destroyRenderer();
setStatus('Ready for another ' + activeRuleset.label.toLowerCase() + '.');
}, 5000);
});
socket.on('match_error', function (err) {
setStatus('Match error: ' + (err.reason || 'unknown'));
socket.on('match_settlement_pending', function () {
// The server deliberately withholds match_end until its authoritative finish transaction
// commits. Keep this player in the arena mapping while the bounded retry runs.
els.raceResult.textContent = 'Result pending durable settlement · retrying safely…';
els.raceResult.classList.remove('hidden');
setStatus('The arena result is not final yet. No final result has been published.');
});
socket.on('match_error', function (err) {
setStatus('Arena error: ' + ((err && (err.message || err.reason)) || 'unknown error'));
});
socket.on('commerce_ack_required', function (err) {
setStatus((err && (err.message || err.error)) || 'Review the paid-play disclosures before joining.');
if (window.CommerceConsent && window.CommerceConsent.require) {
var refreshed = window.CommerceConsent.reject
? window.CommerceConsent.reject(err)
: Promise.resolve();
refreshed.catch(function () { return null; }).then(function () {
window.CommerceConsent.require(function () {
setStatus('Disclosures acknowledged. Retry the paid queue when ready.');
});
});
}
});
socket.on('disconnect', function () {
setStatus('Disconnected. Reconnecting…');
inQueue = false;
inRace = false;
clearCountdown();
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
setStatus(inRace ? 'Connection lost · attempting to rejoin your match…' : 'Disconnected · reconnecting…');
});
els.queueBtn.addEventListener('click', function () {
if (inQueue || inRace) return;
socket.emit('match_queue', { economy: selectedEconomy, action: 'join' });
function join(acknowledgement) {
els.queueBtn.disabled = true;
setStatus('Joining ' + economyLabel(selectedEconomy) + '…');
var payload = { economy: selectedEconomy, action: 'join' };
if (acknowledgement) payload.legalAcknowledgement = acknowledgement;
socket.emit('match_queue', payload);
setTimeout(function () { if (!inQueue && !inRace) els.queueBtn.disabled = false; }, 1500);
}
if (selectedEconomy !== 'free' && window.CommerceConsent) {
window.CommerceConsent.require(join);
} else {
join(null);
}
});
els.leaveBtn.addEventListener('click', function () {
if (!inQueue) return;
socket.emit('match_queue', { economy: selectedEconomy, action: 'leave' });
});
function sendMove(dx, dy) {
if (!inRace) return;
if (!inRace || !socket.connected) return;
socket.emit('match_move', { dx: dx, dy: dy });
}
function directionMove(dir) {
if (dir === 'up') sendMove(0, -1);
else if (dir === 'down') sendMove(0, 1);
else if (dir === 'left') sendMove(-1, 0);
else if (dir === 'right') sendMove(1, 0);
}
window.addEventListener('keydown', function (e) {
if (keyState[e.key]) return; // already pressed
if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
if (keyState[e.key]) return;
keyState[e.key] = true;
if (['ArrowUp', 'w', 'W'].includes(e.key)) { e.preventDefault(); sendMove(0, -1); }
else if (['ArrowDown', 's', 'S'].includes(e.key)) { e.preventDefault(); sendMove(0, 1); }
else if (['ArrowLeft', 'a', 'A'].includes(e.key)) { e.preventDefault(); sendMove(-1, 0); }
else if (['ArrowRight', 'd', 'D'].includes(e.key)) { e.preventDefault(); sendMove(1, 0); }
if (['ArrowUp', 'w', 'W'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('up'); }
else if (['ArrowDown', 's', 'S'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('down'); }
else if (['ArrowLeft', 'a', 'A'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('left'); }
else if (['ArrowRight', 'd', 'D'].indexOf(e.key) !== -1) { e.preventDefault(); directionMove('right'); }
});
window.addEventListener('keyup', function (e) {
keyState[e.key] = false;
window.addEventListener('keyup', function (e) { keyState[e.key] = false; });
window.addEventListener('blur', function () { keyState = {}; });
if (els.dpad) Array.prototype.forEach.call(els.dpad.querySelectorAll('[data-dir]'), function (b) {
// Native click covers mouse/touch plus Enter and Space activation for keyboard and AT users.
b.addEventListener('click', function () { directionMove(b.dataset.dir); });
});
}());
+28 -13
View File
@@ -52,11 +52,19 @@ const SmirkAuth = {
}
const socketId = window.socket.id;
let sessionToken = '';
try { sessionToken = localStorage.getItem('wownerogue_token') || ''; } catch (_) {}
if (!sessionToken) {
throw new Error('Your game session is still being established. Please retry in a moment.');
}
// Step 1: Get challenge from server
const challengeRes = await fetch('/api/auth/smirk/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-Session-Token': sessionToken
},
body: JSON.stringify({ socketId })
});
@@ -73,7 +81,7 @@ const SmirkAuth = {
if (this._supportsNip98()) {
try {
const result = await this._verifyNip98(socketId, challenge);
const result = await this._verifyNip98(socketId, challenge, sessionToken);
verifyData = result.verifyData;
provenPublicKey = result.pubkey;
} catch (nip98Err) {
@@ -86,12 +94,12 @@ const SmirkAuth = {
|| /reject|cancel|denied|declined/i.test((nip98Err && nip98Err.message) || '');
if (userCancelled) throw nip98Err;
console.warn('Smirk NIP-98 failed, trying legacy connect():', nip98Err && nip98Err.message);
const result = await this._verifyLegacy(socketId, challenge);
const result = await this._verifyLegacy(socketId, challenge, sessionToken);
verifyData = result.verifyData;
provenPublicKey = result.pubkey;
}
} else {
const result = await this._verifyLegacy(socketId, challenge);
const result = await this._verifyLegacy(socketId, challenge, sessionToken);
verifyData = result.verifyData;
provenPublicKey = result.pubkey;
}
@@ -140,7 +148,7 @@ const SmirkAuth = {
*
* @returns {Promise<{verifyData: object, pubkey: string|null}>}
*/
async _verifyNip98(socketId, challenge) {
async _verifyNip98(socketId, challenge, sessionToken) {
// Build the NIP-98 HTTP auth event. The wallet fills in pubkey/id/sig.
const evt = {
kind: 27235,
@@ -173,7 +181,10 @@ const SmirkAuth = {
const verifyRes = await fetch('/api/auth/smirk/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-Session-Token': sessionToken
},
body: JSON.stringify({ socketId, event: signed })
});
@@ -192,7 +203,7 @@ const SmirkAuth = {
*
* @returns {Promise<{verifyData: object, pubkey: string|null}>}
*/
async _verifyLegacy(socketId, challenge) {
async _verifyLegacy(socketId, challenge, sessionToken) {
// Connect to Smirk extension
let keys;
try {
@@ -226,7 +237,10 @@ const SmirkAuth = {
// Verify signature with backend
const verifyRes = await fetch('/api/auth/smirk/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-Session-Token': sessionToken
},
body: JSON.stringify({
socketId,
challenge,
@@ -374,7 +388,7 @@ const SmirkAuth = {
*/
init() {
// Check if server has Smirk disabled (e.g., for Monero stagenet)
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._smirkEnabled === false) {
if (typeof SocketHandlers === 'undefined' || SocketHandlers._smirkEnabled !== true) {
console.log('Smirk integration disabled by server');
return;
}
@@ -449,12 +463,12 @@ const SmirkAuth = {
$(document).ready(function() {
// Wait for game_mode_info to arrive first (typically within 2 seconds)
// SocketHandlers will call SmirkAuth.init() when it receives game_mode_info
// This fallback is for cases where game_mode_info doesn't include smirkEnabled
// Fail closed: the server must explicitly advertise smirkEnabled=true.
setTimeout(() => {
// Only auto-init if not already initialized by SocketHandlers
// and if SocketHandlers._smirkEnabled is not explicitly false
// and only when the server explicitly enabled it
if (!SmirkAuth._initialized &&
(typeof SocketHandlers === 'undefined' || SocketHandlers._smirkEnabled !== false)) {
typeof SocketHandlers !== 'undefined' && SocketHandlers._smirkEnabled === true) {
SmirkAuth.init();
}
}, 3000);
@@ -464,7 +478,8 @@ $(document).ready(function() {
if (typeof window.socket !== 'undefined') {
window.socket.on('connect', function() {
setTimeout(() => {
if (SmirkAuth.isAvailable() && !SmirkAuth._isLinked) {
if (typeof SocketHandlers !== 'undefined' && SocketHandlers._smirkEnabled === true &&
SmirkAuth.isAvailable() && !SmirkAuth._isLinked) {
SmirkAuth.checkStatus().then(status => {
const btn = $('#smirkLoginBtn');
if (btn.length > 0 && status.linked) {
+165 -22
View File
@@ -18,6 +18,74 @@ const SocketHandlers = {
_directPayoutsEnabled: false,
_paymentsEnabled: false,
_isSmirkExplicitlyEnabled: function(data) {
return !!data && data.smirkEnabled === true;
},
_fairnessOffer: null,
_pendingFairnessAttempt: null,
_randomClientSeed: function() {
try {
if (!window.crypto || typeof window.crypto.getRandomValues !== 'function') return null;
var bytes = new Uint8Array(32);
window.crypto.getRandomValues(bytes);
return Array.prototype.map.call(bytes, function(b) { return b.toString(16).padStart(2, '0'); }).join('');
} catch (e) { return null; }
},
/** Build a start payload only after receiving the server's commitment. */
fairnessAttempt: function(extra) {
var offer = this._fairnessOffer;
if (!offer || !offer.offerId || !/^[0-9a-f]{64}$/i.test(String(offer.commitment || ''))) {
socket.emit('fairness_offer_request');
$('#messages').append($('<li class="status" style="color:#ffcc00;">').text('Preparing a fairness commitment — try again in a moment.'));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
return null;
}
// Generate the player's contribution only AFTER selecting the published commitment.
var clientSeed = this._randomClientSeed();
if (!clientSeed) {
$('#messages').append($('<li class="error">').text('Secure randomness is unavailable in this browser; the game was not started.'));
return null;
}
var payload = Object.assign({}, extra || {}, {
fairnessOfferId: offer.offerId,
clientSeed: clientSeed
});
this._pendingFairnessAttempt = {
offerId: offer.offerId,
commitment: offer.commitment,
clientSeed: clientSeed
};
return payload;
},
emitFairGameStart: function(eventName, extra, acknowledgementReady) {
var paidIntent = !(extra && extra.free === true);
var existingAcknowledgement = paidIntent
&& typeof CommerceConsent !== 'undefined'
&& CommerceConsent.acknowledgement
? CommerceConsent.acknowledgement()
: null;
if (paidIntent && !acknowledgementReady && !existingAcknowledgement
&& typeof CommerceConsent !== 'undefined' && CommerceConsent.require) {
CommerceConsent.require(function(acknowledgement) {
var acknowledgedExtra = Object.assign({}, extra || {});
if (acknowledgement) acknowledgedExtra.legalAcknowledgement = acknowledgement;
if (SocketHandlers.emitFairGameStart(eventName, acknowledgedExtra, true)) {
$('#entryChoiceOverlay').remove();
if (typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) ScreenManager.drawWaitingScreen();
}
});
return false;
}
var acknowledgedPayload = Object.assign({}, extra || {});
if (existingAcknowledgement) acknowledgedPayload.legalAcknowledgement = existingAcknowledgement;
var payload = this.fairnessAttempt(acknowledgedPayload);
if (!payload) return false;
socket.emit(eventName, payload);
return true;
},
payoutAddressRequired: function() {
if (this._gameMode === 'PAID_SINGLE' && this._directPayoutsEnabled) return true;
@@ -114,6 +182,11 @@ const SocketHandlers = {
registerEventHandlers: function() {
// Connection handlers
socket.on('connect', this.onConnect);
socket.on('disconnect', function() {
if (typeof CommerceConsent !== 'undefined' && CommerceConsent.clear) {
CommerceConsent.clear();
}
});
socket.on('welcome', this.onWelcome);
socket.on('session_token', this.onSessionToken);
socket.on('session_resumed', this.onSessionResumed);
@@ -130,6 +203,9 @@ const SocketHandlers = {
socket.on('game_start', this.onGameStart);
socket.on('game_update', this.onGameUpdate);
socket.on('game_over', this.onGameOver);
socket.on('game_settlement_pending', this.onGameSettlementPending);
socket.on('fairness_offer', this.onFairnessOffer);
socket.on('fairness_error', this.onFairnessError);
// Discrete in-run events. Multi-level DESCEND is the big one — without this, taking the
// stairs down silently dumped the player at a new entrance and read as a bug.
socket.on('game_event', function (data) {
@@ -156,6 +232,7 @@ const SocketHandlers = {
socket.on('address_prompt', this.onAddressPrompt);
socket.on('game_mode_info', this.onGameModeInfo);
socket.on('payment_created', this.onPaymentCreated);
socket.on('commerce_ack_required', this.onCommerceAcknowledgementRequired);
socket.on('payment_confirmed', this.onPaymentConfirmed);
socket.on('payment_detected', this.onPaymentDetected);
socket.on('show_payment_options', this.onShowPaymentOptions);
@@ -208,18 +285,24 @@ const SocketHandlers = {
} catch (e) { /* non-critical */ }
},
onFairnessOffer: function(data) {
if (!data || !data.offerId || !/^[0-9a-f]{64}$/i.test(String(data.commitment || ''))) return;
SocketHandlers._fairnessOffer = data;
},
onFairnessError: function(data) {
SocketHandlers._pendingFairnessAttempt = null;
var message = (data && data.message) || 'Fairness offer could not be consumed. Please retry.';
$('#messages').append($('<li class="error">').text(message));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
socket.emit('fairness_offer_request');
},
onConnect: function() {
if (SocketHandlers._didConnect) return; // prevent duplicate registration emission
SocketHandlers._didConnect = true;
// Include stored session token in a lightweight resume emit (if server did not get it via handshake)
try {
const existing = localStorage.getItem('wownerogue_token');
if (existing) {
// If we later decide to rely solely on query param at io() creation, this is harmless redundancy.
socket.io.opts.query = socket.io.opts.query || {};
socket.io.opts.query.resumeToken = existing;
}
} catch (e) {}
// The token is supplied in Socket.IO's handshake auth object by index.html. Do not copy
// it into a query string: reverse proxies commonly log URLs, which would leak sessions.
socket.emit('register_client', {
clientId: socket.id,
userAgent: navigator.userAgent
@@ -545,14 +628,28 @@ const SocketHandlers = {
// Display provably fair commitment if present
if (data && data.proof && data.proof.commitment) {
var attempted = SocketHandlers._pendingFairnessAttempt;
var proofMatchesAttempt = !attempted || (
data.proof.offerId === attempted.offerId &&
data.proof.commitment === attempted.commitment &&
data.proof.clientSeed === attempted.clientSeed
);
const shortHash = data.proof.commitment.substring(0, 16) + '...';
const shortClientSeed = data.proof.clientSeed
? data.proof.clientSeed.substring(0, 16) + '...'
: '(legacy empty seed)';
const $proofMsg = $('<li class="proof-commitment" style="color:#0af; font-size:11px;">').html(
'🔐 <strong>Provably Fair:</strong> Game hash commitment: <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
data.proof.commitment + '">' + shortHash + '</code> ' +
'<button class="copy-hash-btn" style="font-size:10px; padding:1px 4px; cursor:pointer;" data-hash="' +
data.proof.commitment + '">Copy</button>'
data.proof.commitment + '">Copy</button><br>' +
'<span style="color:#888;">Accepted client seed:</span> <code>' + shortClientSeed + '</code>'
);
$('#messages').append($proofMsg);
if (!proofMatchesAttempt) {
$('#messages').append($('<li class="error">').text('⚠️ Fairness proof mismatch: the server did not echo the offer/client seed used for this attempt.'));
}
SocketHandlers._pendingFairnessAttempt = null;
// Store for later verification display
SocketHandlers._currentGameProof = data.proof;
@@ -609,21 +706,40 @@ const SocketHandlers = {
UI.scrollChat();
},
onGameSettlementPending: function() {
// The server withholds game_over until the authoritative completion + any payout
// obligation commit together. Keep the player informed without publishing a result that
// PostgreSQL cannot yet prove or offering another entry while retry is in progress.
$('#messages').find('.solo-settlement-pending').remove();
$('#messages').append($('<li class="status solo-settlement-pending" style="color:#ffcc00;">')
.text('Result pending durable settlement · retrying safely…'));
SocketHandlers._setBannerStatus('Saving result…', '#ffcc00');
UI.scrollChat();
},
onGameOver: function(data) {
$('#messages').find('.solo-settlement-pending').remove();
$('#messages').append($('<li class="game-over">').text("Game Over: " + data.message));
// Display provably fair verification info if present
if (data && data.proof && data.proof.seed) {
const shortSeed = data.proof.seed.substring(0, 16) + '...';
const serverSeed = data.proof.serverSeed || '';
const effectiveSeed = data.proof.effectiveSeed || data.proof.seed;
const clientSeed = data.proof.clientSeed || '';
const shortServerSeed = serverSeed ? serverSeed.substring(0, 16) + '...' : '(unavailable)';
const shortEffectiveSeed = effectiveSeed.substring(0, 16) + '...';
const shortClientSeed = clientSeed ? clientSeed.substring(0, 16) + '...' : '(legacy empty seed)';
const shortCommitment = data.proof.commitment.substring(0, 16) + '...';
const verifyUrl = data.proof.verificationUrl || ('/verify/' + data.proof.gameId);
const $proofReveal = $('<li class="proof-reveal" style="color:#4ade80; font-size:11px; margin-top:5px; padding:8px; background:rgba(0,50,0,0.5); border-radius:4px;">').html(
'🔓 <strong>Game Verified:</strong><br>' +
'<span style="color:#888;">Seed:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
data.proof.seed + '">' + shortSeed + '</code> ' +
'🔓 <strong>Proof revealed:</strong><br>' +
'<span style="color:#888;">Server seed:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;" title="' +
serverSeed + '">' + shortServerSeed + '</code> ' +
'<button class="copy-seed-btn" style="font-size:10px; padding:1px 4px; cursor:pointer;" data-seed="' +
data.proof.seed + '">Copy</button><br>' +
serverSeed + '">Copy</button><br>' +
'<span style="color:#888;">Client seed:</span> <code>' + shortClientSeed + '</code><br>' +
'<span style="color:#888;">Effective seed:</span> <code title="' + effectiveSeed + '">' + shortEffectiveSeed + '</code><br>' +
'<span style="color:#888;">Hash:</span> <code style="background:#001a00; padding:2px 4px; border-radius:3px;">' + shortCommitment + '</code><br>' +
'<a href="' + verifyUrl + '" target="_blank" style="color:#0af;">🔗 Verify this game</a>'
);
@@ -783,10 +899,17 @@ const SocketHandlers = {
SocketHandlers._creditsPayoutsEnabled = !!data.creditsPayoutsEnabled;
SocketHandlers._directPayoutsEnabled = !!data.directPayoutsEnabled;
SocketHandlers._paymentsEnabled = !!data.paymentsEnabled;
SocketHandlers._smirkEnabled = data.smirkEnabled !== false; // Default to true if not specified
SocketHandlers._smirkEnabled = SocketHandlers._isSmirkExplicitlyEnabled(data);
SocketHandlers._cryptoType = data.cryptoType || 'WOW';
SocketHandlers._currencyLabel = data.currencyLabel || data.cryptoType || 'WOW'; // sXMR on stagenet
SocketHandlers._explorerTxUrl = data.explorerTxUrl || null;
// Do not ask prestige-only users for a payout address or imply that mainnet paid
// entries award crypto. Match crypto is exposed only when its economy is admitted.
var matchCryptoPayouts = !!(data.modes && data.modes.match && data.modes.match.economies
&& data.modes.match.economies.crypto_race);
var anyPayouts = SocketHandlers._creditsPayoutsEnabled
|| SocketHandlers._directPayoutsEnabled || matchCryptoPayouts;
$('#manageAddressButton').toggle(anyPayouts);
if (data.entitlements && typeof SinglePlayerAvatar !== 'undefined') {
SinglePlayerAvatar.applyEntitlements(data.entitlements);
}
@@ -1057,6 +1180,24 @@ const SocketHandlers = {
}
},
onCommerceAcknowledgementRequired: function(data) {
var message = data && (data.message || data.error)
|| 'Review the paid-play disclosures before continuing.';
$('#messages').append($('<li class="status" style="color:#fbbf24;">').text(message));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
if (typeof CommerceConsent !== 'undefined' && CommerceConsent.require) {
var refreshed = CommerceConsent.reject
? CommerceConsent.reject(data)
: Promise.resolve();
refreshed.catch(function() { return null; }).then(function() {
CommerceConsent.require(function() {
$('#messages').append($('<li class="status" style="color:#aaa;">').text('Disclosures acknowledged. Retry the paid action when ready.'));
if (typeof UI !== 'undefined' && UI.scrollChat) UI.scrollChat();
});
});
}
},
onBalanceCritical: function(data) {
console.warn('Balance critical - games halted:', data);
@@ -1729,7 +1870,9 @@ const SocketHandlers = {
var $btn = $('#earlyEntryButton');
$btn.prop('disabled', true).text('⏳ Entering...');
socket.emit('early_entry');
if (!SocketHandlers.emitFairGameStart('early_entry')) {
$btn.prop('disabled', false).text('⚡ ENTER NOW (RISKY!) ⚡');
}
},
/**
@@ -1806,8 +1949,8 @@ const SocketHandlers = {
'<span style="font-size:12px;letter-spacing:1px;color:' + accent + ';">ENTER &#9656;</span></div>';
}
function begin(msg, emit) {
if (emit() === false) return;
$('#entryChoiceOverlay').remove();
emit();
// Transient "dropping in…/queued…" line — cleared in onGameStart once the game is live so
// it doesn't linger next to a running game reading as "still starting". The queued variant
// stays visible during the (real) block wait; only a started game removes it.
@@ -1842,13 +1985,13 @@ const SocketHandlers = {
$('#ecNow').on('click', function () { timing = 'now'; render(); });
$('#ecX').on('click', function () { $('#entryChoiceOverlay').remove(); });
$('#ecFree').on('click', function () {
if (timing === 'now') begin('⚡ Free game — dropping in...', function () { socket.emit('auto_start', { free: true }); });
else begin('🛡️ Free game — queued for the next block...', function () { socket.emit('join_queue', { free: true }); });
if (timing === 'now') begin('⚡ Free game — dropping in...', function () { return SocketHandlers.emitFairGameStart('auto_start', { free: true }); });
else begin('🛡️ Free game — queued for the next block...', function () { return SocketHandlers.emitFairGameStart('join_queue', { free: true }); });
});
$('#ecRank').on('click', function () {
if (!hasCredits) { pay(); return; }
if (timing === 'now') begin('⚡ Ranked — dropping in now...', function () { socket.emit('auto_start'); });
else begin('🛡️ Ranked — queued for the next block...', function () { socket.emit('join_queue'); });
if (timing === 'now') begin('⚡ Ranked — dropping in now...', function () { return SocketHandlers.emitFairGameStart('auto_start'); });
else begin('🛡️ Ranked — queued for the next block...', function () { return SocketHandlers.emitFairGameStart('join_queue'); });
});
$('#ecBuy').on('click', pay);
}
+5 -1
View File
@@ -114,13 +114,17 @@
// Camera focus — centre the SP camera on the player cell (same convention as the tiled
// renderer). Sticky: only update when the player is present, so switching TO ascii mid-game
// doesn't leave focusPoint null (which pinned the whole grid to the corner → black screen).
var focusFallback = null;
for (var pj = 0; pj < scene.entities.length; pj++) {
var pe = scene.entities[pj];
if (pe.kind === 'player' || pe.you) {
if (pe.kind === 'player' && !focusFallback) focusFallback = pe;
if (pe.you || pe.cameraTarget) {
this.focusPoint = { x: pe.x * cell + cell / 2, y: pe.y * cell + cell / 2 };
focusFallback = null;
break;
}
}
if (focusFallback) this.focusPoint = { x: focusFallback.x * cell + cell / 2, y: focusFallback.y * cell + cell / 2 };
// Keep a light RAF alive so the torch glows flicker between game updates (~22fps).
this._hasTorches = hasTorch;
+4
View File
@@ -624,12 +624,16 @@
function modeLockedTitle(mode) {
var label = mode && mode.label ? mode.label : 'Mode';
if (mode && RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode.id)) {
return label + ' is unavailable on this server because its optional renderer dependency is disabled.';
}
if (mode && mode.pack) return label + ' is locked. Buy any credits or unlock ' + packLabel(mode.pack) + '.';
return label + ' is locked. Buy any credits to unlock premium modes.';
}
function modeStatus(mode, unlocked) {
if (!mode || !mode.premium) return 'Included';
if (RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode.id)) return 'Unavailable';
if (unlocked && RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return 'Test';
return unlocked ? 'Unlocked' : 'Locked';
}
+10 -1
View File
@@ -32,6 +32,7 @@
this.cell = opts.cell || 26;
this.app = new PIXI.Application({ backgroundColor: 0x0a0c0f, antialias: true });
this.app.view.className = 'rk-canvas';
this.canvas = this.app.view;
host.appendChild(this.app.view);
this.tileLayer = new PIXI.Container();
@@ -288,7 +289,7 @@
};
FancyRenderer.prototype._syncEntities = function (scene) {
var cell = this.cell, seen = {}, RKr = window.RK;
var cell = this.cell, seen = {}, RKr = window.RK, focusFallback = null;
for (var i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i];
seen[e.id] = true;
@@ -315,6 +316,14 @@
s = this.sprites[e.id] = { c: c, kind: kind, renderKey: renderKey };
}
s.tx = e.x; s.ty = e.y; s.e = e;
if (e.kind === 'player' && !focusFallback) focusFallback = e;
if (e.you || e.cameraTarget) {
this.focusPoint = { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
focusFallback = null;
}
}
if (!this.focusPoint && focusFallback) {
this.focusPoint = { x: focusFallback.x * cell + cell / 2, y: focusFallback.y * cell + cell / 2 };
}
for (var id in this.sprites) {
if (!seen[id]) {
+3 -2
View File
@@ -326,8 +326,9 @@
var e = scene.entities[i];
p = this._project(e.x, e.y, originX, originY);
items.push({ type: 'entity', e: e, sx: p.x, sy: p.y, depth: e.x + e.y + 0.55 });
// Sticky camera target (keep last if the player is momentarily absent).
if (e.you || e.kind === 'player') { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
// Sticky camera target (keep last if the player is momentarily absent). Multiplayer
// scenes explicitly mark one racer as cameraTarget; never let a later rival steal it.
if (e.you || e.cameraTarget) { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
}
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
+23
View File
@@ -2,6 +2,16 @@
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
RK.externalRenderersEnabled = function () {
return !!(root.WOWNGEON_RUNTIME && root.WOWNGEON_RUNTIME.rendererCdnEnabled === true);
};
RK.isModeRuntimeAvailable = function (id) {
if (!RK.modeMeta || !RK.modeMeta(id)) return false;
if (id === '3d') {
return !!(RK.THREE && RK.THREE.THREE) || RK.externalRenderersEnabled();
}
return true;
};
// Render tiers. `premium` marks modes intended to be unlocked with credits — a cosmetic
// entitlement gated by Operator Policy, the same pattern as avatar unlocks. NOTE: rendering
@@ -39,6 +49,7 @@
RK.canUseMode = function (id) {
var m = RK.modeMeta(id);
if (!m) return false;
if (!RK.isModeRuntimeAvailable(id)) return false;
if (RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return true;
if (!m.premium) return true;
// A premium mode is available once the user has ANY unlocked pack for its projection.
@@ -47,6 +58,16 @@
return !!RK.entitlements.premium;
};
RK.modeAvailability = function (id) {
var runtimeAvailable = RK.isModeRuntimeAvailable(id);
var usable = runtimeAvailable && RK.canUseMode(id);
return {
usable: usable,
runtimeAvailable: runtimeAvailable,
reason: !runtimeAvailable ? 'runtime_unavailable' : (usable ? null : 'entitlement_locked')
};
};
RK.createRenderer = function (mode, host, opts) {
if (!RK.canUseMode(mode)) return new RK.TileRenderer(host, opts);
if (mode === 'ascii' && RK.AsciiRenderer) return new RK.AsciiRenderer(host, opts);
@@ -76,6 +97,7 @@
RK.pixiReady = function () { return typeof PIXI !== 'undefined'; };
RK.ensurePixi = function (cb) {
if (RK.pixiReady()) { cb(true); return; }
if (!RK.externalRenderersEnabled()) { cb(false); return; }
pixiCbs.push(cb);
if (pixiLoading) return;
pixiLoading = true;
@@ -96,6 +118,7 @@
RK.threeReady = function () { return !!(RK.THREE && RK.THREE.THREE); };
RK.ensureThree = function (cb) {
if (RK.threeReady()) { cb(true); return; }
if (!RK.externalRenderersEnabled()) { cb(false); return; }
threeCbs.push(cb);
if (threeLoading) return;
threeLoading = true;
+97 -12
View File
@@ -155,9 +155,67 @@ function maxDims(maps) {
return { rows: rows, cols: cols };
}
function normalizeGameStateOpts(opts) {
// Older multiplayer callers passed a socket id directly. Keep that path working while the
// public API moves to an options object (`{ viewerId, playerAppearance, cryptoType }`).
if (typeof opts === 'string') return { viewerId: opts };
return (opts && typeof opts === 'object') ? opts : {};
}
function pickCameraPlayer(players, state, opts) {
if (!players || !players.length) return null;
var viewerId = opts.viewerId || opts.socketId || null;
var focusId = opts.focusPlayerId || null;
var i, p;
// Match ticks are broadcast once, so the client socket id is the authoritative local marker
// when the server cannot include a recipient-specific `you` flag.
if (viewerId) {
for (i = 0; i < players.length; i++) {
p = players[i];
if (p && p.id === viewerId) return p;
}
}
for (i = 0; i < players.length; i++) {
p = players[i];
if (p && p.you === true) return p;
}
for (i = 0; i < players.length; i++) {
p = players[i];
if (p && focusId && p.id === focusId) return p;
}
for (i = 0; i < players.length; i++) {
p = players[i];
if (p && state.winnerId && p.id === state.winnerId) return p;
}
// Spectators follow a live contender by default, then gracefully fall back to the first racer.
for (i = 0; i < players.length; i++) {
p = players[i];
if (p && p.alive !== false && !p.finished) return p;
}
return players[0] || null;
}
function pointOf(value) {
if (!value) return null;
if (Array.isArray(value)) return { x: value[0], y: value[1] };
if (typeof value.x === 'number' && typeof value.y === 'number') return { x: value.x, y: value.y };
return null;
}
function racerColor(player, index, isYou) {
if (isYou) return '#67e8f9';
if (player && player.alive === false) return '#6b7280';
if (player && (player.finished || player.escaped)) return '#fbbf24';
var palette = ['#9aa4b2', '#a78bfa', '#4ade80', '#fb7185', '#60a5fa', '#f97316'];
var raw = String((player && player.id) || index), hash = 0;
for (var i = 0; i < raw.length; i++) hash = ((hash * 31) + raw.charCodeAt(i)) >>> 0;
return palette[hash % palette.length];
}
function sceneFromGameState(state, opts) {
state = state || {};
opts = opts || {};
opts = normalizeGameStateOpts(opts);
var visible = state.visibleTiles || {};
var explored = state.exploredTiles || {};
var lighting = state.lighting || {};
@@ -167,8 +225,12 @@ function sceneFromGameState(state, opts) {
var rows = state.dungeonRows || visible.length || dims.rows;
var cols = state.dungeonCols || dims.cols;
// Player cell — explored MEMORY fades to black with distance from here (see below).
var _pl = state.player || {};
var matchPlayers = Array.isArray(state.players) ? state.players : [];
var cameraPlayer = pickCameraPlayer(matchPlayers, state, opts);
// Player cell — explored MEMORY fades to black with distance from here (see below). Multiplayer
// states use `players[]`, while the original single-player protocol uses singular `player`.
var _pl = state.player || cameraPlayer || {};
var _plx = typeof _pl.x === 'number' ? _pl.x : (cols / 2);
var _ply = typeof _pl.y === 'number' ? _pl.y : (rows / 2);
@@ -215,15 +277,19 @@ function sceneFromGameState(state, opts) {
function inView(x, y) { return !!(visible[y] && visible[y][x] !== undefined); }
// Entrance / exit / treasure as entities (so they layer above tiles) — only once explored.
if (state.entrance && seen(state.entrance[0], state.entrance[1])) {
entities.push({ id: 'entrance', x: state.entrance[0], y: state.entrance[1], kind: 'feature', char: '<', color: '#3fb950', label: null });
// Solo uses `[x,y]`; match state uses `{x,y}` for treasure, so accept both point shapes.
var entrance = pointOf(state.entrance);
var exit = pointOf(state.exit);
var treasure = pointOf(state.treasure);
if (entrance && seen(entrance.x, entrance.y)) {
entities.push({ id: 'entrance', x: entrance.x, y: entrance.y, kind: 'feature', char: '<', color: '#3fb950', label: null });
}
if (state.exit && seen(state.exit[0], state.exit[1])) {
entities.push({ id: 'exit', x: state.exit[0], y: state.exit[1], kind: 'feature', char: '>', color: '#d29922', label: null });
if (exit && seen(exit.x, exit.y)) {
entities.push({ id: 'exit', x: exit.x, y: exit.y, kind: 'feature', char: '>', color: '#d29922', label: null });
}
if (state.treasure && seen(state.treasure[0], state.treasure[1])) {
if (treasure && seen(treasure.x, treasure.y) && !(state.treasure && state.treasure.carrierId)) {
var tChar = opts.cryptoType === 'XMR' ? '$M' : '$W';
entities.push({ id: 'treasure', x: state.treasure[0], y: state.treasure[1], kind: 'feature', char: tChar, color: '#fbbf24', label: null });
entities.push({ id: 'treasure', x: treasure.x, y: treasure.y, kind: 'feature', char: tChar, color: '#fbbf24', label: null });
}
// Items — only once explored.
@@ -246,15 +312,34 @@ function sceneFromGameState(state, opts) {
});
}
// Player.
if (state.player) {
// Multiplayer racers. Preserve public status fields useful to HUD/render extensions while
// keeping the shared entity contract identical to solo (`kind: player`, appearance, facing).
if (matchPlayers.length) {
var viewerId = opts.viewerId || opts.socketId || null;
for (var pi = 0; pi < matchPlayers.length; pi++) {
var rp = matchPlayers[pi];
if (!rp || typeof rp.x !== 'number' || typeof rp.y !== 'number') continue;
var isYou = viewerId ? rp.id === viewerId : rp.you === true;
var appearance = rp.appearance || (rp.avatar ? { avatar: rp.avatar } : null) || { avatar: 'default' };
entities.push({
id: rp.id || ('player:' + pi), x: rp.x, y: rp.y,
kind: 'player', char: '@', color: racerColor(rp, pi, isYou),
facing: rp.facing || null, label: rp.name || null,
avatar: appearance.avatar || rp.avatar || 'default', appearance: appearance,
you: isYou, cameraTarget: rp === cameraPlayer,
alive: rp.alive !== false, finished: !!rp.finished, escaped: !!rp.escaped,
hasTreasure: !!rp.hasTreasure, placement: rp.placement == null ? null : rp.placement
});
}
// Original single-player protocol.
} else if (state.player) {
var playerEntity = {
id: 'player', x: state.player.x, y: state.player.y,
kind: 'player', char: '@', color: '#9aa4b2',
// No bogus default: the SP server doesn't send facing, so leave it null and let the iso/3D
// renderers INFER facing from movement. A hardcoded 'down' made them always face down/SW.
facing: state.player.facing || null, label: null,
you: !state.isSpectating
you: !state.isSpectating, cameraTarget: true
};
// Attach the player's appearance if available (for render-kit character rendering).
if (opts.playerAppearance) {
+66 -3
View File
@@ -53,6 +53,7 @@
SP.setMode = function (mode) {
if (RK.canUseMode && !RK.canUseMode(mode)) return false;
if (SP._mode !== mode) SP._zoom = null; // let the new projection use its legible default
SP._mode = mode;
if (RK.saveMode) RK.saveMode(mode);
SP._destroyRenderer();
@@ -71,6 +72,42 @@
SP._renderer = null;
};
SP._setZoom = function (next) {
SP._zoom = Math.max(0.4, Math.min(4, Number(next) || SP._defaultZoom()));
var readout = doc() && doc().getElementById('rk-sp-camera-zoom');
if (readout) readout.textContent = Math.round(SP._zoom * 100) + '%';
SP._applyCamera();
};
SP._mountCameraControls = function (host) {
if (!host || host._rkCameraControls || !doc()) return;
var controls = doc().createElement('div');
controls.id = 'rk-sp-camera-controls';
controls.className = 'rk-camera-controls';
controls.setAttribute('aria-label', 'Dungeon camera controls');
controls.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;' +
'align-items:center;gap:4px;padding:4px;background:rgba(4,7,10,.82);border:1px solid rgba(255,255,255,.2);' +
'border-radius:7px;box-shadow:0 4px 18px rgba(0,0,0,.45);font:12px monospace;';
function addButton(label, title, action) {
var b = doc().createElement('button');
b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title);
b.style.cssText = 'min-width:30px;height:28px;padding:0 7px;background:#151b22;color:#e5e7eb;' +
'border:1px solid #3b4552;border-radius:4px;cursor:pointer;font:inherit;';
b.addEventListener('click', function (e) { e.stopPropagation(); action(); });
controls.appendChild(b);
}
addButton('−', 'Zoom out', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) / 1.18); });
var readout = doc().createElement('span');
readout.id = 'rk-sp-camera-zoom';
readout.style.cssText = 'min-width:42px;text-align:center;color:#a7f3d0;';
controls.appendChild(readout);
addButton('+', 'Zoom in', function () { SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * 1.18); });
addButton('⌂', 'Reset camera', function () { SP._zoom = null; SP._camX = null; SP._setZoom(SP._defaultZoom()); });
host.appendChild(controls);
host._rkCameraControls = controls;
readout.textContent = Math.round(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * 100) + '%';
};
SP._ensureRenderer = function () {
if (SP._renderer) return SP._renderer;
var host = rkHost();
@@ -116,17 +153,42 @@
// NOT a hardcoded 1.7 — otherwise the first scroll on iso (default 1.0) jumped up to
// 1.7 and BOTH directions read as "zoom in".
var base = (SP._zoom != null) ? SP._zoom : SP._defaultZoom();
SP._zoom = Math.max(0.4, Math.min(4, base * Math.exp(-ev.deltaY * 0.0015)));
SP._applyCamera();
SP._setZoom(base * Math.exp(-ev.deltaY * 0.0015));
}, { passive: false });
host.addEventListener('dblclick', function (ev) {
if (ev.target && ev.target.closest && ev.target.closest('.rk-camera-controls')) return;
SP._zoom = null; SP._camX = null; SP._setZoom(SP._defaultZoom());
});
var points = {}, pinchDistance = null;
host.addEventListener('pointerdown', function (ev) {
if (ev.target && ev.target.closest && ev.target.closest('.rk-camera-controls')) return;
points[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
try { host.setPointerCapture(ev.pointerId); } catch (_) {}
});
host.addEventListener('pointermove', function (ev) {
if (!points[ev.pointerId]) return;
points[ev.pointerId] = { x: ev.clientX, y: ev.clientY };
var ids = Object.keys(points);
if (ids.length < 2) return;
var a = points[ids[0]], b = points[ids[1]];
var distance = Math.hypot(a.x - b.x, a.y - b.y);
if (pinchDistance) SP._setZoom(((SP._zoom != null) ? SP._zoom : SP._defaultZoom()) * (distance / pinchDistance));
pinchDistance = distance;
});
function release(ev) { delete points[ev.pointerId]; pinchDistance = null; }
host.addEventListener('pointerup', release);
host.addEventListener('pointercancel', release);
}
SP._mountCameraControls(host);
return SP._renderer;
};
// Mode-aware default zoom: iso tiles are large (84px) so they want less magnification than the
// small tiled/ascii cells (~24px).
SP._defaultZoom = function () {
return (SP._renderer && SP._renderer.name === 'iso') ? 1.0 : 1.7;
if (SP._renderer && SP._renderer.name === 'iso') return 1.0;
if (SP._renderer && SP._renderer.name === '3d') return 1.05;
return 1.7;
};
// Centre the (whole-scene) canvas on the player via a CSS transform, clipped to the host —
@@ -138,6 +200,7 @@
// The 3D renderer fills the host itself (setSize) and its OWN THREE camera follows the
// player — so DON'T apply a CSS transform (it would double-transform the WebGL view).
if (r.name === '3d') {
if (r.setZoom) r.setZoom((SP._zoom != null) ? SP._zoom : SP._defaultZoom());
r.canvas.style.transform = 'none';
r.canvas.style.left = '0';
r.canvas.style.top = '0';
+34 -9
View File
@@ -59,7 +59,9 @@
targetX: x, targetY: y,
moveCooldown: 0,
sitTimer: 0,
speechTimer: 0,
// Stagger the first lines so entering the room feels conversational instead of every
// patron opening a bubble on the same frame.
speechTimer: opts.speechTimer == null ? 1800 + Math.random() * 7000 : opts.speechTimer,
speech: null, speechUntil: 0,
color: opts.color || '#d7dbe0',
label: opts.label || null,
@@ -334,6 +336,7 @@
// Get the screen position of an entity for a given renderer/cell size.
// This is renderer-agnostic: top-down uses grid coords * cell; iso uses projection.
TavernLife.prototype._entityScreenPos = function (e, scene, cell, mode) {
var pos;
if (mode === 'iso') {
// Match IsoRenderer._project: originX = margin + rows * tileW/2 + tileW; originY = margin + imageH
var tileW = (RK.isoAssets && RK.isoAssets.tile && RK.isoAssets.tile.w) || 84;
@@ -342,18 +345,21 @@
var margin = 28;
var originX = margin + scene.rows * tileW / 2 + tileW;
var originY = margin + imageH;
return {
pos = {
x: originX + (e.x - e.y) * tileW / 2,
y: originY + (e.x + e.y) * tileH / 2
};
}
if (mode === '3d') {
} else if (mode === '3d') {
// 3D renderer uses Three.js — we can't easily get screen coords.
// Skip speech bubbles for 3D mode.
return null;
} else {
// top-down / ascii / fancy: grid coords * cell
pos = { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
}
// top-down / ascii / fancy: grid coords * cell
return { x: e.x * cell + cell / 2, y: e.y * cell + cell / 2 };
var view = this._viewTransform;
if (view) return { x: view.left + pos.x * view.scaleX, y: view.top + pos.y * view.scaleY };
return pos;
};
TavernLife.prototype._drawBubbles = function (scene, cell, mode, now) {
@@ -369,7 +375,7 @@
var remaining = npc.speechUntil - now;
if (remaining < BUBBLE_FADE) alpha = remaining / BUBBLE_FADE;
var pos = this._entityScreenPos(npc, scene, cell, mode);
if (pos) drawBubble(ctx, pos.x, pos.y - cell * 0.3, npc.speech, alpha);
if (pos) drawBubble(ctx, pos.x, pos.y - cell * 0.3 * ((this._viewTransform && this._viewTransform.scaleY) || 1), npc.speech, alpha);
}
// Real player bubbles (from chat).
@@ -386,7 +392,7 @@
var rRemaining = bubble.until - now;
if (rRemaining < BUBBLE_FADE) rAlpha = rRemaining / BUBBLE_FADE;
var rPos = this._entityScreenPos(e, scene, cell, mode);
if (rPos) drawBubble(ctx, rPos.x, rPos.y - cell * 0.3, bubble.text, rAlpha);
if (rPos) drawBubble(ctx, rPos.x, rPos.y - cell * 0.3 * ((this._viewTransform && this._viewTransform.scaleY) || 1), bubble.text, rAlpha);
}
};
@@ -419,7 +425,26 @@
// Draw speech bubbles on the overlay.
var rendererCanvas = this.host.querySelector('.rk-canvas');
if (rendererCanvas) {
this._syncOverlaySize(rendererCanvas.width, rendererCanvas.height);
this._syncOverlaySize(this.host.clientWidth || rendererCanvas.width, this.host.clientHeight || rendererCanvas.height);
// Keep the overlay at viewport scale (so chat remains readable), but project each
// world-space head position through the camera-transformed canvas rectangle.
var hostRect = this.host.getBoundingClientRect ? this.host.getBoundingClientRect() : { left: 0, top: 0 };
var canvasRect = rendererCanvas.getBoundingClientRect ? rendererCanvas.getBoundingClientRect() : {
left: 0, top: 0, width: rendererCanvas.width, height: rendererCanvas.height
};
this._viewTransform = {
left: canvasRect.left - hostRect.left,
top: canvasRect.top - hostRect.top,
scaleX: canvasRect.width / (rendererCanvas.width || 1),
scaleY: canvasRect.height / (rendererCanvas.height || 1)
};
this._overlay.style.inset = '0';
this._overlay.style.left = '0';
this._overlay.style.top = '0';
this._overlay.style.maxWidth = 'none';
this._overlay.style.maxHeight = 'none';
this._overlay.style.transform = '';
this._overlay.style.transition = '';
}
this._drawBubbles(scene, cell, mode, now);
};
+46 -4
View File
@@ -22,6 +22,9 @@
this.mixers = [];
this.entities = {};
this.last = {};
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this._sizeKey = null;
this._init();
}
@@ -40,7 +43,13 @@
this.camera.lookAt(0, 0, 0);
this.renderer = new T.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
this.renderer.domElement.className = 'rk-canvas';
this.canvas = this.renderer.domElement;
this.renderer.setPixelRatio(Math.min(2, root.devicePixelRatio || 1));
if (T.SRGBColorSpace) this.renderer.outputColorSpace = T.SRGBColorSpace;
if (T.ACESFilmicToneMapping != null) {
this.renderer.toneMapping = T.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.08;
}
this.host.appendChild(this.renderer.domElement);
var hemi = new T.HemisphereLight(0xfff3d2, 0x283044, 2.1);
@@ -48,6 +57,11 @@
var key = new T.DirectionalLight(0xffffff, 1.6);
key.position.set(6, 10, 5);
this.scene.add(key);
// A subtle player-carried torch adds depth and keeps the followed avatar readable without
// washing out the dungeon's black fog-of-war background.
this.playerLight = new T.PointLight(0xffb35c, 2.2, 8, 2);
this.playerLight.position.set(0, 3.2, 0);
this.scene.add(this.playerLight);
this.world = new T.Group();
this.scene.add(this.world);
this.clock = new T.Clock();
@@ -65,7 +79,7 @@
if (w === this._cw && h === this._ch) return;
this._cw = w; this._ch = h;
this.renderer.setSize(w, h, true); // updateStyle:true → the canvas CSS-fills the host
var aspect = w / h, span = 8; // fixed zoom = a screenful around the player
var aspect = w / h, span = 8 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
this.camera.left = -span * aspect;
this.camera.right = span * aspect;
this.camera.top = span;
@@ -73,6 +87,24 @@
this.camera.updateProjectionMatrix();
};
ThreeRenderer.prototype.setZoom = function (zoom) {
var next = Math.max(0.55, Math.min(3.2, Number(zoom) || 1));
if (Math.abs(next - this.zoom) < 0.001) return;
this.zoom = next;
this._cw = null; // force projection update even when the host dimensions did not change
this._fitToHost();
};
// `attachCamera` reports drag pan in viewport pixels. Keep that contract for 3D and convert the
// offset to ground-plane world units in _animate(), where the followed player's current smoothed
// position is available. Bound it to one viewport so a stray gesture cannot lose the dungeon.
ThreeRenderer.prototype.setPan = function (x, y) {
var w = (this.host && (this.host.clientWidth || this.host.offsetWidth)) || 640;
var h = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
this.panX = Math.max(-w, Math.min(w, Number(x) || 0));
this.panY = Math.max(-h, Math.min(h, Number(y) || 0));
};
ThreeRenderer.prototype._mat = function (hex) {
var T = this.THREE;
return new T.MeshStandardMaterial({ color: colorNum(hex), roughness: 0.82, metalness: 0.02 });
@@ -297,11 +329,21 @@
var pcam = null;
for (var pid in this.entities) {
var pen = this.entities[pid];
if (pen.e && (pen.e.you || pen.e.kind === 'player')) { pcam = pen.obj; break; }
if (pen.e && (pen.e.you || pen.e.cameraTarget)) { pcam = pen.obj; break; }
if (!pcam && pen.e && pen.e.kind === 'player') pcam = pen.obj;
}
if (pcam) {
this.camera.position.set(pcam.position.x + 8, 9, pcam.position.z + 8);
this.camera.lookAt(pcam.position.x, 0, pcam.position.z);
// Dragging the viewport should move the rendered dungeon with the pointer. Translate the
// camera target opposite the corresponding screen-space axes while retaining the fixed
// isometric camera offset and player-follow smoothing.
var viewportHeight = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
var unitsPerPixel = (16 / (this.zoom || 1)) / Math.max(1, viewportHeight);
var diagonal = unitsPerPixel / Math.sqrt(2);
var targetX = pcam.position.x + (-this.panX - this.panY) * diagonal;
var targetZ = pcam.position.z + (this.panX - this.panY) * diagonal;
this.camera.position.set(targetX + 8, 9, targetZ + 8);
this.camera.lookAt(targetX, 0, targetZ);
if (this.playerLight) this.playerLight.position.set(pcam.position.x, 3.2, pcam.position.z);
}
this.renderer.render(this.scene, this.camera);
} catch (err) {
+6 -2
View File
@@ -41,11 +41,15 @@
// The player/you entity that carries the torch (for the light composite + embers).
function findPlayer(scene) {
if (!scene || !scene.entities) return null;
var live = null, fallback = null;
for (var i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i];
if (e.kind === 'player' || e.you) return e;
if (!(e.kind === 'player' || e.you)) continue;
if (e.you || e.cameraTarget) return e;
if (!live && e.alive !== false && !e.finished) live = e;
if (!fallback) fallback = e;
}
return null;
return live || fallback;
}
// Real-tile coord for a dungeon feature entity, keyed off its glyph.
+235
View File
@@ -37,4 +37,239 @@
RK.applyZoom(host);
});
};
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function rendererCanvas(renderer) {
return renderer && (renderer.canvas || (renderer.app && renderer.app.view) ||
(renderer.renderer && renderer.renderer.domElement));
}
// Player-follow camera for fixed viewports (single/multiplayer races and spectator views).
// Renderers only need to expose `canvas` + `focusPoint`; ThreeRenderer instead handles zoom and
// following internally through setZoom(). The older attachZoom API above remains unchanged for
// whole-room previews that intentionally do not follow an avatar.
RK.attachCamera = function (host, opts) {
if (!host) return null;
if (host._rkCamera) return host._rkCamera.api;
opts = opts || {};
var state = host._rkCamera = {
zoom: clamp(Number(opts.zoom) || 1.35, Number(opts.min) || 0.55, Number(opts.max) || 3.2),
defaultZoom: Number(opts.zoom) || 1.35,
fitRatio: Number(opts.fitRatio) || 0,
autoFit: false,
min: Number(opts.min) || 0.55,
max: Number(opts.max) || 3.2,
panX: 0,
panY: 0,
renderer: null,
pointers: {},
pinchDistance: null,
listeners: [],
destroyed: false
};
host.style.position = host.style.position || 'relative';
host.style.overflow = 'hidden';
host.style.touchAction = 'none';
host.style.cursor = 'grab';
function updateReadout() {
if (state.readout) state.readout.textContent = Math.round(state.zoom * 100) + '%';
}
function listen(target, type, handler, options) {
target.addEventListener(type, handler, options);
state.listeners.push({ target: target, type: type, handler: handler, options: options });
}
function fitZoom(renderer, ratio) {
if (!renderer || renderer.name === '3d') return clamp(state.defaultZoom, state.min, state.max);
var canvas = rendererCanvas(renderer);
if (!canvas) return clamp(state.defaultZoom, state.min, state.max);
var cw = canvas.width || canvas.offsetWidth || 1;
var ch = canvas.height || canvas.offsetHeight || 1;
var w = host.clientWidth || host.offsetWidth || 640;
var h = host.clientHeight || host.offsetHeight || 400;
return clamp(Math.min(w / cw, h / ch) * (Number(ratio) || state.fitRatio || 0.72), state.min, state.max);
}
function setZoom(next, manual) {
if (state.destroyed) return;
if (manual) state.autoFit = false;
state.zoom = clamp(Number(next) || 1, state.min, state.max);
updateReadout();
RK.updateCamera(host, state.renderer);
}
function reset() {
if (state.destroyed) return;
state.panX = 0;
state.panY = 0;
state.autoFit = !!(state.fitRatio && state.renderer);
setZoom(state.autoFit ? fitZoom(state.renderer) : state.defaultZoom);
}
function onWheel(e) {
if (state.destroyed) return;
e.preventDefault();
setZoom(state.zoom * Math.exp(-e.deltaY * 0.0015), true);
}
function onDoubleClick(e) {
if (state.destroyed) return;
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
reset();
}
function onPointerDown(e) {
if (state.destroyed) return;
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
state.dragId = e.pointerId;
host.style.cursor = 'grabbing';
try { host.setPointerCapture(e.pointerId); } catch (_) {}
}
function onPointerMove(e) {
if (state.destroyed) return;
var prev = state.pointers[e.pointerId];
if (!prev) return;
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
var ids = Object.keys(state.pointers);
if (ids.length > 1) {
var a = state.pointers[ids[0]], b = state.pointers[ids[1]];
var dist = Math.hypot(a.x - b.x, a.y - b.y);
if (state.pinchDistance) setZoom(state.zoom * (dist / state.pinchDistance));
state.pinchDistance = dist;
} else {
state.autoFit = false;
state.panX += e.clientX - prev.x;
state.panY += e.clientY - prev.y;
RK.updateCamera(host, state.renderer);
}
}
function releasePointer(e) {
if (state.destroyed) return;
delete state.pointers[e.pointerId];
state.pinchDistance = null;
if (!Object.keys(state.pointers).length) host.style.cursor = 'grab';
}
listen(host, 'wheel', onWheel, { passive: false });
listen(host, 'dblclick', onDoubleClick);
listen(host, 'pointerdown', onPointerDown);
listen(host, 'pointermove', onPointerMove);
listen(host, 'pointerup', releasePointer);
listen(host, 'pointercancel', releasePointer);
if (opts.controls !== false && root.document) {
var controls = root.document.createElement('div');
controls.className = 'rk-camera-controls';
controls.setAttribute('aria-label', 'Camera controls');
controls.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;' +
'align-items:center;gap:4px;padding:4px;background:rgba(4,7,10,.82);border:1px solid rgba(255,255,255,.2);' +
'border-radius:7px;box-shadow:0 4px 18px rgba(0,0,0,.45);font:12px ui-monospace,monospace;';
function button(label, title, fn) {
var b = root.document.createElement('button');
b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title);
b.style.cssText = 'min-width:30px;height:28px;padding:0 7px;background:#151b22;color:#e5e7eb;' +
'border:1px solid #3b4552;border-radius:4px;cursor:pointer;font:inherit;';
listen(b, 'click', function (e) { e.stopPropagation(); fn(); });
controls.appendChild(b);
}
button('−', 'Zoom out', function () { setZoom(state.zoom / 1.18, true); });
state.readout = root.document.createElement('span');
state.readout.style.cssText = 'min-width:42px;text-align:center;color:#a7f3d0;';
controls.appendChild(state.readout);
button('+', 'Zoom in', function () { setZoom(state.zoom * 1.18, true); });
button('⌂', 'Reset camera', reset);
host.appendChild(controls);
state.controls = controls;
}
updateReadout();
if (typeof root.ResizeObserver === 'function') {
state.observer = new root.ResizeObserver(function () {
if (state.destroyed) return;
if (state.autoFit) setZoom(fitZoom(state.renderer));
else RK.updateCamera(host, state.renderer);
});
state.observer.observe(host);
}
state.api = {
update: function (renderer) {
if (!state.destroyed) RK.updateCamera(host, renderer);
},
setZoom: setZoom,
setDefaultZoom: function (zoom) {
if (!state.destroyed) state.defaultZoom = Number(zoom) || state.defaultZoom;
},
fit: function (renderer, ratio) {
if (state.destroyed) return;
state.renderer = renderer || state.renderer;
if (ratio != null) state.fitRatio = Number(ratio) || state.fitRatio;
state.panX = 0;
state.panY = 0;
state.autoFit = !!state.renderer;
setZoom(fitZoom(state.renderer));
},
reset: reset,
getZoom: function () { return state.zoom; },
destroy: function () {
if (state.destroyed) return;
state.destroyed = true;
state.listeners.splice(0).forEach(function (entry) {
if (entry.target && typeof entry.target.removeEventListener === 'function') {
entry.target.removeEventListener(entry.type, entry.handler, entry.options);
}
});
Object.keys(state.pointers).forEach(function (pointerId) {
try {
if (typeof host.releasePointerCapture === 'function') {
host.releasePointerCapture(Number(pointerId));
}
} catch (_) {}
});
state.pointers = {};
state.pinchDistance = null;
host.style.cursor = 'grab';
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
if (state.controls && state.controls.parentNode) state.controls.parentNode.removeChild(state.controls);
state.controls = null;
state.readout = null;
state.renderer = null;
if (host._rkCamera === state) delete host._rkCamera;
}
};
return state.api;
};
RK.updateCamera = function (host, renderer) {
var state = host && host._rkCamera;
if (!state || !renderer) return;
state.renderer = renderer;
if (renderer.name === '3d') {
if (typeof renderer.setZoom === 'function') renderer.setZoom(state.zoom);
if (typeof renderer.setPan === 'function') renderer.setPan(state.panX, state.panY);
return;
}
var canvas = rendererCanvas(renderer);
if (!canvas) return;
var cw = canvas.width || canvas.offsetWidth || 1;
var ch = canvas.height || canvas.offsetHeight || 1;
var w = host.clientWidth || host.offsetWidth || 640;
var h = host.clientHeight || host.offsetHeight || 400;
var focus = renderer.focusPoint || { x: cw / 2, y: ch / 2 };
var z = state.zoom;
var tx = w / 2 - focus.x * z + state.panX;
var ty = h / 2 - focus.y * z + state.panY;
var sw = cw * z, sh = ch * z;
tx = sw <= w ? (w - sw) / 2 : clamp(tx, w - sw, 0);
ty = sh <= h ? (h - sh) / 2 : clamp(ty, h - sh, 0);
canvas.style.position = 'absolute';
canvas.style.left = '0';
canvas.style.top = '0';
canvas.style.maxWidth = 'none';
canvas.style.maxHeight = 'none';
canvas.style.transformOrigin = '0 0';
canvas.style.imageRendering = 'pixelated';
canvas.style.transition = 'transform 110ms ease-out';
canvas.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + z + ')';
};
})(window);
+13 -6
View File
@@ -33,6 +33,16 @@ const HelpModal = {
updateConfig: function(config) {
this._config = config;
const payoutsOn = !!(config && (config.directPayoutsEnabled || config.creditsPayoutsEnabled));
if (payoutsOn) {
$('#helpPayoutTitle').text('🎁 Payouts');
$('#helpPayoutCopy').text('Set your payout address with “Manage Payout Address.” An escape pays the configured reward automatically, and escaping with the treasure earns the higher multiplier.');
$('#helpTreasureLegend').text('Treasure - collect for the higher payout and score bonus');
} else {
$('#helpPayoutTitle').text('🏅 Prestige rewards');
$('#helpPayoutCopy').text('This server does not pay crypto winnings. Paid entries and credits qualify scores for the Hall of Champions; free runs stay on the separate Pleb leaderboard.');
$('#helpTreasureLegend').text('Treasure - collect for a score bonus');
}
},
show: function() {
@@ -73,7 +83,7 @@ const HelpModal = {
// discount; 1 credit = 1 run either way.
const price = config.singleGamePriceFormatted || '1';
const packages = config.creditPackages || [];
const payoutsOn = !!(config.payoutsEnabled || config.directPayoutsEnabled || config.creditsPayoutsEnabled);
const payoutsOn = !!(config.directPayoutsEnabled || config.creditsPayoutsEnabled);
const buy = [];
if (directOn) buy.push(`<strong>1 credit</strong> — ${price} ${currency}`);
@@ -82,16 +92,13 @@ const HelpModal = {
const bonus = pkg.bonus > 0 ? ` +${pkg.bonus}` : '';
return `${pkg.credits}${bonus} for ${pkg.priceFormatted}`;
}).join(' · ');
buy.push(`<strong>Bundles</strong> — ${bundles} ${currency} <span style="color:#888;">(cheaper per run)</span>`);
buy.push(`<strong>Bundles</strong> — ${bundles} ${currency}`);
}
// Reward line is config-driven so it stays honest on prestige (no-payout) deployments.
let reward;
if (payoutsOn) {
const m = (config.payoutMultipliers && (config.payoutMultipliers.direct || config.payoutMultipliers.credits)) || {};
const esc = m.escape || 2;
const tre = m.escapeWithTreasure || m.treasure || 3;
reward = `<span style="color:#4ade80;">💰 Escape pays <strong>${esc}×</strong> · with treasure <strong>${tre}×</strong>.</span>`;
reward = '<span style="color:#4ade80;">💰 Exact escape and treasure rewards are shown before entry and locked for that run.</span>';
} else {
reward = `<span style="color:#fbbf24;">🏅 No crypto payout — escape for the win; top scores enter the <strong>Hall of Champions</strong>.</span>`;
}
+28 -9
View File
@@ -5,7 +5,7 @@
const Leaderboard = {
_isLoading: false,
_currentPeriod: 'all',
_currentBoard: 'champions', // 'champions' (paid) | 'pleb' (free)
_currentBoard: 'champions', // solo paid | solo/free match | credits-prestige PvP
init: function() {
$('#leaderboardButton').on('click', function() {
@@ -33,16 +33,18 @@ const Leaderboard = {
if ($('#leaderboard-panel').length) return;
var panel = $(
'<div id="leaderboard-panel" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:2100; background:rgba(10,10,20,0.97); border:2px solid #f59e0b; padding:20px; color:#e0e0e0; min-width:380px; max-width:500px; max-height:80vh; overflow-y:auto; box-shadow:0 0 20px rgba(245,158,11,0.3); border-radius:6px;">' +
'<div id="leaderboard-panel" role="dialog" aria-modal="false" aria-labelledby="leaderboard-title" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:2100; background:rgba(10,10,20,0.97); border:2px solid #f59e0b; padding:20px; color:#e0e0e0; min-width:380px; max-width:500px; max-height:80vh; overflow-y:auto; box-shadow:0 0 20px rgba(245,158,11,0.3); border-radius:6px;">' +
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">' +
'<strong id="leaderboard-title" style="color:#fbbf24; font-size:1.1em;">🏅 Hall of Champions</strong>' +
'<button id="close-leaderboard" style="background:#500; color:#f00; border:1px solid #f00; padding:2px 8px; cursor:pointer; font-size:14px;">✕</button>' +
'<button id="close-leaderboard" type="button" aria-label="Close scores" style="background:#500; color:#f00; border:1px solid #f00; padding:2px 8px; cursor:pointer; font-size:14px;">✕</button>' +
'</div>' +
'<div id="leaderboard-boards" style="display:flex; gap:4px; margin-bottom:8px;">' +
'<button class="lb-board" data-board="champions" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🏅 Champions</button>' +
'<button class="lb-board" data-board="pleb" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🪙 Pleb</button>' +
'<div id="leaderboard-boards" role="group" aria-label="Score board" style="display:flex; gap:4px; margin-bottom:8px;">' +
'<button type="button" class="lb-board" data-board="champions" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🏅 Champions</button>' +
'<button type="button" class="lb-board" data-board="prestige" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">✨ Prestige</button>' +
'<button type="button" class="lb-board" data-board="pleb" style="flex:1; padding:7px; font-size:12px; font-weight:bold; cursor:pointer; border:1px solid #555; border-radius:3px;">🪙 Free / Pleb</button>' +
'</div>' +
'<div id="leaderboard-tabs" style="display:flex; gap:4px; margin-bottom:12px;">' +
'<div id="leaderboard-board-note" style="margin:0 0 8px;min-height:24px;color:#9ca3af;font-size:10px;line-height:1.25;"></div>' +
'<div id="leaderboard-tabs" role="group" aria-label="Score period" style="display:flex; gap:4px; margin-bottom:12px;">' +
'<button class="lb-tab" data-period="all" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">All Time</button>' +
'<button class="lb-tab" data-period="month" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">Month</button>' +
'<button class="lb-tab" data-period="week" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">Week</button>' +
@@ -84,13 +86,30 @@ const Leaderboard = {
});
var board = this._currentBoard;
$('.lb-board').each(function() {
if ($(this).data('board') === board) {
var selected = $(this).data('board') === board;
$(this).attr('aria-pressed', selected ? 'true' : 'false');
if (selected) {
$(this).css({ background: '#92400e', color: '#fff', borderColor: '#f59e0b' });
} else {
$(this).css({ background: '#1a1a2e', color: '#aaa', borderColor: '#555' });
}
});
$('#leaderboard-title').text(board === 'pleb' ? '🪙 Pleb Board' : '🏅 Hall of Champions');
var boardMeta = {
champions: {
title: '🏅 Hall of Champions',
note: 'Paid solo and configured crypto-stakes competitive results; never mixed with Free or Prestige.'
},
prestige: {
title: '✨ PvP Prestige',
note: 'Credit-entry competitive PvP scores only; never mixed with Free or the Hall of Champions.'
},
pleb: {
title: '🪙 Free / Pleb Board',
note: 'Free solo and free competitive results only; no paid entries or payouts.'
}
}[board] || {};
$('#leaderboard-title').text(boardMeta.title || 'Scores');
$('#leaderboard-board-note').text(boardMeta.note || '');
},
_fetchAndRender: function() {
+163
View File
@@ -0,0 +1,163 @@
(function (root) {
'use strict';
var MODES = {
race: {
label: 'Dungeon Race',
description: 'Outrun the other delvers, survive the shared dungeon, and reach the exit first.',
badge: 'FIRST TO EXIT',
objective: 'Reach the exit (→) first.',
participant: 'racer',
complete: 'Race complete',
victory: '🏆 You escaped first!'
},
'last-alive': {
label: 'Last Delver Standing',
description: 'Rivals can strike each other down. Survive the dungeon and be the last contender alive.',
badge: 'LAST ALIVE',
objective: 'Stay alive; move into a rival to strike.',
participant: 'contender',
complete: 'Last-delver round complete',
victory: '🏆 You are the last delver standing!'
},
'score-attack': {
label: 'Score Attack',
description: 'Build the strongest run before the arena clock expires.',
badge: 'HIGH SCORE',
objective: 'Build the highest score before time expires.',
participant: 'delver',
complete: 'Score attack complete',
victory: '🏆 You set the high score!'
},
'coop-escape': {
label: 'Co-op Escape',
description: 'Work together and get every surviving delver to safety.',
badge: 'TEAM ESCAPE',
objective: 'Work together and get every surviving delver to the exit (→).',
participant: 'teammate',
complete: 'Co-op expedition complete',
victory: '🤝 Team escaped!'
}
};
function cleanId(raw) {
var id = raw && (raw.id || raw.rulesetId);
return Object.prototype.hasOwnProperty.call(MODES, id) ? id : 'race';
}
function normalize(raw) {
raw = raw || {};
var id = cleanId(raw);
var base = MODES[id];
var win = raw.winCondition;
if (win && typeof win === 'object') win = win.type;
return {
id: id,
label: raw.label || base.label,
description: raw.description || raw.summary || base.description,
badge: String(win || raw.win || base.badge).replace(/[-_]/g, ' ').toUpperCase(),
objective: base.objective,
participant: base.participant,
complete: base.complete,
victory: base.victory,
cooperative: id === 'coop-escape'
};
}
function fromPayload(data, fallback) {
data = data || {};
var state = data.state || data.gameState || {};
return normalize(data.ruleset || state.ruleset || fallback || {});
}
function plural(mode, count) {
var word = normalize(mode).participant;
return word + (Number(count) === 1 ? '' : 's');
}
function playerFor(players, viewerId) {
players = Array.isArray(players) ? players : [];
for (var i = 0; i < players.length; i++) {
var player = players[i];
if (player && (player.you || (viewerId && player.id === viewerId))) return player;
}
return null;
}
function winnerFor(data, players) {
players = Array.isArray(players) ? players : [];
for (var i = 0; i < players.length; i++) {
if (players[i] && players[i].id === data.winnerId) return players[i];
}
return null;
}
function finalResult(data, viewerId, fallbackRuleset) {
data = data || {};
var players = Array.isArray(data.players) ? data.players : [];
var mode = fromPayload(data, fallbackRuleset);
var me = playerFor(players, viewerId);
var reason = data.reason || data.endReason || '';
if (mode.cooperative) {
var escaped = reason === 'all_escaped';
var detail = '';
if (me) detail = me.escaped ? 'you reached safety' : 'you did not escape';
return {
mode: mode,
won: escaped,
headline: escaped ? mode.victory : '☠️ The expedition was lost',
detail: detail,
text: (escaped ? mode.victory : '☠️ The expedition was lost') + (detail ? ' · ' + detail : '')
};
}
var winner = winnerFor(data, players);
var won = !!me && (me.id === data.winnerId || (!data.winnerId && Number(me.placement) === 1));
var details = [];
if (me && me.placement) details.push('you placed #' + me.placement);
if (mode.id === 'score-attack' && me && Number.isFinite(Number(me.score))) {
details.push('score ' + Number(me.score));
}
if (!won && winner) details.push('winner: ' + (winner.name || 'another delver'));
var headline = won ? mode.victory : mode.complete;
return {
mode: mode,
won: won,
headline: headline,
detail: details.join(' · '),
text: headline + (details.length ? ' · ' + details.join(' · ') : '')
};
}
function liveSummary(state, viewerId, fallbackRuleset) {
state = state || {};
var players = Array.isArray(state.players) ? state.players : [];
var mode = fromPayload({ state: state }, fallbackRuleset);
var me = playerFor(players, viewerId);
var alive = players.filter(function (p) { return p && p.alive !== false && !p.finished; }).length;
var escaped = players.filter(function (p) { return p && p.escaped; }).length;
if (mode.id === 'last-alive') {
return alive + ' ' + plural(mode, alive) + ' still in' + (me && me.alive === false ? ' · you are out' : '');
}
if (mode.id === 'score-attack') {
return alive + ' ' + plural(mode, alive) + ' still scoring' + (me && me.alive === false ? ' · you are out' : '');
}
if (mode.cooperative) {
return escaped + '/' + players.length + ' safe · ' + alive + ' still exploring';
}
if (me && me.escaped) return 'You escaped';
if (me && me.alive === false) return 'You are out · ' + alive + ' still racing';
return players.length + ' ' + plural(mode, players.length) + ' in the dungeon';
}
root.MatchUiCopy = {
modes: MODES,
normalize: normalize,
fromPayload: fromPayload,
plural: plural,
finalResult: finalResult,
liveSummary: liveSummary
};
})(window);
+50 -24
View File
@@ -143,6 +143,23 @@ const PaymentUI = {
return `<div style="font-size:0.78em;color:#fbbf24;margin-top:4px;margin-left:${options.indent || 0}px;">Includes: ${this.escapeHtml(parts.join(' • '))}</div>`;
},
payoutSummaryHtml: function(mode, currency) {
const outcomes = this.config?.payoutOutcomes?.[mode];
if (!outcomes?.escape || !outcomes?.escapeWithTreasure) {
return '<span style="color:#f87171;">Exact payout terms are unavailable. Reload before entering paid play.</span>';
}
const describe = (label, outcome) => {
if (!outcome.payable) {
const why = outcome.suppressedReason === 'below_minimum' ? ' (below configured minimum)' : '';
return `${label}: no payout${why}`;
}
const amount = `${this.escapeHtml(outcome.amountFormatted)} ${this.escapeHtml(currency)}`;
if (outcome.capApplied) return `${label}: ${amount} (configured cap applied)`;
return `${label}: ${this.escapeHtml(outcome.multiplier)}× = ${amount}`;
};
return `<span style="color:#4ade80;">💰 ${describe('Escape', outcomes.escape)} • ${describe('Treasure', outcomes.escapeWithTreasure)}</span>`;
},
render: function() {
if (!this.config) return;
@@ -172,15 +189,10 @@ const PaymentUI = {
// === SECTION 1: Use existing credits (if available) ===
if (hasCredits && this.config.creditsModeEnabled) {
const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled;
const creditsMultipliers = this.config.payoutMultipliers?.credits || { escape: 2, escapeWithTreasure: 3 };
const creditsBaseValue = this.config.creditsPayoutBaseValue || this.config.singleGamePrice;
let payoutNote = '';
if (creditsPayoutsEnabled) {
const baseFormatted = this.formatPrice(creditsBaseValue);
const escapeWin = (parseFloat(baseFormatted) * creditsMultipliers.escape).toFixed(2);
const treasureWin = (parseFloat(baseFormatted) * creditsMultipliers.escapeWithTreasure).toFixed(2);
payoutNote = `<span style="color:#4ade80;">💰 Win: ${creditsMultipliers.escape}x (${escapeWin} ${currency}) • Treasure: ${creditsMultipliers.escapeWithTreasure}x (${treasureWin} ${currency})</span><br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
payoutNote = `${this.payoutSummaryHtml('credits', currency)}<br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
} else {
payoutNote = '<span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>';
}
@@ -204,7 +216,6 @@ const PaymentUI = {
// === SECTION 2: Direct payment (pay per game) ===
if (this.config.directModeEnabled) {
const price = this.config.singleGamePriceFormatted || this.formatPrice(this.config.singleGamePrice);
const multipliers = this.config.payoutMultipliers?.direct || { escape: 2, escapeWithTreasure: 3 };
// Payout-honest: only show multipliers when payouts are actually on (default off, not on).
const directPayoutsEnabled = this.config.directPayoutsEnabled != null
? !!this.config.directPayoutsEnabled
@@ -212,9 +223,7 @@ const PaymentUI = {
let payoutInfo = '';
if (directPayoutsEnabled) {
const escapeWin = (parseFloat(price) * multipliers.escape).toFixed(2);
const treasureWin = (parseFloat(price) * multipliers.escapeWithTreasure).toFixed(2);
payoutInfo = `<span style="color:#4ade80;">Win: ${multipliers.escape}x (${escapeWin} ${currency}) • Treasure: ${multipliers.escapeWithTreasure}x (${treasureWin} ${currency})</span><br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
payoutInfo = `${this.payoutSummaryHtml('direct', currency)}<br><span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>`;
} else {
payoutInfo = '<span style="color:#fbbf24;">🏅 High scores enter the Hall of Champions</span>';
}
@@ -241,16 +250,11 @@ const PaymentUI = {
if (this.config.creditsModeEnabled && creditPackages.length > 0) {
const singlePrice = this.config.singleGamePrice;
const creditsPayoutsEnabled = this.config.creditsPayoutsEnabled;
const creditsMultipliers = this.config.payoutMultipliers?.credits || { escape: 2, escapeWithTreasure: 3 };
const creditsBaseValue = this.config.creditsPayoutBaseValue || this.config.singleGamePrice;
// Build payout info for credits mode
let creditsPayoutInfo = '';
if (creditsPayoutsEnabled) {
const baseFormatted = this.formatPrice(creditsBaseValue);
const escapeWin = (parseFloat(baseFormatted) * creditsMultipliers.escape).toFixed(2);
const treasureWin = (parseFloat(baseFormatted) * creditsMultipliers.escapeWithTreasure).toFixed(2);
creditsPayoutInfo = `<div style="font-size:0.85em;color:#4ade80;margin-bottom:10px;">💰 Win: ${creditsMultipliers.escape}x (${escapeWin} ${currency}) • Treasure: ${creditsMultipliers.escapeWithTreasure}x (${treasureWin} ${currency})</div>`;
creditsPayoutInfo = `<div style="font-size:0.85em;margin-bottom:10px;">${this.payoutSummaryHtml('credits', currency)}</div>`;
} else {
creditsPayoutInfo = `<div style="font-size:0.85em;color:#fbbf24;margin-bottom:10px;">🏅 High scores enter the Hall of Champions • bulk discount</div>`;
}
@@ -283,7 +287,7 @@ const PaymentUI = {
packagesHtml += `
<label style="display:block;padding:10px;margin:5px 0;background:#252540;border:1px solid #444;border-radius:4px;cursor:pointer;"
class="package-option" data-package-id="${pkg.id}">
<input type="radio" name="credit-package" value="${pkg.id}" ${checked} style="margin-right:10px;">
<input type="radio" name="credit-package" value="${this.escapeHtml(pkg.id)}" ${checked} style="margin-right:10px;">
<strong>${pkg.credits} credits${bonusText}</strong>${discountBadge}
<span style="float:right;color:#fbbf24;font-weight:bold;">${priceFormatted} ${currency}</span>
<div style="font-size:0.8em;color:#888;margin-top:4px;margin-left:22px;">
@@ -298,7 +302,7 @@ const PaymentUI = {
<div class="payment-section" style="margin-bottom:15px;padding:12px;background:#1a1a2e;border:1px solid #444;border-radius:6px;">
<div style="margin-bottom:10px;">
<strong style="color:#f0f0f0;">🎫 Buy Credits</strong>
<span style="font-size:0.85em;color:#888;margin-left:10px;">Bulk discount • Never expire</span>
<span style="font-size:0.85em;color:#888;margin-left:10px;">Bulk discount • tied to this browser session</span>
</div>
${creditsPayoutInfo}
<div class="packages-list">
@@ -370,24 +374,39 @@ const PaymentUI = {
}
},
handleModeSelection: function(mode, action, packageId) {
handleModeSelection: function(mode, action, packageId, acknowledgementReady, legalAcknowledgement) {
console.log("Mode selected:", mode, "Action:", action, "Package:", packageId);
if (mode === 'FREE') {
$('#payment-ui').hide();
if (window.socket) {
if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
// play_free records the game as FREE (Pleb leaderboard, no payout) even on
// an instance that also sells credits/entry.
window.socket.emit('play_free');
SocketHandlers.emitFairGameStart('play_free', { free: true });
}
return;
}
// A checkbox in the browser is not a legal/identity verification, but requiring a fresh
// per-session statement prevents the UI from silently opening a value-bearing action.
if (!acknowledgementReady) {
if (typeof CommerceConsent === 'undefined' || !CommerceConsent.require) {
$('#payment-status').text('Paid-play disclosures are unavailable. No payment was requested.');
return;
}
CommerceConsent.require(function(acknowledgement) {
PaymentUI.handleModeSelection(mode, action, packageId, true, acknowledgement);
});
return;
}
// If using existing credits, just start the game
if (action === 'use_credit' && this.userCredits >= 1) {
$('#payment-ui').hide();
if (window.socket) {
window.socket.emit('enter_game');
if (window.socket && typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
SocketHandlers.emitFairGameStart('auto_start', {
legalAcknowledgement: legalAcknowledgement
}, true);
}
return;
}
@@ -401,6 +420,7 @@ const PaymentUI = {
}
const requestData = { type: paymentType };
if (legalAcknowledgement) requestData.legalAcknowledgement = legalAcknowledgement;
if (packageId) {
if (paymentType === 'cosmetic_pack') {
requestData.productId = packageId;
@@ -410,7 +430,13 @@ const PaymentUI = {
}
if (window.socket) {
window.socket.emit('request_payment', requestData);
if (paymentType === 'single_game' && typeof SocketHandlers !== 'undefined' && SocketHandlers.fairnessAttempt) {
var fairRequest = SocketHandlers.fairnessAttempt(requestData);
if (!fairRequest) return;
window.socket.emit('request_payment', fairRequest);
} else {
window.socket.emit('request_payment', requestData);
}
// Show loading state
$('.game-modes').hide();
+9 -3
View File
@@ -46,11 +46,17 @@
var modeRow = doc().createElement('div');
modeRow.className = 'rp-row';
RK.RENDER_MODES.forEach(function (m) {
var usable = !RK.canUseMode || RK.canUseMode(m.id);
var availability = RK.modeAvailability
? RK.modeAvailability(m.id)
: { usable: !RK.canUseMode || RK.canUseMode(m.id), runtimeAvailable: true };
var usable = availability.usable;
var b = doc().createElement('button');
b.className = 'rp-btn' + (m.id === current ? ' active' : '') + (usable ? '' : ' locked');
b.textContent = m.label + (m.premium && !usable ? ' 🔒' : '');
b.title = usable ? m.label : (m.label + ' — unlock with credits');
b.textContent = m.label + (!availability.runtimeAvailable ? ' ⛔' : (m.premium && !usable ? ' 🔒' : ''));
b.title = !availability.runtimeAvailable
? (m.label + ' — unavailable on this server (optional renderer dependency disabled)')
: (usable ? m.label : (m.label + ' — unlock with credits'));
b.setAttribute('aria-disabled', usable ? 'false' : 'true');
b.onclick = function () {
if (!usable) return;
if (RK.SPGame.setMode(m.id)) build();
+123 -28
View File
@@ -3,71 +3,166 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wownerogue — Match Race</title>
<title>Wownerogue — Multiplayer Arena</title>
<link rel="stylesheet" href="styles/commerce-consent.css">
<style>
:root { --bg: #0a0c0f; --text: #c9d1d9; --accent: #58a65c; --line: #30363d; }
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator-Bold.ttf') format('truetype'); font-weight: 700; font-display: swap; }
:root { --bg: #07090c; --panel: #11161b; --panel2: #171d24; --text: #d7dde5; --muted: #8b97a7;
--accent: #55d96b; --gold: #f2b84b; --cyan: #62d8ff; --purple: #a98bff; --line: #303946; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
#app { max-width: 900px; margin: 0 auto; padding: 16px; }
h1 { margin: 0 0 8px; font-size: 1.4rem; }
.muted { color: #8b949e; font-size: 0.85rem; }
#panel { background: #11161b; border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
button { background: #21262d; border: 1px solid var(--line); color: var(--text); padding: 8px 14px; border-radius: 6px; cursor: pointer; }
button.primary { background: var(--accent); color: #08130a; border-color: var(--accent); font-weight: 600; }
body { min-height: 100vh; margin: 0; background:
radial-gradient(circle at 50% -20%, rgba(73,118,84,.28), transparent 42%),
linear-gradient(180deg, #0b0e12, var(--bg) 42%); color: var(--text);
font-family: PixelOperator, ui-monospace, monospace; }
body::before { content:""; position:fixed; inset:0; pointer-events:none; opacity:.17;
background-image:linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);
background-size:24px 24px; mask-image:linear-gradient(to bottom,#000,transparent 75%); }
#app { position:relative; max-width: 1040px; margin: 0 auto; padding: 18px clamp(12px,3vw,28px) 32px; }
.topbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:28px; }
.brand { color:#fff; font-weight:700; letter-spacing:.08em; }
.brand span { color:var(--accent); }
nav { display:flex; gap:8px; }
nav a { color:var(--muted); text-decoration:none; border:1px solid var(--line); border-radius:5px; padding:6px 10px; font-size:13px; }
nav a:hover, nav a:focus-visible { color:#fff; border-color:#627083; }
.hero { margin-bottom:14px; }
.eyebrow { color:var(--accent); font-size:11px; letter-spacing:.18em; text-transform:uppercase; }
h1 { margin: 5px 0 6px; color:#fff; font-size:clamp(24px,5vw,42px); line-height:1; letter-spacing:.02em; }
.hero p { max-width:720px; margin:0; color:var(--muted); line-height:1.45; }
.mode-summary { display:flex; align-items:center; flex-wrap:wrap; gap:7px; margin-top:12px; }
.tag { display:inline-flex; align-items:center; min-height:24px; padding:3px 8px; border:1px solid #344b3a; border-radius:999px;
color:#a7f3b4; background:rgba(40,110,55,.12); font-size:12px; }
#rulesetCatalog { display:flex; gap:6px; flex-wrap:wrap; }
#rulesetCatalog .tag { color:var(--muted); border-color:var(--line); background:rgba(255,255,255,.025); }
#rulesetCatalog .tag.active { color:#a7f3b4; border-color:#3d7b49; }
.muted { color: var(--muted); font-size: 0.85rem; }
#panel { overflow:hidden; background:rgba(17,22,27,.96); border:1px solid var(--line); border-radius:12px;
box-shadow:0 22px 70px rgba(0,0,0,.38), inset 0 1px rgba(255,255,255,.035); }
#queuePanel { padding:clamp(16px,3vw,26px); }
.section-title { margin:0 0 3px; color:#fff; font-size:17px; }
button { font:inherit; background:#212832; border:1px solid #3b4653; color:var(--text); padding:9px 14px; border-radius:6px; cursor:pointer; }
button:hover:not(:disabled), button:focus-visible { border-color:#718095; filter:brightness(1.1); }
button.primary { background:linear-gradient(#67e17a,#42b957); color:#061108; border-color:#7af08b; font-weight:700; box-shadow:0 0 18px rgba(85,217,107,.14); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.economies { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.economy { padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; }
.economy.active { border-color: var(--accent); background: rgba(88,166,92,0.12); }
.economies { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin:14px 0; }
.economy { min-height:92px; padding:12px; text-align:left; border:1px solid var(--line); border-radius:8px; background:var(--panel2); cursor:pointer; }
.economy strong, .economy small { display:block; }
.economy strong { color:#fff; margin-bottom:5px; font-size:14px; }
.economy small { color:var(--muted); font-size:11px; line-height:1.35; }
.economy .availability { margin-top:8px; color:#7ee38d; font-size:10px; letter-spacing:.08em; }
.economy.active { border-color:var(--accent); background:linear-gradient(145deg,rgba(66,185,87,.16),rgba(23,29,36,.95)); box-shadow:0 0 0 1px rgba(85,217,107,.18); }
.economy[data-economy="credits_prestige"].active { border-color:var(--purple); box-shadow:0 0 0 1px rgba(169,139,255,.18); }
.economy[data-economy="crypto_race"].active { border-color:var(--gold); box-shadow:0 0 0 1px rgba(242,184,75,.18); }
.economy.locked { opacity: 0.55; cursor: not-allowed; }
#status { min-height: 24px; margin: 8px 0; }
#stage { width: 100%; aspect-ratio: 16 / 10; background: #000; border: 1px solid var(--line); border-radius: 8px; }
#hud { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 0.85rem; }
#hud span { background: #161b22; padding: 4px 8px; border-radius: 4px; }
#status { min-height:24px; margin:10px 0; color:#b8c1cc; }
.queue-actions { display:flex; gap:8px; }
#racePanel { position:relative; }
.race-head { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 12px; border-bottom:1px solid var(--line); background:#0e1318; }
#hud { display:flex; gap:6px; flex-wrap:wrap; font-size:12px; }
#hud span { background:#192029; border:1px solid #2b3541; padding:4px 8px; border-radius:4px; }
#racePlace { color:var(--gold); }
#renderModes { display:flex; gap:4px; }
#renderModes button { padding:4px 8px; font-size:11px; }
#renderModes button.active { border-color:var(--cyan); color:var(--cyan); background:#10222a; }
#renderModes button.locked { opacity:.42; }
#stage { position:relative; width:100%; height:clamp(360px,64vh,650px); background:#000;
border-bottom:1px solid var(--line); isolation:isolate; }
#stage::after { content:""; position:absolute; inset:0; pointer-events:none; z-index:10; box-shadow:inset 0 0 70px rgba(0,0,0,.62); }
#stage canvas { display:block; }
.race-foot { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:10px 12px; }
#raceResult { position:absolute; z-index:30; inset:50% auto auto 50%; transform:translate(-50%,-50%); min-width:min(360px,86%);
padding:18px; text-align:center; color:#fff; background:rgba(8,11,14,.94); border:1px solid var(--gold); border-radius:9px; box-shadow:0 18px 60px #000; }
.touch-pad { display:none; grid-template-areas:". up ." "left down right"; gap:5px; }
.touch-pad button { width:44px; height:40px; padding:0; touch-action:manipulation; }
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
.hidden { display: none !important; }
@media (max-width:700px), (pointer:coarse) {
#app { padding-inline:10px; }
.topbar { margin-bottom:20px; }
.economies { grid-template-columns:1fr; }
.economy { min-height:0; }
#stage { height:min(58vh,520px); }
.race-head { align-items:flex-start; flex-direction:column; }
.race-foot { align-items:flex-end; }
.touch-pad { display:grid; }
}
@media (max-width:430px) { nav a { padding:5px 7px; font-size:11px; } .brand { font-size:13px; } #renderModes { max-width:100%; overflow-x:auto; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { scroll-behavior:auto!important; transition:none!important; animation:none!important; } }
</style>
<!-- Render kit -->
<script src="js/render/sceneModel.js"></script>
<script src="js/render/fxLayer.js"></script>
<script src="js/render/skins.js"></script>
<script src="js/render/charCustomize.js"></script>
<script src="js/render/atlas.js"></script>
<script src="js/render/charSprites.js"></script>
<script src="js/render/zoomControl.js"></script>
<script src="js/render/packRegistry.js"></script>
<script src="js/render/assetPacks.js"></script>
<script src="js/render/avatarVisuals.js"></script>
<script src="js/render/asciiRenderer.js"></script>
<script src="js/render/tileRenderer.js"></script>
<script src="js/render/isoRenderer.js"></script>
<script src="js/render/threeRenderer.js"></script>
<script src="/runtime-config.js"></script>
<script src="js/render/renderModes.js"></script>
<script src="js/ui/matchUiCopy.js"></script>
</head>
<body>
<div id="app">
<h1>🏁 Race Queue</h1>
<p class="muted">Multiplayer dungeon race. A new race starts every block when at least two players are queued.</p>
<header class="topbar">
<div class="brand">WOWNERO<span>GUE</span> / ARENA</div>
<nav aria-label="Main navigation"><a href="/">← Lobby</a><a href="/tavern">Tavern</a><a href="/responsible-play">Responsible Play</a></nav>
</header>
<section class="hero" aria-labelledby="modeTitle">
<div class="eyebrow">Operator-selected live mode</div>
<h1 id="modeTitle">Dungeon Race</h1>
<p id="modeDescription">Outrun the other delvers, survive the shared dungeon, and reach the exit first.</p>
<div class="mode-summary"><span id="modeBadge" class="tag">FIRST TO EXIT</span><div id="rulesetCatalog" aria-label="Available multiplayer modes"></div></div>
</section>
<div id="panel">
<div id="queuePanel">
<div class="muted">Select economy:</div>
<div id="economies" class="economies"></div>
<div id="status">Connect to server to see available economies.</div>
<h2 class="section-title">Choose your entry mode</h2>
<div id="economyHelp" class="muted">Free, Prestige, and Champions results remain on separate boards.</div>
<div id="economies" class="economies" role="group" aria-label="Match entry mode"></div>
<div id="status" role="status" aria-live="polite">Connecting to the arena…</div>
<div style="display:flex; gap:8px;">
<div class="queue-actions">
<button id="queueBtn" class="primary">Join Queue</button>
<button id="leaveBtn" class="hidden">Leave Queue</button>
</div>
</div>
<div id="racePanel" class="hidden">
<div id="hud">
<span id="raceEconomy"></span>
<span id="raceTimer"></span>
<span id="racePlace"></span>
<div class="race-head">
<div id="hud" aria-label="Match status">
<span id="raceMode"></span>
<span id="raceEconomy"></span>
<span id="raceTimer"></span>
<span id="racePlace"></span>
</div>
<div id="renderModes" aria-label="Render style"></div>
</div>
<div id="stage" aria-label="Live multiplayer dungeon"></div>
<div id="raceResult" class="hidden" role="status"></div>
<div class="race-foot">
<p id="objectiveHint" class="muted">Arrow keys / WASD · reach the exit (→) first · drag or pinch the camera</p>
<div id="matchDpad" class="touch-pad" role="group" aria-label="Match movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
<button type="button" data-dir="down" aria-label="Move down">▼</button>
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
</div>
<div id="stage"></div>
<p class="muted">Move with arrow keys or WASD. Reach the exit (→) first.</p>
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="js/legal/commerceConsent.js"></script>
<script src="js/matchClient.js"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
.commerce-consent-overlay { position:fixed; inset:0; z-index:100000; display:grid; place-items:center; padding:16px; background:rgba(0,0,0,.82); }
.commerce-consent-dialog { width:min(620px,100%); max-height:calc(100vh - 32px); overflow:auto; padding:20px; border:1px solid #9a6b25; border-radius:10px; background:#11161b; color:#d7dde5; box-shadow:0 24px 80px #000; font:14px/1.45 system-ui,-apple-system,sans-serif; }
.commerce-consent-dialog h2 { margin:0 0 10px; color:#fff; font-size:1.4rem; }
.commerce-summary { padding:10px 12px; border-left:3px solid #efb64b; background:#211a0f; }
.commerce-check { display:flex; align-items:flex-start; gap:10px; margin:10px 0; padding:10px; border:1px solid #303944; border-radius:6px; background:#171d24; cursor:pointer; }
.commerce-check input { flex:0 0 auto; width:18px; height:18px; margin-top:1px; }
.commerce-links,.commerce-links a { color:#99e9a8; }
.commerce-status { color:#96a0ad; font-size:12px; }
.commerce-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; }
.commerce-actions button,.commerce-consent-dialog>button { padding:8px 12px; border:1px solid #46515e; border-radius:6px; font:inherit; cursor:pointer; }
.commerce-cancel { background:#202730; color:#d7dde5; }
.commerce-accept { background:#55d96b; color:#071209; font-weight:700; }
.commerce-accept:disabled { opacity:.45; cursor:not-allowed; }
+18
View File
@@ -0,0 +1,18 @@
:root { color-scheme: dark; --bg:#090b0f; --panel:#14191f; --line:#303944; --text:#d7dbe0; --muted:#96a0ad; --accent:#64db79; --gold:#efb64b; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; background:radial-gradient(circle at 50% -15%,#25352a 0,transparent 38%),var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,sans-serif; }
main { width:min(860px,calc(100% - 28px)); margin:0 auto; padding:24px 0 48px; }
nav { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:28px; }
nav a,a { color:#9eeaac; }
nav a { padding:6px 10px; border:1px solid var(--line); border-radius:6px; text-decoration:none; }
nav a:hover,nav a:focus-visible { border-color:var(--accent); }
header { margin-bottom:20px; }
h1 { margin:.15em 0; color:#fff; font-size:clamp(2rem,6vw,3.4rem); line-height:1; }
h2 { color:#fff; font-size:1.15rem; }
.eyebrow { margin:0; color:var(--accent); font-size:.75rem; font-weight:700; letter-spacing:.15em; text-transform:uppercase; }
header>p:last-child,footer { color:var(--muted); }
section { margin:12px 0; padding:18px clamp(16px,4vw,24px); border:1px solid var(--line); border-radius:10px; background:rgba(20,25,31,.96); }
.notice { border-color:#7a5c28; background:rgba(78,55,16,.24); }
li+li { margin-top:.45rem; }
footer { padding:12px 4px; }
strong { color:#fff; }
+432 -60
View File
@@ -3,11 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tavern - Wownerogue</title>
<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script>
<title>Wownerogue — Tavern</title>
<link rel="stylesheet" href="styles/commerce-consent.css">
<script src="/socket.io/socket.io.js"></script>
<script src="js/legal/commerceConsent.js"></script>
<script src="js/smirkChatSign.js"></script>
<!-- PixiJS (+ @pixi/unsafe-eval for the CSP) is lazy-loaded by renderModes.js only when the
Fancy mode is first selected, so ASCII/Tiled users never download the WebGL library. -->
<!-- Optional external renderers are lazy-loaded only when the server's runtime policy opts in. -->
<script src="js/render/sceneModel.js"></script>
<script src="js/render/fxLayer.js"></script>
<script src="js/render/skins.js"></script>
@@ -26,35 +27,47 @@
<script src="js/render/isoRenderer.js"></script>
<script src="js/render/threeRenderer.js"></script>
<script src="js/render/tavernLife.js"></script>
<script src="/runtime-config.js"></script>
<script src="js/render/renderModes.js"></script>
<script src="js/ui/matchUiCopy.js"></script>
<style>
:root { --bg: #0f1216; --panel: #171b21; --line: #2a313a; --text: #d7dbe0; --accent: #3fb950; }
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator.ttf') format('truetype'); font-display: swap; }
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator-Bold.ttf') format('truetype'); font-weight: 700; font-display: swap; }
:root { --bg: #090b0f; --panel: #151a20; --panel2:#1b2129; --line: #303944; --text: #d7dbe0;
--muted:#8b97a7; --accent: #51d569; --gold:#efb64b; --purple:#a98bff; --cyan:#62d8ff; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font-family: ui-monospace, Menlo, Consolas, monospace; }
.wrap { max-width: 760px; margin: 0 auto; padding: 16px; }
h1 { font-size: 18px; margin: 4px 0 12px; }
.muted { color: #8b949e; font-size: 13px; }
.rk-canvas { background: #0a0c0f; border: 1px solid var(--line); border-radius: 6px;
display: block; max-width: 100%; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
padding: 12px; margin-bottom: 12px; }
body { min-height:100vh; margin:0; background:radial-gradient(circle at 50% -15%,rgba(116,70,32,.25),transparent 38%),
linear-gradient(180deg,#111015,var(--bg) 48%); color:var(--text); font-family:PixelOperator,ui-monospace,monospace; }
body::before { content:"";position:fixed;inset:0;pointer-events:none;opacity:.14;background-image:linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px);background-size:24px 24px; }
.wrap { position:relative; max-width:980px; margin:0 auto; padding:18px clamp(10px,3vw,26px) 32px; }
.tavern-head { display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px; }
h1 { color:#fff;font-size:clamp(24px,4vw,34px);margin:0;letter-spacing:.03em; }
.backlink { color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:5px;padding:6px 10px;font-size:13px; }
.backlink:hover { color:#fff;border-color:#667386; }
.muted { color:var(--muted);font-size:13px; }
.rk-canvas { background:#050609;display:block;max-width:100%;image-rendering:pixelated; }
.panel { background:rgba(21,26,32,.97);border:1px solid var(--line);border-radius:9px;padding:14px;margin-bottom:12px;box-shadow:inset 0 1px rgba(255,255,255,.025); }
label { font-size: 13px; margin-right: 6px; }
input, select, button { font: inherit; background: #0a0c0f; color: var(--text);
border: 1px solid var(--line); border-radius: 4px; padding: 6px 8px; }
button { background: var(--accent); color: #08130a; border: 0; cursor: pointer; font-weight: 600; }
input, select, button { font:inherit;background:#0d1116;color:var(--text);border:1px solid var(--line);border-radius:5px;padding:7px 9px; }
input:focus,button:focus-visible { outline:2px solid rgba(98,216,255,.7);outline-offset:2px; }
button { background:linear-gradient(#63df77,#43b859);color:#071209;border-color:#78eb8a;cursor:pointer;font-weight:700; }
button:hover:not(:disabled) { filter:brightness(1.08); }
button.secondary { background:#202731;color:var(--text);border-color:#414d5c; }
button:disabled { opacity: 0.5; cursor: default; }
#status { min-height: 18px; font-size: 13px; }
.hidden { display: none; }
#modebar { display: flex; gap: 6px; margin-bottom: 8px; }
.hidden { display: none !important; }
#modebar { display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap; }
.modebtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-weight: 500; }
.modebtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
.modebtn.locked { opacity: 0.55; }
#packbar { display: flex; gap: 6px; margin-bottom: 8px; align-items: center; }
#packbar { display:flex;gap:6px;margin-bottom:8px;align-items:center;flex-wrap:wrap; }
#packbar .packlabel { color: var(--muted); font-size: 12px; margin-right: 2px; }
.packbtn { background: #0a0c0f; color: var(--text); border: 1px solid var(--line); font-size: 12px; padding: 3px 10px; border-radius: 5px; cursor: pointer; }
.packbtn.active { background: var(--accent); color: #08130a; border-color: var(--accent); }
#chatPanel { margin-top: 12px; }
#stage,.spectator-render { position:relative;width:100%;height:clamp(360px,62vh,620px);overflow:hidden;background:#000;
border:1px solid var(--line);border-radius:9px;box-shadow:0 18px 55px rgba(0,0,0,.38),inset 0 0 60px rgba(0,0,0,.5);isolation:isolate; }
#stage::after,.spectator-render::after { content:"";position:absolute;inset:0;pointer-events:none;z-index:10;box-shadow:inset 0 0 70px rgba(0,0,0,.58); }
#chatPanel { margin-top:12px; }
#chatLog { height: 160px; overflow-y: auto; border: 1px solid var(--line); border-radius: 4px;
padding: 8px; background: #0a0c0f; font-size: 13px; line-height: 1.45; }
#chatLog .cname { color: var(--accent); font-weight: 600; }
@@ -62,11 +75,28 @@
#chatLog .csys { color: #8b949e; font-style: italic; }
#chatForm { display: flex; gap: 6px; margin-top: 8px; }
#chatInput { flex: 1; }
.actionbar { display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 12px; }
.actionbar button { margin:0!important; }
#watchGamesBtn { background:#102b35;color:var(--cyan);border-color:#367287; }
#matchQueueBtn { background:#271d3b;color:#d4c5ff;border-color:#7156a6; }
.panel h3,.panel h4 { color:#fff; }
.gamerow { display:flex;justify-content:space-between;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--line); }
.gamerow:last-child { border-bottom:0; }
.spectator-head { display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px; }
.touch-pad { display:none;grid-template-areas:". up ." "left down right";gap:5px;justify-content:end;margin:8px 0; }
.touch-pad button { width:44px;height:40px;padding:0;touch-action:manipulation;background:rgba(25,45,31,.94);color:#93f3a2;border-color:#4f9a5c; }
.touch-pad [data-dir="up"]{grid-area:up}.touch-pad [data-dir="left"]{grid-area:left}.touch-pad [data-dir="down"]{grid-area:down}.touch-pad [data-dir="right"]{grid-area:right}
@media (max-width:700px),(pointer:coarse) { .touch-pad{display:grid} #stage,.spectator-render{height:min(54vh,500px)} #joinPanel{display:flex;flex-wrap:wrap;gap:7px;align-items:center} #joinPanel label{width:100%} #name{min-width:0;flex:1} }
@media (max-width:430px) { .wrap{padding-inline:8px}.tavern-head .muted{display:none}#modebar{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}.modebtn{white-space:nowrap} }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after{animation:none!important;transition:none!important} }
</style>
</head>
<body>
<div class="wrap">
<h1>Tavern <span class="muted">— early preview</span></h1>
<header class="tavern-head">
<h1>🔥 The Tavern <span class="muted">social hall</span></h1>
<span><a class="backlink" href="/responsible-play">Responsible Play</a> <a class="backlink" href="/">← Lobby</a></span>
</header>
<div id="joinPanel" class="panel">
<label for="name">Name</label>
@@ -78,9 +108,15 @@
<div id="modebar" class="hidden"></div>
<div id="packbar" class="hidden"></div>
<div id="stage" class="hidden"></div>
<p id="status" class="muted"></p>
<p class="muted">Move with arrow keys or WASD. ★ modes are premium (credit unlock).</p>
<div id="stage" class="hidden" aria-label="Shared tavern room"></div>
<div id="tavernDpad" class="touch-pad hidden" role="group" aria-label="Tavern movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
<button type="button" data-dir="down" aria-label="Move down">▼</button>
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
<p id="status" class="muted" role="status" aria-live="polite"></p>
<p id="movementHint" class="muted">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
<div id="chatPanel" class="panel hidden">
<div id="chatLog" aria-live="polite"></div>
@@ -91,34 +127,43 @@
</div>
<!-- Spectator camera: watch live dungeon runs from inside the tavern -->
<button id="watchGamesBtn" class="hidden" style="margin-top:8px;">👁️ Watch Live Games</button>
<div class="actionbar">
<button id="watchGamesBtn" class="hidden">👁 Watch Solo Runs</button>
<button id="matchQueueBtn" class="hidden">🎮 Multiplayer Arena</button>
</div>
<div id="spectatorPanel" class="panel hidden">
<h3 style="margin:0 0 8px;font-size:14px;">Live Games</h3>
<div id="gameList" style="max-height:200px;overflow-y:auto;font-size:13px;"></div>
<button id="closeGameListBtn" style="margin-top:8px;">Close</button>
<button id="closeGameListBtn" class="secondary" style="margin-top:8px;">Close</button>
</div>
<!-- Match mode panel -->
<button id="matchQueueBtn" class="hidden" style="margin-top:8px;">🏁 Queue for Race</button>
<div id="matchPanel" class="panel hidden">
<h3 style="margin:0 0 8px;font-size:14px;">Race Queue</h3>
<h3 id="tavernMatchTitle" style="margin:0 0 4px;font-size:16px;">Multiplayer Arena</h3>
<div id="tavernMatchDescription" class="muted" style="margin-bottom:10px;">Choose a leaderboard for the server's active mode.</div>
<div id="matchEconomies" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px;"></div>
<div id="matchQueueStatus" class="muted" style="margin-bottom:8px;">Choose an economy.</div>
<div style="display:flex;gap:6px;">
<button id="joinMatchQueueBtn">Join Queue</button>
<button id="leaveMatchQueueBtn" class="hidden">Leave Queue</button>
<button id="leaveMatchQueueBtn" class="hidden secondary">Leave Queue</button>
</div>
<hr style="border-color:var(--line);margin:12px 0;">
<h4 style="margin:0 0 8px;font-size:13px;">Active Races</h4>
<h4 style="margin:0 0 8px;font-size:13px;">Active Matches</h4>
<div id="activeMatchList" style="max-height:160px;overflow-y:auto;font-size:13px;"></div>
<button id="closeMatchPanelBtn" style="margin-top:8px;">Close</button>
<button id="closeMatchPanelBtn" class="secondary" style="margin-top:8px;">Close</button>
</div>
<div id="spectatorStage" class="hidden">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div class="spectator-head">
<span id="spectatorLabel" class="muted"></span>
<button id="leaveSpectateBtn">← Back to tavern</button>
<button id="leaveSpectateBtn" class="secondary">← Back to tavern</button>
</div>
<div id="spectatorRender" class="spectator-render"></div>
<div id="spectatorDpad" class="touch-pad hidden" role="group" aria-label="Match movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
<button type="button" data-dir="down" aria-label="Move down">▼</button>
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
<div id="spectatorRender" style="position:relative;min-height:300px;"></div>
</div>
</div>
@@ -131,6 +176,7 @@
} catch (_) {}
var socket = io(socketOpts);
var RK = window.RK;
var MatchCopy = window.MatchUiCopy;
var statusEl = document.getElementById('status');
var joinPanel = document.getElementById('joinPanel');
@@ -141,6 +187,18 @@
var chatLog = document.getElementById('chatLog');
var chatForm = document.getElementById('chatForm');
var chatInput = document.getElementById('chatInput');
var chatPanel = document.getElementById('chatPanel');
var movementHint = document.getElementById('movementHint');
var tavernDpad = document.getElementById('tavernDpad');
var watchGamesBtn = document.getElementById('watchGamesBtn');
var spectatorPanel = document.getElementById('spectatorPanel');
var gameList = document.getElementById('gameList');
var closeGameListBtn = document.getElementById('closeGameListBtn');
var spectatorStage = document.getElementById('spectatorStage');
var spectatorLabel = document.getElementById('spectatorLabel');
var spectatorRender = document.getElementById('spectatorRender');
var spectatorDpad = document.getElementById('spectatorDpad');
var leaveSpectateBtn = document.getElementById('leaveSpectateBtn');
// Character customization — persisted appearance identity, chosen before entering.
var customizeBtn = document.getElementById('customizeBtn');
@@ -165,6 +223,19 @@
var currentScene = null;
var tavernLife = null;
var rafActive = false;
var mainCamera = null;
var rendererRequest = 0;
var cryptoType = 'WOW';
var lastGameModeInfo = null;
var spectatorKind = null; // solo | match | player-match
var spectatorRenderer = null;
var spectatorCamera = null;
var spectatorPendingState = null;
var spectatorRequest = 0;
var currentSpectatingMatchId = null;
var activeMatchSnapshots = {};
var activeMatchTickMs = 250;
var currentMatchRuleset = MatchCopy.normalize({ id: 'race' });
function setStatus(msg) { statusEl.textContent = msg; }
function applyEntitlements(data) {
@@ -185,7 +256,10 @@
if (!currentScene) return;
// Inject client-side NPCs (bartender, patrons, cat) into the scene.
if (tavernLife) tavernLife.injectInto(currentScene);
if (currentRenderer) currentRenderer.render(currentScene);
if (currentRenderer) {
currentRenderer.render(currentScene);
if (mainCamera) mainCamera.update(currentRenderer);
}
}
// rAF loop for smooth NPC animation + speech bubbles.
@@ -199,13 +273,16 @@
if (!currentScene._npcInjected) tavernLife.injectInto(currentScene);
tavernLife.update(currentScene, currentRenderer && currentRenderer.cell || 24, currentMode || 'tiles');
}
root.requestAnimationFrame(loop);
window.requestAnimationFrame(loop);
}
root.requestAnimationFrame(loop);
window.requestAnimationFrame(loop);
}
function lockedModeMessage(mode) {
var meta = RK.modeMeta ? RK.modeMeta(mode) : null;
var label = (meta && meta.label) || mode;
if (RK.isModeRuntimeAvailable && !RK.isModeRuntimeAvailable(mode)) {
return label + ' is unavailable on this server because its optional renderer dependency is disabled.';
}
if (meta && meta.pack && RK.pack) {
var pack = RK.pack(meta.pack);
return label + ' is locked. Buy any credits or unlock ' + ((pack && pack.label) || 'the pack') + '.';
@@ -214,6 +291,7 @@
}
function setMode(mode) {
if (!RK.canUseMode(mode)) { setStatus(lockedModeMessage(mode)); return; }
var request = ++rendererRequest;
if (currentRenderer) { currentRenderer.destroy(); currentRenderer = null; }
currentMode = mode;
if (tavernLife) tavernLife.setMode(mode);
@@ -222,12 +300,21 @@
if (mode === 'fancy' && !RK.pixiReady()) setStatus('Loading Fancy renderer…');
// Async: Fancy lazy-loads PixiJS on first use.
RK.createRendererAsync(mode, stageEl, {}, function (r) {
if (currentMode !== mode) { r.destroy(); return; } // switched again while loading
if (currentMode !== mode || request !== rendererRequest) { r.destroy(); return; } // switched while loading
currentRenderer = r;
draw();
if (RK.attachZoom) { RK.attachZoom(stageEl); RK.applyZoom(stageEl); } // wheel/pinch zoom, all modes
var activeMode = r.name || mode;
var defaultZoom = activeMode === 'iso' ? 0.8 : (activeMode === '3d' ? 1.05 : 1.25);
if (!mainCamera && RK.attachCamera) {
mainCamera = RK.attachCamera(stageEl, { zoom: defaultZoom, fitRatio: 0.75, min: 0.5, max: 3.2 });
}
if (mainCamera) {
if (mainCamera.setDefaultZoom) mainCamera.setDefaultZoom(defaultZoom);
if (activeMode === '3d' || !mainCamera.fit) mainCamera.setZoom(defaultZoom);
else mainCamera.fit(r, 0.75);
}
buildPackbar(); // show pack choices for this mode's projection
var active = r.name || mode;
var active = activeMode;
setStatus(active === mode
? 'Render mode: ' + active
: '“' + mode + '” unavailable here — using “' + active + '”.');
@@ -236,10 +323,11 @@
function buildModebar() {
modebar.innerHTML = '';
RK.RENDER_MODES.forEach(function (m) {
var runtimeAvailable = !RK.isModeRuntimeAvailable || RK.isModeRuntimeAvailable(m.id);
var b = document.createElement('button');
b.className = 'modebtn';
b.dataset.mode = m.id;
b.textContent = m.label + (m.premium ? ' ★' : '');
b.textContent = m.label + (!runtimeAvailable ? ' ⛔' : (m.premium ? ' ★' : ''));
if (!RK.canUseMode(m.id)) { b.classList.add('locked'); b.title = lockedModeMessage(m.id); }
b.addEventListener('click', function () { setMode(m.id); });
modebar.appendChild(b);
@@ -299,8 +387,9 @@
setStatus((e && e.message) || 'Could not enter the tavern.');
});
socket.on('game_mode_info', function (data) {
lastGameModeInfo = data || null;
if (data && data.entitlements) applyEntitlements(data.entitlements);
if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
if (data && data.cryptoType) cryptoType = data.cryptoType;
});
socket.on('credits_update', applyEntitlements);
socket.on('session_token', function (data) {
@@ -329,7 +418,8 @@
joinPanel.classList.add('hidden');
modebar.classList.remove('hidden');
stageEl.classList.remove('hidden');
document.getElementById('chatPanel').classList.remove('hidden');
chatPanel.classList.remove('hidden');
tavernDpad.classList.remove('hidden');
rebuildScene();
buildModebar();
setMode(RK.loadMode('tiles'));
@@ -435,20 +525,171 @@
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
w: 'up', s: 'down', a: 'left', d: 'right', W: 'up', S: 'down', A: 'left', D: 'right'
};
function sendDirection(dir) {
if (spectatorKind === 'player-match') {
var delta = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[dir];
if (delta) socket.emit('match_move', { dx: delta[0], dy: delta[1] });
return;
}
if (!spectatorKind && state.you) socket.emit('tavern_move', { dir: dir });
}
window.addEventListener('keydown', function (e) {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
var dir = KEYS[e.key];
if (!dir || !state.you) return;
if (!dir || (!state.you && spectatorKind !== 'player-match')) return;
e.preventDefault();
socket.emit('tavern_move', { dir: dir });
sendDirection(dir);
});
function bindDpad(el) {
if (!el) return;
Array.prototype.forEach.call(el.querySelectorAll('[data-dir]'), function (b) {
// Native click includes touch/mouse plus Enter and Space for keyboard and AT users.
b.addEventListener('click', function () { sendDirection(b.dataset.dir); });
});
}
bindDpad(tavernDpad);
bindDpad(spectatorDpad);
setStatus('Connecting…');
socket.on('connect', function () {
socket.emit('register_client', { clientId: socket.id, userAgent: navigator.userAgent });
socket.emit('identity:get');
socket.emit('match_reconnect', {});
setStatus('Connected. Choose a name and enter.');
});
// ---- Shared spectator viewport (solo runs + public PvP races) -----------------
function spectatorZoom(mode) { return mode === 'iso' ? 0.78 : (mode === '3d' ? 1.05 : 1.3); }
function destroySpectatorRenderer() {
spectatorRequest++;
if (spectatorRenderer && spectatorRenderer.destroy) {
try { spectatorRenderer.destroy(); } catch (_) {}
}
spectatorRenderer = null;
}
function renderSpectatorState(gameState) {
if (!gameState) return;
spectatorPendingState = gameState;
if (!spectatorRenderer) return;
var safeState = Object.assign({}, gameState, { isSpectating: spectatorKind !== 'player-match' });
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(safeState, {
viewerId: spectatorKind === 'player-match' ? socket.id : null,
cryptoType: cryptoType
}) : null;
if (!scene) return;
spectatorRenderer.render(scene);
if (spectatorCamera) spectatorCamera.update(spectatorRenderer);
}
function openSpectatorView(kind, label, initialState) {
spectatorKind = kind;
spectatorPendingState = initialState || null;
joinPanel.classList.add('hidden');
stageEl.classList.add('hidden');
modebar.classList.add('hidden');
packbar.classList.add('hidden');
tavernDpad.classList.add('hidden');
movementHint.classList.add('hidden');
spectatorPanel.classList.add('hidden');
matchPanel.classList.add('hidden');
spectatorStage.classList.remove('hidden');
spectatorDpad.classList.toggle('hidden', kind !== 'player-match');
spectatorLabel.textContent = label || 'Live dungeon';
leaveSpectateBtn.textContent = kind === 'player-match' ? 'Forfeit match' : '← Back to tavern';
destroySpectatorRenderer();
var request = ++spectatorRequest;
var renderMode = currentMode || (RK.loadMode && RK.loadMode('tiles')) || 'tiles';
RK.createRendererAsync(renderMode, spectatorRender, {}, function (r) {
if (request !== spectatorRequest || !spectatorKind) { r.destroy(); return; }
spectatorRenderer = r;
if (!spectatorCamera && RK.attachCamera) {
spectatorCamera = RK.attachCamera(spectatorRender, { zoom: spectatorZoom(r.name || renderMode), min: 0.5, max: 3.2 });
}
if (spectatorCamera) spectatorCamera.setZoom(spectatorZoom(r.name || renderMode));
renderSpectatorState(spectatorPendingState);
});
}
function closeSpectatorView(notifyServer) {
var previous = spectatorKind;
if (notifyServer && previous === 'solo') socket.emit('leave_spectate');
spectatorKind = null;
currentSpectatingMatchId = null;
spectatorPendingState = null;
destroySpectatorRenderer();
spectatorStage.classList.add('hidden');
spectatorDpad.classList.add('hidden');
if (state.you) {
stageEl.classList.remove('hidden');
modebar.classList.remove('hidden');
tavernDpad.classList.remove('hidden');
movementHint.classList.remove('hidden');
buildPackbar();
draw();
} else {
joinPanel.classList.remove('hidden');
}
leaveSpectateBtn.textContent = '← Back to tavern';
}
function populateSoloGames(data) {
gameList.innerHTML = '';
var games = data && Array.isArray(data.games) ? data.games : [];
if (!games.length) {
gameList.textContent = 'No live solo runs right now.';
return;
}
games.forEach(function (game) {
var row = document.createElement('div');
row.className = 'gamerow';
var text = document.createElement('span');
var seconds = Number(game.durationSeconds) || 0;
text.textContent = 'Delver ' + (game.playerId || '??????') + ' · ' + (game.difficulty || 'normal') + ' · ' + seconds + 's';
var watch = document.createElement('button');
watch.type = 'button'; watch.textContent = 'Watch'; watch.className = 'secondary';
watch.addEventListener('click', function () {
setStatus('Joining live solo run…');
socket.emit('spectate_game', { gameId: game.gameId });
});
row.appendChild(text); row.appendChild(watch); gameList.appendChild(row);
});
}
watchGamesBtn.addEventListener('click', function () {
spectatorPanel.classList.toggle('hidden');
if (!spectatorPanel.classList.contains('hidden')) {
gameList.textContent = 'Loading live runs…';
socket.emit('get_active_games', { page: 1, pageSize: 20 });
}
});
closeGameListBtn.addEventListener('click', function () { spectatorPanel.classList.add('hidden'); });
socket.on('active_games', populateSoloGames);
socket.on('spectate_start', function (data) {
if (!data || !data.initialState) return;
openSpectatorView('solo', '👁 Watching delver ' + (data.playerId || 'live'), data.initialState);
setStatus('Watching a live solo run.');
});
socket.on('spectator_update', function (data) {
if (spectatorKind === 'solo' && data && data.gameState) renderSpectatorState(data.gameState);
});
socket.on('spectate_ended', function (data) {
if (spectatorKind !== 'solo') return;
closeSpectatorView(false);
setStatus(data && data.reason === 'game_over' ? 'That run has ended.' : 'Back in the tavern.');
});
socket.on('spectate_error', function (data) {
setStatus((data && data.message) || 'Could not watch that run.');
});
leaveSpectateBtn.addEventListener('click', function () {
if (spectatorKind === 'player-match') {
if (!window.confirm('Forfeit this match and return to the tavern?')) return;
socket.emit('match_leave');
closeSpectatorView(false);
setStatus('You forfeited the match.');
return;
}
closeSpectatorView(spectatorKind === 'solo');
setStatus('Back in the tavern.');
});
// ---- Tavern Match spectator / queue UI -------------------------------
var matchQueueBtn = document.getElementById('matchQueueBtn');
var matchPanel = document.getElementById('matchPanel');
@@ -458,13 +699,24 @@
var leaveMatchQueueBtn = document.getElementById('leaveMatchQueueBtn');
var activeMatchList = document.getElementById('activeMatchList');
var closeMatchPanelBtn = document.getElementById('closeMatchPanelBtn');
var tavernMatchTitle = document.getElementById('tavernMatchTitle');
var tavernMatchDescription = document.getElementById('tavernMatchDescription');
var selectedMatchEconomy = 'free';
var inMatchQueue = false;
var matchEndTimer = null;
function activeRulesetFrom(info) {
var match = info && info.modes && info.modes.match || {};
var raw = match.activeRuleset || { id: match.rulesetId || 'race' };
return MatchCopy.normalize(raw);
}
function buildMatchEconomies(info) {
if (!info || !info.modes || !info.modes.match || !info.modes.match.enabled) return;
var economies = info.modes.match.economies || {};
var labels = { free: 'Free', credits_prestige: 'Prestige', crypto_race: 'Crypto' };
var labels = { free: 'Pleb · Free', credits_prestige: 'Prestige · Credits', crypto_race: 'Stagenet · Stakes' };
var available = Object.keys(labels).filter(function (key) { return !!economies[key]; });
if (!economies[selectedMatchEconomy] && available.length) selectedMatchEconomy = available[0];
matchEconomies.innerHTML = '';
Object.keys(labels).forEach(function (key) {
if (!economies[key]) return;
@@ -478,20 +730,29 @@
});
matchEconomies.appendChild(b);
});
joinMatchQueueBtn.disabled = available.length === 0;
}
socket.on('game_mode_info', function (data) {
// Extend existing handler: also build match economies.
if (data && data.entitlements) applyEntitlements(data.entitlements);
if (data && data.cryptoType) SocketHandlers_cryptoType = data.cryptoType;
lastGameModeInfo = data || lastGameModeInfo;
if (data && data.cryptoType) cryptoType = data.cryptoType;
if (data && data.modes && data.modes.match && data.modes.match.enabled) {
var ruleset = activeRulesetFrom(data);
tavernMatchTitle.textContent = '🎮 ' + ruleset.label;
tavernMatchDescription.textContent = ruleset.description + (ruleset.cooperative
? ' This is a team result, not an individual leaderboard win.'
: ' Choose a separate leaderboard below.');
matchQueueBtn.textContent = '🎮 ' + ruleset.label;
matchQueueBtn.classList.remove('hidden');
buildMatchEconomies(data);
}
});
socket.on('match_queue_joined', function (res) {
res = res || {};
joinMatchQueueBtn.disabled = false;
if (res.success) {
selectedMatchEconomy = res.economy || selectedMatchEconomy;
inMatchQueue = true;
joinMatchQueueBtn.classList.add('hidden');
leaveMatchQueueBtn.classList.remove('hidden');
@@ -502,6 +763,7 @@
});
socket.on('match_queue_left', function (res) {
res = res || {};
inMatchQueue = false;
joinMatchQueueBtn.classList.remove('hidden');
leaveMatchQueueBtn.classList.add('hidden');
@@ -510,39 +772,134 @@
socket.on('tavern_match_list', function (list) {
activeMatchList.innerHTML = '';
activeMatchSnapshots = {};
if (!list || list.length === 0) {
activeMatchList.textContent = 'No active races right now.';
activeMatchList.textContent = 'No active multiplayer matches right now.';
return;
}
list.forEach(function (m) {
if (!m || !m.matchId) return;
activeMatchSnapshots[m.matchId] = m;
var row = document.createElement('div');
row.className = 'gamerow';
row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--line);';
row.innerHTML = '<span>' + (m.economy || 'race') + ' — ' + (m.players ? m.players.length : 0) + ' players</span> <button class="watchMatchBtn" data-matchid="' + m.matchId + '">Watch</button>';
var summary = document.createElement('span');
var mode = MatchCopy.fromPayload(m, { id: m.rulesetId || m.variant || 'race' });
var count = m.players ? m.players.length : 0;
summary.textContent = mode.label + ' · ' + (m.economy || 'free') + ' · ' + count + ' ' + MatchCopy.plural(mode, count);
var watch = document.createElement('button');
watch.type = 'button'; watch.className = 'watchMatchBtn secondary'; watch.dataset.matchid = m.matchId; watch.textContent = 'Watch';
row.appendChild(summary); row.appendChild(watch);
activeMatchList.appendChild(row);
});
Array.prototype.forEach.call(activeMatchList.querySelectorAll('.watchMatchBtn'), function (b) {
b.addEventListener('click', function () {
var matchId = b.dataset.matchid;
// Switch spectator renderer to the public match state stream.
enterSpectatorMode(matchId);
var snap = activeMatchSnapshots[matchId];
currentSpectatingMatchId = matchId;
currentMatchRuleset = MatchCopy.fromPayload(snap, { id: snap && (snap.rulesetId || snap.variant) || 'race' });
var count = (snap && snap.players && snap.players.length) || 0;
openSpectatorView('match', '👁 ' + currentMatchRuleset.label + ' · ' + count + ' ' + MatchCopy.plural(currentMatchRuleset, count), snap);
});
});
});
socket.on('tavern_match_tick', function (state) {
if (currentSpectatingMatchId && state.matchId === currentSpectatingMatchId) {
var scene = RK.sceneFromGameState ? RK.sceneFromGameState(state, null) : null;
if (scene && spectatorRenderer) spectatorRenderer.render(scene);
renderSpectatorState(state);
currentMatchRuleset = MatchCopy.fromPayload(state, currentMatchRuleset);
spectatorLabel.textContent = '👁 ' + currentMatchRuleset.label + ' · '
+ MatchCopy.liveSummary(state, null, currentMatchRuleset) + ' · tick ' + (state.tick || 0);
}
});
socket.on('tavern_match_end', function (data) {
if (currentSpectatingMatchId && data.matchId === currentSpectatingMatchId) {
spectatorLabel.textContent = 'Race ended. Winner: ' + (data.winnerId ? data.winnerId.slice(0, 6) : 'none');
var priorPlayers = spectatorPendingState && spectatorPendingState.players || [];
var namedPlayers = (data.players || []).map(function (player) {
var prior = priorPlayers.filter(function (candidate) { return candidate.id === player.id; })[0];
return Object.assign({}, prior || {}, player);
});
var result = MatchCopy.finalResult(Object.assign({}, data, { players: namedPlayers }), null, currentMatchRuleset);
spectatorLabel.textContent = result.text;
}
});
// A player can queue without leaving the tavern. When that queue becomes a match, reuse the
// same viewport as a first-class playable arena (local camera + movement), not a blind queue.
socket.on('match_joined', function (data) {
if (!data) return;
selectedMatchEconomy = data.economy || selectedMatchEconomy;
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
currentMatchRuleset = ruleset;
var count = data.players ? data.players.length : 0;
matchQueueStatus.textContent = 'Match found: ' + ruleset.label + ' · ' + count + ' ' + MatchCopy.plural(ruleset, count) + ' · starting in ' + ((Number(data.countdownMs) || 0) / 1000).toFixed(1) + 's.';
setStatus(ruleset.objective + ' Get ready…');
});
socket.on('match_start', function (data) {
data = data || {};
if (matchEndTimer) clearTimeout(matchEndTimer);
activeMatchTickMs = Math.max(50, Number(data.tickMs) || 250);
currentSpectatingMatchId = data.matchId || null;
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
currentMatchRuleset = ruleset;
openSpectatorView('player-match', '⚔️ ' + ruleset.label + ' · GO', null);
setStatus('Match live. ' + ruleset.objective);
});
socket.on('match_rejoined', function (data) {
if (!data || !data.state) return;
currentSpectatingMatchId = data.matchId || data.state.matchId || null;
selectedMatchEconomy = data.state.economy || selectedMatchEconomy;
var ruleset = activeRulesetFrom({ modes: { match: { activeRuleset: data.state.ruleset || data.ruleset || (lastGameModeInfo && lastGameModeInfo.modes && lastGameModeInfo.modes.match.activeRuleset) } } });
currentMatchRuleset = ruleset;
openSpectatorView('player-match', '⚔️ Rejoined ' + ruleset.label, data.state);
setStatus('Rejoined your active multiplayer match.');
});
socket.on('match_tick', function (data) {
if (!data || !data.state) return;
if (spectatorKind !== 'player-match') {
currentSpectatingMatchId = data.matchId || data.state.matchId || null;
openSpectatorView('player-match', '⚔️ Multiplayer match', data.state);
} else {
renderSpectatorState(data.state);
}
currentMatchRuleset = MatchCopy.fromPayload(data, currentMatchRuleset);
var elapsed = ((Number(data.tick) || Number(data.state.tick) || 0) * activeMatchTickMs / 1000).toFixed(1);
spectatorLabel.textContent = '⚔️ ' + currentMatchRuleset.label + ' · ' + elapsed + 's · '
+ MatchCopy.liveSummary(data.state, socket.id, currentMatchRuleset);
});
socket.on('match_end', function (data) {
if (spectatorKind !== 'player-match') return;
data = data || {};
var result = MatchCopy.finalResult(data, socket.id, currentMatchRuleset);
currentMatchRuleset = result.mode;
spectatorKind = 'match-result';
spectatorDpad.classList.add('hidden');
leaveSpectateBtn.textContent = '← Back to tavern';
spectatorLabel.textContent = result.text;
setStatus(result.mode.cooperative
? 'Team result recorded; co-op does not create an individual leaderboard winner.'
: 'Competitive result recorded on its separate leaderboard.');
matchEndTimer = setTimeout(function () {
if (spectatorKind === 'match-result') closeSpectatorView(false);
}, 5000);
});
socket.on('match_error', function (data) {
var message = data && (data.message || data.reason) || 'Could not update the multiplayer match.';
matchQueueStatus.textContent = message;
setStatus(message);
});
socket.on('commerce_ack_required', function (data) {
if (!window.CommerceConsent || !window.CommerceConsent.require) return;
var refreshed = window.CommerceConsent.reject
? window.CommerceConsent.reject(data)
: Promise.resolve();
refreshed.catch(function () { return null; }).then(function () {
window.CommerceConsent.require(function () {
matchQueueStatus.textContent = 'Disclosures acknowledged. Retry the paid queue when ready.';
});
});
});
matchQueueBtn.addEventListener('click', function () {
matchPanel.classList.toggle('hidden');
if (!matchPanel.classList.contains('hidden')) socket.emit('tavern_match_list');
@@ -551,7 +908,19 @@
joinMatchQueueBtn.addEventListener('click', function () {
if (inMatchQueue) return;
socket.emit('match_queue', { economy: selectedMatchEconomy, action: 'join' });
function join(acknowledgement) {
joinMatchQueueBtn.disabled = true;
matchQueueStatus.textContent = 'Joining queue…';
var payload = { economy: selectedMatchEconomy, action: 'join' };
if (acknowledgement) payload.legalAcknowledgement = acknowledgement;
socket.emit('match_queue', payload);
setTimeout(function () { if (!inMatchQueue) joinMatchQueueBtn.disabled = false; }, 1500);
}
if (selectedMatchEconomy !== 'free' && window.CommerceConsent) {
window.CommerceConsent.require(join);
} else {
join(null);
}
});
leaveMatchQueueBtn.addEventListener('click', function () {
if (!inMatchQueue) return;
@@ -560,7 +929,10 @@
// ---- End match UI ----------------------------------------------------
socket.on('disconnect', function () { setStatus('Disconnected.'); });
socket.on('disconnect', function () {
if (window.CommerceConsent && window.CommerceConsent.clear) window.CommerceConsent.clear();
setStatus('Disconnected.');
});
})();
</script>
</body>
@@ -0,0 +1,239 @@
# Parallel Monero stagenet wallet candidate
> **STATUS: DESIGN / NO-GO FOR HOST MUTATION OR PRODUCTION DEPLOYMENT.**
>
> The read-only preflight and disposable fixture test in this change are executable. The candidate
> unit is an inert, manual-only template. There is intentionally no password-capture, wallet-copy,
> credential-bundle, installer, activation, or cutover program yet. Do not improvise those steps
> from shell commands. No in-place destructive migration is shipped with this design; deleting an
> abandoned draft does not make the parallel candidate production-deployable.
This is the smaller alternative to replacing the current wallet in place. It is fixed to the
Monero **stagenet** wallet used by `monerogue.app`. It has no mainnet or Wownero mode.
The safety property is simple: the current wallet, unit, and application stay exactly as they are.
A stopped, consistent copy is opened under a new dedicated identity on a different loopback port.
Nothing can use that copy until one complete directory containing the wallet, protected
credentials, configuration, and readiness marker has been validated and atomically published.
## What is safe to run now
The local fixture uses only synthetic bytes below a temporary directory:
```bash
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
```
The fixed-path preflight is read-only. It deliberately does not read unit text, process argv,
wallet bytes, keys, passwords, or mnemonics. After independent review, it can check the current
legacy RPC:
```bash
sudo python3 scripts/deploy/monero-stagenet-parallel-preflight.py legacy
```
Its `candidate-prereqs` scope additionally requires the new system identity, free candidate ports,
an unused candidate path, the root-owned candidate binary, and enough local space. Provisioning
that identity is a future approved change; the preflight does not create it.
Neither command proves that secret capture, bundle creation, activation, backup recovery, or an
application cutover is safe. The service template must not be installed yet.
## Fixed boundary
| Purpose | Fixed value |
| --- | --- |
| Legacy unit | `monero-wallet-rpc.service` |
| Legacy wallet | `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test` + `.keys` |
| Legacy RPC | `127.0.0.1:38083` |
| Network/daemon | Monero stagenet / `127.0.0.1:38081` |
| Candidate identity | `monerogue-wallet-candidate:monerogue-wallet-candidate` |
| Staging generation | `/var/lib/.monerogue-stagenet-wallet-candidate.staging` |
| Published generation | `/var/lib/monerogue-stagenet-wallet-candidate` |
| Candidate RPC | `127.0.0.1:38084`, Digest authentication required |
| Offline verification RPC | `127.0.0.1:39084`, temporary only |
| Candidate unit | `monerogue-stagenet-wallet-candidate.service`, manual only |
The final bundle must be one directory on the same filesystem as its staging generation:
```text
/var/lib/monerogue-stagenet-wallet-candidate/ root:root 0711
├── READY root:root 0600, written last
├── STATE.json root:root 0600, non-secret phase/address metadata
├── expected-address root:root 0600, public address only
├── wallet/ candidate:candidate 0700
│ ├── stagenet candidate:candidate 0600
│ └── stagenet.keys candidate:candidate 0600
├── config/ root:candidate 0710
│ └── wallet-rpc.conf root:candidate 0640
├── secrets/ root:root 0711
│ ├── wallet-password root:candidate 0640
│ └── rpc-auth.netrc root:root 0600
└── integration/ root:root 0711
└── candidate-app.env root:monerogue 0640
```
Execute-only directory traversal permits each identity to open its one reviewed fixed path without
listing the directory. No file may be a symlink or have a link count other than one. The `READY`
marker contains only version, stagenet, `127.0.0.1:38084`, the public address, and a
`manual-only` flag—never a password, hash of a seed, private key, or wallet bytes.
## Required staged procedure
These are acceptance requirements for small future helpers, not commands to run today.
### 1. Read-only inventory
The preflight must pass immediately before a maintenance window. Separately record the public
wallet address without recording credentials. Confirm the current application and wallet remain
healthy and that `38084` and `39084` have no listener.
The preflight intentionally does not inspect `/proc/<pid>/cmdline`, because the current process
contains a password. That work belongs only in the bounded capture helper described next.
### 2. Capture the already-exposed wallet password
A future fixed-path helper may read the password only from the validated live process. It must:
- run as root with `RLIMIT_CORE=0`, `PR_SET_DUMPABLE=0`, `umask 077`, and no tracing/logging;
- obtain only `MainPID` from systemd, then validate PID start time, `/proc/<pid>/exe`, UID, cgroup,
and the sole PID-owned `127.0.0.1:38083` listener before and after capture;
- read NUL-delimited `/proc/<pid>/cmdline` directly, never systemd unit text or `ExecStart` output;
- accept exactly one bounded `--password VALUE` or `--password=VALUE`, reject duplicates,
`--password-file`, NUL/newline data, or a process change;
- write only a new `root:root 0600` file below the unpublished staging generation using
`openat(O_NOFOLLOW|O_CREAT|O_EXCL)`, `fsync`, and retained directory descriptors;
- print only success/refusal, never arguments, password length, content, hash, or exception data;
- zero mutable buffers before exit; and
- leave an incomplete stage inert and refuse automatic overwrite or cleanup after any failure.
The helper needs executable fixture coverage for both argv forms, duplicates, process replacement,
short writes, fsync failures, interruption, and restart with every possible partial file/state. A
static source test is not sufficient.
### 3. One bounded stopped-wallet copy window
Only a narrow wrapper may stop the legacy unit. It must acquire a fixed root-only lock and install
an `EXIT`, signal, and error trap that always attempts to restart the exact legacy unit. It must not
read or alter the unit.
After stop, require `MainPID=0`, no listener on `38083`, and no other process descriptor open on
either source inode. Open every source/path component with retained directory descriptors and
`O_NOFOLLOW`; require `jw:jw 0600`, a single link, and stable inode/size/timestamps. Copy wallet and
keys with `O_EXCL` temporary files, `fsync`, byte-compare through retained descriptors, publish each
inside the unpublished stage with `renameat2(RENAME_NOREPLACE)`, and fsync its directory.
The wrapper must restart the legacy unit whether the copy succeeds, fails, or is interrupted. A
successful window is not complete until the restarted RPC has the same PID ownership/listener
properties and returns the exact public address captured before stop. At no point may a helper
move, rename, chmod, chown, truncate, erase, or unlink the legacy wallet, keys, directory, or unit.
### 4. Build and verify one unused bundle
Generate a new RPC credential in a non-dumpable process. The wallet password and RPC credential
must be distinct. Keep all copies in the one unpublished generation; no `/etc` credential file or
application drop-in is needed.
The protected wallet config must contain exactly these non-secret semantics plus one Digest login:
```text
stagenet=1
wallet-file=/var/lib/monerogue-stagenet-wallet-candidate/wallet/stagenet
password-file=/var/lib/monerogue-stagenet-wallet-candidate/secrets/wallet-password
rpc-bind-port=38084
rpc-bind-ip=127.0.0.1
daemon-address=127.0.0.1:38081
rpc-login=monerogue-candidate:<generated-secret>
non-interactive=1
```
Before publication, open the copied wallet offline on temporary port `39084` as the candidate UID,
with supplementary groups and all capabilities cleared, `no_new_privs`, core dumps disabled, and
logs directed to `/dev/null`. Prove all of the following without printing response data:
- the verifier PID owns the sole exact loopback listener;
- unauthenticated JSON-RPC returns HTTP 401;
- Digest-authenticated `get_address` returns HTTP 200 and the captured stagenet address; and
- termination removes the verifier and listener cleanly.
Then verify every directory/file owner, mode, type, and link count. Write and fsync `STATE.json`,
write `READY` **last**, fsync every file and directory, atomically rename the whole staging
directory to the unused final name with `RENAME_NOREPLACE`, and fsync `/var/lib`. A crash before the
single directory rename leaves no final candidate. An existing final candidate is never replaced.
### 5. Manual candidate validation
Only after an independent reviewer accepts the helper and crash evidence may the inert service
template be installed as `/etc/systemd/system/monerogue-stagenet-wallet-candidate.service`. It has
no `[Install]` section, no boot target, no restart loop, no relationship to `monerogue.service`, and
requires the exact readiness marker.
Start it manually, then prove systemd MainPID/executable/UID/cgroup agreement, the sole
`127.0.0.1:38084` listener owned by that PID, HTTP 401 without auth, HTTP 200 with the protected
netrc, and the sealed public address. The legacy RPC and live app must remain healthy throughout.
### 6. Candidate application canary
The bundle may contain one `root:monerogue 0640` environment override with candidate endpoint and
Digest credentials. It must also force:
```text
HOST=127.0.0.1
PORT=3103
PAYMENTS_ENABLED=false
PAYOUTS_ENABLED=false
DIRECT_PAYMENT_ENABLED=false
DIRECT_PAYOUTS_ENABLED=false
CREDITS_PAYOUTS_ENABLED=false
MATCH_ENABLED=false
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
```
Run the exact release only against a fresh, isolated canary database. Do not attach port 3103 to
the NPM/router, public ingress, production database, payment intake, payout dispatchers, or the
live service. Readiness and a non-money wallet query are the only allowed checks. Stop both canary
app and candidate wallet afterward.
Promotion of the production app from `38083` to `38084` is explicitly out of scope. It requires a
separate reviewed maintenance change and rollback plan.
## Rollback and partial failures
There is no destructive wallet rollback because this path never alters the legacy artifacts.
- During the copy window, always restart and validate the legacy unit from the trap.
- Before candidate activation, leave any partial staging generation root-only and inert. Do not
delete, merge, resume, or overwrite it automatically.
- After candidate activation, stop the candidate unit and prove port `38084` is closed. Restart and
validate the legacy unit if needed. The live application still points to `38083`.
- Preserve the unused candidate bundle for incident review; cleanup is a later exact-target,
separately approved operation.
If the legacy unit cannot restart or its address differs, keep the application stopped/ingress
held under the existing incident runbook. Do not attempt an app cutover to rescue the ceremony.
## GO criteria
All are mandatory before any host mutation beyond provisioning the unused candidate identity:
1. Small fixed-path capture, copy-window, bundle, and validation helpers exist; no general source,
destination, network, unit, port, user, or overwrite arguments exist.
2. Disposable tests inject failure before/after every create, write, chmod/chown, file fsync,
directory fsync, state write, readiness write, stop, start, address check, and final rename.
3. Kill/restart tests prove every partial stage remains inert and every stopped-wallet failure
makes a bounded restart attempt without touching legacy bytes or metadata.
4. Symlink, hardlink, inode replacement, open-writer, wrong UID/cgroup/PID, wildcard/IPv6 listener,
port collision, duplicate password option, and existing-final fixtures all fail closed.
5. A reviewer verifies secrets never enter argv, environment, stdout/stderr, journald, unit text,
tracebacks, Git, ordinary docs, or the non-secret readiness marker.
6. The restored wallet backup plus its password has been opened offline and address-matched, and
mnemonic/off-host recovery custody is resolved separately. A `.keys` file alone is not mnemonic
custody evidence.
7. The complete process succeeds, rolls back, and succeeds again on an isolated stagenet host with
synthetic/unfunded material before touching the funded stagenet wallet.
8. An independent reviewer records GO against an immutable release hash.
Until every item passes, the production-deployable answer is **no**. Only the read-only preflight
and disposable fixture test are approved by this document; even the candidate service template is
not approved for installation.
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Build a deterministic, runtime-only Wowngeon release artifact from one clean Git commit.
# This script deliberately does not install dependencies, read environment files, contact a
# deployment host, or activate a release. See --help and docs/DEPLOY.md for the operator gates.
umask 077
die() {
printf 'build-release-artifact: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
Usage: scripts/deploy/build-release-artifact.sh [options]
Options:
--ref REF Git commit-ish to archive (default: HEAD)
--release-id ID Destination directory name inside the archive
(default: git-<12-character commit prefix>)
--output-dir DIR Existing or new non-symlink output directory
(default: <repository>/dist/releases)
-h, --help Show this help
The builder fails unless tracked files and non-ignored untracked files are clean. It archives only
the runtime allowlist (LICENSE, html/**, and the guarded runtime subset of src/**) from the selected
commit, rejects symlinks/gitlinks and non-example .env files anywhere in that commit, and refuses
to overwrite an artifact or checksum. Repeating the command for the same commit, release ID, and
toolchain produces byte-identical .tar.gz output.
EOF
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "required command is unavailable: $1"
}
for required_command in git tar gzip sha256sum mktemp install ln rm chmod cat dirname basename find sort xargs node; do
require_command "$required_command"
done
script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
repo_root=$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null) \
|| die 'script is not inside a Git worktree'
repo_root=$(CDPATH= cd -- "$repo_root" && pwd -P)
ref=HEAD
release_id=''
output_dir="$repo_root/dist/releases"
while (($# > 0)); do
case "$1" in
--ref)
(($# >= 2)) || die '--ref requires a value'
ref=$2
shift 2
;;
--release-id)
(($# >= 2)) || die '--release-id requires a value'
release_id=$2
shift 2
;;
--output-dir)
(($# >= 2)) || die '--output-dir requires a value'
output_dir=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
# A commit name cannot describe uncommitted candidate content. Refuse both tracked changes and
# non-ignored untracked additions instead of silently producing an artifact different from the
# reviewed tree. Ignored files such as local .env and node_modules are never read or archived.
if [[ -n "$(git -C "$repo_root" status --porcelain=v1 --untracked-files=all)" ]]; then
die 'worktree is dirty; commit or remove every tracked/untracked candidate change first'
fi
commit=$(git -C "$repo_root" rev-parse --verify "${ref}^{commit}" 2>/dev/null) \
|| die "ref does not resolve to a commit: $ref"
[[ "$commit" =~ ^[0-9a-f]{40}$ ]] || die 'resolved commit is not a full SHA-1 object ID'
if [[ -z "$release_id" ]]; then
release_id="git-${commit:0:12}"
fi
[[ "$release_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$ ]] \
|| die 'release ID must be 1-96 safe characters and start with an alphanumeric character'
[[ "$release_id" != '.' && "$release_id" != '..' ]] || die 'release ID cannot be dot or dot-dot'
if [[ -e "$output_dir" || -L "$output_dir" ]]; then
[[ -d "$output_dir" && ! -L "$output_dir" ]] \
|| die 'output directory must be a real directory, not a symlink'
else
install -d -m 0700 -- "$output_dir"
fi
output_dir=$(CDPATH= cd -- "$output_dir" && pwd -P)
artifact="$output_dir/${release_id}.tar.gz"
checksum="$artifact.sha256"
[[ ! -e "$artifact" && ! -L "$artifact" ]] || die "refusing to overwrite: $artifact"
[[ ! -e "$checksum" && ! -L "$checksum" ]] || die "refusing to overwrite: $checksum"
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/wowngeon-release.XXXXXXXX")
artifact_tmp=$(mktemp "$output_dir/.${release_id}.artifact.XXXXXXXX")
checksum_tmp=$(mktemp "$output_dir/.${release_id}.checksum.XXXXXXXX")
published=0
cleanup() {
local status=$?
if [[ "$published" != '1' ]]; then
# Another same-ID builder may have won publication after both processes passed the
# initial existence check. Remove a destination only while it is still our own hard link;
# a losing builder must never unlink the winning builder's artifact or checksum.
if [[ -e "$artifact" && "$artifact" -ef "$artifact_tmp" ]]; then
rm -f -- "$artifact"
fi
if [[ -e "$checksum" && "$checksum" -ef "$checksum_tmp" ]]; then
rm -f -- "$checksum"
fi
fi
rm -f -- "$artifact_tmp" "$checksum_tmp"
rm -rf -- "$work_dir"
return "$status"
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
source_tar="$work_dir/runtime-source.tar"
stage_root="$work_dir/stage"
release_root="$stage_root/$release_id"
install -d -m 0700 -- "$release_root"
# Git trees can encode regular files, executables, symlinks, and gitlinks. Releases accept only
# the first two. This also validates path policy before tar is allowed to extract anything.
while IFS= read -r -d '' tree_entry; do
metadata=${tree_entry%%$'\t'*}
path=${tree_entry#*$'\t'}
mode=${metadata%% *}
[[ "$metadata" != "$path" ]] || die 'malformed Git tree entry'
[[ "$mode" == '100644' || "$mode" == '100755' ]] \
|| die "tracked symlink, gitlink, or unsafe file mode is forbidden: $path"
[[ "$path" != *$'\n'* && "$path" != *$'\r'* ]] \
|| die 'tracked filenames containing newline or carriage return are forbidden'
[[ "$path" != /* && "$path" != ../* && "$path" != */../* && "$path" != */.. ]] \
|| die "unsafe tracked path: $path"
[[ "/$path/" != *'/.git/'* && "/$path/" != *'/node_modules/'* ]] \
|| die "tracked VCS/dependency state is forbidden: $path"
filename=$(basename -- "$path")
case "$filename" in
.env)
die "tracked runtime environment file is forbidden: $path"
;;
.env.*)
[[ "$filename" == *.example ]] \
|| die "tracked non-example environment file is forbidden: $path"
;;
.npmrc|.yarnrc|.netrc)
die "tracked credential-capable configuration file is forbidden: $path"
;;
esac
done < <(git -C "$repo_root" ls-tree -rz --full-tree "$commit")
# Production releases intentionally exclude source documentation, tests, root-level operator
# tooling/service units, and environment examples. Operational artifacts remain in the reviewed
# source/fleet repositories and must have their own pinned provenance; they are never made part of
# the application runtime merely because they share a Git commit.
runtime_pathspecs=(
'LICENSE'
'html'
'src'
':(exclude)src/.env*'
':(glob,exclude)src/**/.env*'
':(exclude)src/scripts/pvp-capture.js'
':(exclude)src/scripts/stagenet-financial-canary.js'
':(exclude)src/scripts/setup_db.js'
':(exclude)src/scripts/smoke.js'
':(exclude)src/jest.config.js'
':(exclude)src/sim'
)
git -C "$repo_root" archive --format=tar "$commit" -- "${runtime_pathspecs[@]}" >"$source_tar"
source_hash=$(sha256sum "$source_tar")
source_hash=${source_hash%% *}
[[ "$source_hash" =~ ^[0-9a-f]{64}$ ]] || die 'could not calculate source archive SHA-256'
tar --extract --file="$source_tar" --directory="$release_root" --no-same-owner --no-same-permissions
runtime_roots=$(find "$release_root" -mindepth 1 -maxdepth 1 -printf '%f\n' | LC_ALL=C sort)
[[ "$runtime_roots" == $'LICENSE\nhtml\nsrc' ]] \
|| die 'runtime archive contains an unexpected or missing top-level path'
[[ -z "$(find "$release_root/src" -type f -name '.env*' -print -quit)" ]] \
|| die 'runtime archive contains a forbidden environment file or example'
for required_file in LICENSE src/index.js src/package.json src/package-lock.json \
src/scripts/preflight.js src/scripts/migrate-disposable-clone.js html/index.html; do
[[ -f "$release_root/$required_file" && ! -L "$release_root/$required_file" ]] \
|| die "required release file is absent or unsafe: $required_file"
done
# Keep the source package manifest useful to developers while exposing only runnable commands in
# the runtime artifact. This deterministic metadata-only rewrite does not change dependencies or
# lockfile resolution; the final runtime bytes are covered by .release-files.sha256 below.
node - "$release_root/src/package.json" <<'NODE'
'use strict';
const fs = require('fs');
const manifestPath = process.argv[2];
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const sourceScripts = manifest.scripts && typeof manifest.scripts === 'object'
? manifest.scripts
: {};
const runtimeScripts = {};
for (const name of ['start', 'preflight', 'db:migrate:clone']) {
if (typeof sourceScripts[name] !== 'string' || sourceScripts[name].trim().length === 0) {
throw new Error(`required runtime package script is absent or empty: ${name}`);
}
runtimeScripts[name] = sourceScripts[name];
}
manifest.scripts = runtimeScripts;
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 });
NODE
lock_hash=$(sha256sum "$release_root/src/package-lock.json")
lock_hash=${lock_hash%% *}
commit_epoch=$(git -C "$repo_root" show -s --format=%ct "$commit")
[[ "$commit_epoch" =~ ^[0-9]+$ ]] || die 'commit timestamp is invalid'
printf '%s\n' "$source_hash" >"$release_root/.release-source-sha256"
printf '%s\n' "$commit" >"$release_root/.release-commit"
printf '%s\n' "$release_id" >"$release_root/.release-id"
printf '%s\n' "$lock_hash" >"$release_root/.release-package-lock-sha256"
(
cd "$release_root"
find . -type f ! -path './.release-files.sha256' -print0 \
| LC_ALL=C sort -z \
| xargs -0 -r sha256sum --
) >"$release_root/.release-files.sha256"
# Normalize ownership, modes, ordering, timestamps, and gzip headers. Metadata files deliberately
# identify the prefix-free, runtime-allowlisted `git archive` hash; the sidecar identifies the
# final blob.
tar \
--sort=name \
--format=posix \
--pax-option=delete=atime,delete=ctime \
--mtime="@$commit_epoch" \
--owner=0 --group=0 --numeric-owner \
--mode='u+rwX,go+rX,go-w' \
--directory="$stage_root" \
--create --file=- "$release_id" \
| gzip -n >"$artifact_tmp"
artifact_hash=$(sha256sum "$artifact_tmp")
artifact_hash=${artifact_hash%% *}
printf '%s %s\n' "$artifact_hash" "$(basename -- "$artifact")" >"$checksum_tmp"
# Hard-link publication is atomic and refuses an existing destination even under a concurrent run.
ln -- "$artifact_tmp" "$artifact" || die "could not publish artifact without overwrite: $artifact"
if ! ln -- "$checksum_tmp" "$checksum"; then
rm -f -- "$artifact"
die "could not publish checksum without overwrite: $checksum"
fi
chmod 0600 -- "$artifact" "$checksum"
published=1
printf 'release_id=%s\ncommit=%s\nsource_sha256=%s\nartifact_sha256=%s\nartifact=%s\nchecksum=%s\n' \
"$release_id" "$commit" "$source_hash" "$artifact_hash" "$artifact" "$checksum"
+103
View File
@@ -0,0 +1,103 @@
\set ON_ERROR_STOP on
\pset pager off
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT current_database() AS database,
COUNT(*) AS applied_migrations,
MAX(filename) AS latest_migration
FROM schema_migrations;
WITH audit(category, row_count, atomic_amount) AS (
SELECT 'payout_nonterminal', COUNT(*), COALESCE(SUM(amount), 0)::numeric
FROM payouts
WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT 'payout_completed_missing_tx', COUNT(*), COALESCE(SUM(amount), 0)::numeric
FROM payouts
WHERE status = 'completed' AND NULLIF(BTRIM(COALESCE(tx_hash, '')), '') IS NULL
UNION ALL
SELECT 'refund_unsettled', COUNT(*), COALESCE(SUM(amount), 0)::numeric
FROM payment_refunds
WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT 'solo_orphan', COUNT(*), 0::numeric
FROM games
WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')
UNION ALL
SELECT 'match_orphan', COUNT(*), COALESCE(SUM(payout_liability_amount_atomic), 0)::numeric
FROM matches
WHERE status IS NULL OR status NOT IN ('finished', 'cancelled')
UNION ALL
SELECT 'match_queue_outstanding', COUNT(*), COALESCE(SUM(escrow_value_atomic), 0)::numeric
FROM match_queue_entries
WHERE status IS NULL OR status NOT IN ('consumed', 'cancelled')
UNION ALL
SELECT 'solo_liability_without_payout', COUNT(*),
COALESCE(SUM(CASE
WHEN treasure_found THEN payout_treasure_amount
ELSE payout_escape_amount
END), 0)::numeric
FROM games g
WHERE g.status = 'won'
AND g.payout_eligible = TRUE
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id)
UNION ALL
SELECT 'match_liability_without_payout', COUNT(*),
COALESCE(SUM(m.payout_liability_amount_atomic), 0)::numeric
FROM matches m
WHERE m.economy = 'crypto_race'
AND m.status = 'finished'
AND m.winner_user_id IS NOT NULL
AND m.payout_liability_accepted_at IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id)
UNION ALL
SELECT 'late_payment_manual_review', COUNT(*), COALESCE(SUM(observed_amount), 0)::numeric
FROM payment_late_reviews
WHERE status IS NULL OR status NOT IN ('resolved', 'ignored')
)
SELECT category, row_count, atomic_amount
FROM audit
ORDER BY category;
WITH ledger AS (
SELECT COUNT(*) = 40
AND MAX(filename) = '040_paid_match_entropy_precommit.sql' AS valid
FROM schema_migrations
), audit(row_count) AS (
SELECT COUNT(*) FROM payouts WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT COUNT(*) FROM payouts
WHERE status = 'completed' AND NULLIF(BTRIM(COALESCE(tx_hash, '')), '') IS NULL
UNION ALL
SELECT COUNT(*) FROM payment_refunds
WHERE status IS DISTINCT FROM 'completed'
UNION ALL
SELECT COUNT(*) FROM games WHERE status IS NULL OR status NOT IN ('won', 'lost', 'expired')
UNION ALL
SELECT COUNT(*) FROM matches WHERE status IS NULL OR status NOT IN ('finished', 'cancelled')
UNION ALL
SELECT COUNT(*) FROM match_queue_entries WHERE status IS NULL OR status NOT IN ('consumed', 'cancelled')
UNION ALL
SELECT COUNT(*) FROM games g
WHERE g.status = 'won' AND g.payout_eligible = TRUE
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.game_id = g.id)
UNION ALL
SELECT COUNT(*) FROM matches m
WHERE m.economy = 'crypto_race' AND m.status = 'finished'
AND m.winner_user_id IS NOT NULL
AND m.payout_liability_accepted_at IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM payouts p WHERE p.match_id = m.id)
UNION ALL
SELECT COUNT(*) FROM payment_late_reviews WHERE status IS NULL OR status NOT IN ('resolved', 'ignored')
)
SELECT ((SELECT valid FROM ledger) AND COALESCE(SUM(row_count), 0) = 0) AS financial_audit_clean FROM audit \gset
\if :financial_audit_clean
\echo 'PASS: no dispatchable/ambiguous financial state or orphaned gameplay state'
\else
\echo 'FAIL: reconcile every nonzero category before any payout-capable boot'
\quit 3
\endif
ROLLBACK;
@@ -0,0 +1,212 @@
-- Read-only release gate for every CHECK constraint introduced with NOT VALID in migrations.
--
-- Usage (connection arguments deliberately omitted):
-- psql -X -v ON_ERROR_STOP=1 \
-- --set=expected_database=EXACT_DATABASE_NAME \
-- --file=scripts/deploy/financial-constraint-audit.sql
--
-- This script is safe to run against a live database: it opens one repeatable-read, read-only
-- transaction, takes no explicit table lock, and makes no catalog or data change.
\set ON_ERROR_STOP on
\pset pager off
\if :{?expected_database}
\else
\echo 'FAIL: pass --set=expected_database=EXACT_DATABASE_NAME'
\quit 2
\endif
SELECT current_database() = :'expected_database' AS expected_database_matches \gset
\if :expected_database_matches
\else
\echo 'FAIL: connected database does not exactly match expected_database'
\quit 2
\endif
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET LOCAL statement_timeout = '10min';
SET LOCAL lock_timeout = '5s';
-- Names are intentionally explicit. A missing/renamed constraint is a release-gate failure even
-- if the equivalent predicate happens to be implemented elsewhere.
WITH expected(table_name, constraint_name) AS (
VALUES
('payments', 'payments_status_check'),
('payments', 'payments_payment_type_check'),
('payouts', 'payouts_status_check'),
('games', 'games_payout_commitment_complete'),
('games', 'games_layout_fingerprints_array_check'),
('race_entry_lots', 'race_entry_lots_remaining_within_original'),
('match_queue_entries', 'match_queue_entries_status_check'),
('match_queue_entries', 'match_queue_entries_escrow_nonnegative'),
('match_queue_entries', 'match_queue_committed_escrow_complete'),
('matches', 'matches_payout_liability_amount_nonnegative'),
('matches', 'matches_payout_liability_cap_positive'),
('matches', 'matches_payout_liability_complete'),
('matches', 'matches_payout_liability_economics_consistent'),
('race_entry_lots', 'race_entry_lots_refund_shape'),
('payment_refunds', 'payment_refunds_progress_nonnegative'),
('games', 'games_entry_consumption_shape'),
('payments', 'payments_fairness_binding_complete'),
('payouts', 'payouts_no_address_review_shape')
), actual AS (
SELECT classes.relname AS table_name,
constraints.conname AS constraint_name,
constraints.convalidated
FROM pg_catalog.pg_constraint AS constraints
JOIN pg_catalog.pg_class AS classes ON classes.oid = constraints.conrelid
JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.oid = classes.relnamespace
WHERE namespaces.nspname = 'public'
AND constraints.contype = 'c'
)
SELECT COUNT(*) = 18
AND COUNT(actual.constraint_name) = 18 AS constraint_inventory_ok,
COUNT(*) FILTER (WHERE actual.convalidated IS FALSE) AS unvalidated_constraints,
COALESCE(string_agg(expected.table_name || '.' || expected.constraint_name, ', '
ORDER BY expected.table_name, expected.constraint_name)
FILTER (WHERE actual.constraint_name IS NULL), '') AS missing_constraints
FROM expected
LEFT JOIN actual USING (table_name, constraint_name)
\gset
\if :constraint_inventory_ok
\echo 'Constraint inventory present; currently unvalidated:' :unvalidated_constraints
\else
ROLLBACK;
\echo 'FAIL: required financial CHECK constraint inventory is incomplete:' :missing_constraints
\quit 3
\endif
-- These predicates mirror PostgreSQL CHECK semantics exactly: only FALSE is a violation; NULL is
-- accepted by CHECK. Counts diagnose historical rows that predate NOT VALID constraint creation.
WITH violations(constraint_name, row_count) AS (
SELECT 'payments_status_check', COUNT(*)::bigint
FROM payments
WHERE (status IN ('pending', 'confirmed', 'expired', 'refunded')) IS FALSE
UNION ALL
SELECT 'payments_payment_type_check', COUNT(*)::bigint
FROM payments
WHERE (payment_type IN ('single_game', 'credits_package', 'cosmetic_pack')) IS FALSE
UNION ALL
SELECT 'payouts_status_check', COUNT(*)::bigint
FROM payouts
WHERE (status IN ('pending', 'processing', 'completed', 'failed', 'needs_review',
'permanently_failed', 'batched')) IS FALSE
UNION ALL
SELECT 'games_payout_commitment_complete', COUNT(*)::bigint
FROM games
WHERE (payout_committed_at IS NULL
OR (payout_eligible IS NOT NULL AND payout_terms IS NOT NULL)) IS FALSE
UNION ALL
SELECT 'games_layout_fingerprints_array_check', COUNT(*)::bigint
FROM games
WHERE (layout_fingerprints IS NULL OR jsonb_typeof(layout_fingerprints) = 'array') IS FALSE
UNION ALL
SELECT 'race_entry_lots_remaining_within_original', COUNT(*)::bigint
FROM race_entry_lots
WHERE (remaining_entries >= 0 AND remaining_entries <= original_entries) IS FALSE
UNION ALL
SELECT 'match_queue_entries_status_check', COUNT(*)::bigint
FROM match_queue_entries
WHERE (status IN ('queued', 'matched', 'consumed', 'cancelled')) IS FALSE
UNION ALL
SELECT 'match_queue_entries_escrow_nonnegative', COUNT(*)::bigint
FROM match_queue_entries
WHERE (escrow_amount >= 0 AND escrow_value_atomic >= 0) IS FALSE
UNION ALL
SELECT 'match_queue_committed_escrow_complete', COUNT(*)::bigint
FROM match_queue_entries
WHERE (
status NOT IN ('matched', 'consumed')
OR economy = 'free'
OR (economy = 'credits_prestige' AND escrow_amount > 0)
OR (economy = 'crypto_race' AND escrow_amount = 1
AND escrow_value_atomic > 0 AND race_entry_lot_id IS NOT NULL)
) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_amount_nonnegative', COUNT(*)::bigint
FROM matches
WHERE (payout_liability_amount_atomic IS NULL
OR payout_liability_amount_atomic >= 0) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_cap_positive', COUNT(*)::bigint
FROM matches
WHERE (payout_liability_cap_atomic IS NULL OR payout_liability_cap_atomic > 0) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_complete', COUNT(*)::bigint
FROM matches
WHERE (
payout_liability_accepted_at IS NULL
OR (economy = 'crypto_race'
AND payout_liability_amount_atomic IS NOT NULL
AND payout_liability_amount_atomic > 0
AND payout_liability_cap_atomic IS NOT NULL
AND payout_liability_amount_atomic <= payout_liability_cap_atomic
AND payout_liability_terms IS NOT NULL)
) IS FALSE
UNION ALL
SELECT 'matches_payout_liability_economics_consistent', COUNT(*)::bigint
FROM matches
WHERE (
payout_liability_accepted_at IS NULL
OR (entry_fee_atomic > 0
AND pot_atomic > 0
AND house_fee_atomic >= 0
AND house_fee_atomic < pot_atomic
AND payout_liability_amount_atomic = pot_atomic - house_fee_atomic)
) IS FALSE
UNION ALL
SELECT 'race_entry_lots_refund_shape', COUNT(*)::bigint
FROM race_entry_lots
WHERE (refunded_at IS NULL OR remaining_entries = 0) IS FALSE
UNION ALL
SELECT 'payment_refunds_progress_nonnegative', COUNT(*)::bigint
FROM payment_refunds
WHERE (purchase_progress_deducted >= 0) IS FALSE
UNION ALL
SELECT 'games_entry_consumption_shape', COUNT(*)::bigint
FROM games
WHERE (entry_credits_spent IS NULL OR entry_credits_spent > 0) IS FALSE
UNION ALL
SELECT 'payments_fairness_binding_complete', COUNT(*)::bigint
FROM payments
WHERE (
fairness_bound_at IS NULL
OR (payment_type = 'single_game'
AND fairness_proof_version = 2
AND fairness_offer_id IS NOT NULL
AND fairness_offer_issued_at IS NOT NULL
AND fairness_commitment ~ '^[0-9a-f]{64}$'
AND fairness_server_seed ~ '^[0-9a-f]{64}$'
AND fairness_client_seed IS NOT NULL)
) IS FALSE
UNION ALL
SELECT 'payouts_no_address_review_shape', COUNT(*)::bigint
FROM payouts
WHERE (
payout_address <> 'PENDING_NO_ADDRESS'
OR (status = 'needs_review'
AND reason IN (
'match_winner_no_address',
'solo_winner_no_address',
'solo_winner_identity_review'
)
AND tx_hash IS NULL)
) IS FALSE
)
SELECT COALESCE(SUM(row_count), 0) = 0 AS historical_constraints_clean,
jsonb_object_agg(constraint_name, row_count ORDER BY constraint_name)::text
AS violation_counts
FROM violations
\gset
\echo 'Historical NOT VALID constraint violation counts:' :violation_counts
\if :historical_constraints_clean
ROLLBACK;
\echo 'PASS: every historical row satisfies all 18 NOT VALID financial/fairness constraints'
\else
ROLLBACK;
\echo 'FAIL: reconcile every nonzero historical constraint category before release'
\quit 3
\endif
@@ -0,0 +1,103 @@
-- Prove PostgreSQL can VALIDATE every historical NOT VALID constraint on a disposable restore.
-- Every catalog update is rolled back, but ALTER TABLE VALIDATE CONSTRAINT still scans and locks;
-- this script therefore refuses databases whose names do not contain "restore" or "scratch".
-- Never run it against a live application database.
--
-- Usage (connection arguments deliberately omitted):
-- psql -X -v ON_ERROR_STOP=1 \
-- --set=expected_database=EXACT_RESTORE_DATABASE \
-- --set=confirm_disposable=I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE \
-- --file=scripts/deploy/financial-constraint-validate-restore.sql
\set ON_ERROR_STOP on
\pset pager off
\if :{?expected_database}
\else
\echo 'FAIL: pass --set=expected_database=EXACT_RESTORE_DATABASE'
\quit 2
\endif
\if :{?confirm_disposable}
\else
\echo 'FAIL: pass the documented confirm_disposable value'
\quit 2
\endif
SELECT current_database() = :'expected_database'
AND current_database() ~* '(restore|scratch)'
AND :'confirm_disposable' = 'I_CONFIRM_THIS_DATABASE_IS_A_DISPOSABLE_RESTORE'
AS disposable_restore_confirmed
\gset
\if :disposable_restore_confirmed
\else
\echo 'FAIL: VALIDATE is restricted to the exact, explicitly confirmed restore/scratch database'
\quit 2
\endif
-- First provide named historical violation counts under a read-only snapshot. The included audit
-- exits nonzero before any ALTER if the inventory is incomplete or any old row violates a CHECK.
\ir financial-constraint-audit.sql
BEGIN;
SET LOCAL statement_timeout = '10min';
SET LOCAL lock_timeout = '5s';
ALTER TABLE public.payments VALIDATE CONSTRAINT payments_status_check;
ALTER TABLE public.payments VALIDATE CONSTRAINT payments_payment_type_check;
ALTER TABLE public.payouts VALIDATE CONSTRAINT payouts_status_check;
ALTER TABLE public.games VALIDATE CONSTRAINT games_payout_commitment_complete;
ALTER TABLE public.games VALIDATE CONSTRAINT games_layout_fingerprints_array_check;
ALTER TABLE public.race_entry_lots VALIDATE CONSTRAINT race_entry_lots_remaining_within_original;
ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_entries_status_check;
ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_entries_escrow_nonnegative;
ALTER TABLE public.match_queue_entries VALIDATE CONSTRAINT match_queue_committed_escrow_complete;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_amount_nonnegative;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_cap_positive;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_complete;
ALTER TABLE public.matches VALIDATE CONSTRAINT matches_payout_liability_economics_consistent;
ALTER TABLE public.race_entry_lots VALIDATE CONSTRAINT race_entry_lots_refund_shape;
ALTER TABLE public.payment_refunds VALIDATE CONSTRAINT payment_refunds_progress_nonnegative;
ALTER TABLE public.games VALIDATE CONSTRAINT games_entry_consumption_shape;
ALTER TABLE public.payments VALIDATE CONSTRAINT payments_fairness_binding_complete;
ALTER TABLE public.payouts VALIDATE CONSTRAINT payouts_no_address_review_shape;
WITH expected(table_name, constraint_name) AS (
VALUES
('payments', 'payments_status_check'),
('payments', 'payments_payment_type_check'),
('payouts', 'payouts_status_check'),
('games', 'games_payout_commitment_complete'),
('games', 'games_layout_fingerprints_array_check'),
('race_entry_lots', 'race_entry_lots_remaining_within_original'),
('match_queue_entries', 'match_queue_entries_status_check'),
('match_queue_entries', 'match_queue_entries_escrow_nonnegative'),
('match_queue_entries', 'match_queue_committed_escrow_complete'),
('matches', 'matches_payout_liability_amount_nonnegative'),
('matches', 'matches_payout_liability_cap_positive'),
('matches', 'matches_payout_liability_complete'),
('matches', 'matches_payout_liability_economics_consistent'),
('race_entry_lots', 'race_entry_lots_refund_shape'),
('payment_refunds', 'payment_refunds_progress_nonnegative'),
('games', 'games_entry_consumption_shape'),
('payments', 'payments_fairness_binding_complete'),
('payouts', 'payouts_no_address_review_shape')
)
SELECT COUNT(*) = 18 AND BOOL_AND(constraints.convalidated) AS all_constraints_validated
FROM expected
JOIN pg_catalog.pg_namespace AS namespaces ON namespaces.nspname = 'public'
JOIN pg_catalog.pg_class AS classes
ON classes.relnamespace = namespaces.oid AND classes.relname = expected.table_name
JOIN pg_catalog.pg_constraint AS constraints
ON constraints.conrelid = classes.oid
AND constraints.conname = expected.constraint_name
AND constraints.contype = 'c'
\gset
\if :all_constraints_validated
ROLLBACK;
\echo 'PASS: PostgreSQL validated all 18 constraints on the disposable restore; catalog changes rolled back'
\else
ROLLBACK;
\echo 'FAIL: explicit VALIDATE CONSTRAINT coverage was incomplete'
\quit 3
\endif
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Disposable fixture proof for the parallel-wallet filesystem contract.
This test uses only synthetic bytes below a fresh TemporaryDirectory. It never imports or invokes
the host preflight, systemd, a wallet binary, a wallet file, or a credential. Run directly with:
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
"""
from __future__ import annotations
import ctypes
import errno
import os
import pathlib
import secrets
import stat
import tempfile
import unittest
from collections.abc import Callable
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
RENAME_NOREPLACE = 1
LIBC = ctypes.CDLL(None, use_errno=True)
class Refusal(RuntimeError):
pass
def rename_noreplace(source_fd: int, source: str, destination_fd: int, destination: str) -> None:
result = LIBC.renameat2(
ctypes.c_int(source_fd),
ctypes.c_char_p(os.fsencode(source)),
ctypes.c_int(destination_fd),
ctypes.c_char_p(os.fsencode(destination)),
ctypes.c_uint(RENAME_NOREPLACE),
)
if result != 0:
problem = ctypes.get_errno()
raise OSError(problem, os.strerror(problem), destination)
def open_directory_nofollow(path: pathlib.Path) -> int:
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
raise Refusal("path is not absolute and normalized")
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
try:
for component in path.parts[1:]:
following = os.open(
component,
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
dir_fd=descriptor,
)
os.close(descriptor)
descriptor = following
return descriptor
except Exception:
os.close(descriptor)
raise
def file_identity(details: os.stat_result) -> tuple[int, ...]:
return (
details.st_dev,
details.st_ino,
details.st_uid,
details.st_gid,
stat.S_IMODE(details.st_mode),
details.st_nlink,
details.st_size,
details.st_mtime_ns,
details.st_ctime_ns,
)
def open_source(directory_fd: int, name: str) -> tuple[int, tuple[int, ...]]:
descriptor = os.open(name, os.O_RDONLY | O_NOFOLLOW, dir_fd=directory_fd)
details = os.fstat(descriptor)
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
os.close(descriptor)
raise Refusal("source is not a single-linked regular file")
if stat.S_IMODE(details.st_mode) != 0o600:
os.close(descriptor)
raise Refusal("source mode differs from the contract")
return descriptor, file_identity(details)
def copy_one(
source_fd: int,
expected: tuple[int, ...],
destination_fd: int,
name: str,
after_copy: Callable[[], None] | None = None,
) -> None:
temporary = f".{name}.pending.{secrets.token_hex(8)}"
output = os.open(
temporary,
os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW,
0o600,
dir_fd=destination_fd,
)
published = False
try:
os.lseek(source_fd, 0, os.SEEK_SET)
while True:
block = os.read(source_fd, 1024 * 1024)
if not block:
break
view = memoryview(block)
while view:
written = os.write(output, view)
if written <= 0:
raise OSError("short synthetic copy write")
view = view[written:]
os.fsync(output)
if after_copy is not None:
after_copy()
if file_identity(os.fstat(source_fd)) != expected:
raise Refusal("source metadata changed during copy")
os.lseek(source_fd, 0, os.SEEK_SET)
os.lseek(output, 0, os.SEEK_SET)
while True:
source_block = os.read(source_fd, 1024 * 1024)
output_block = os.read(output, 1024 * 1024)
if source_block != output_block:
raise Refusal("copy differs from retained source")
if not source_block:
break
os.close(output)
output = -1
rename_noreplace(destination_fd, temporary, destination_fd, name)
published = True
os.fsync(destination_fd)
finally:
if output >= 0:
os.close(output)
if not published:
try:
os.unlink(temporary, dir_fd=destination_fd)
except FileNotFoundError:
pass
def copy_pair(source: pathlib.Path, wallet_destination: pathlib.Path) -> None:
source_fd = open_directory_nofollow(source)
destination_fd = open_directory_nofollow(wallet_destination)
try:
opened: list[tuple[int, tuple[int, ...], str]] = []
try:
for source_name, destination_name in (
("test", "stagenet"),
("test.keys", "stagenet.keys"),
):
descriptor, expected = open_source(source_fd, source_name)
opened.append((descriptor, expected, destination_name))
for descriptor, expected, destination_name in opened:
copy_one(descriptor, expected, destination_fd, destination_name)
finally:
for descriptor, _, _ in opened:
os.close(descriptor)
finally:
os.close(source_fd)
os.close(destination_fd)
def require_ready(stage: pathlib.Path) -> None:
descriptor = open_directory_nofollow(stage)
try:
marker = os.open("READY", os.O_RDONLY | O_NOFOLLOW, dir_fd=descriptor)
try:
details = os.fstat(marker)
if (
not stat.S_ISREG(details.st_mode)
or details.st_nlink != 1
or stat.S_IMODE(details.st_mode) != 0o600
):
raise Refusal("readiness marker metadata is invalid")
finally:
os.close(marker)
except FileNotFoundError:
raise Refusal("readiness marker is absent")
finally:
os.close(descriptor)
def publish(stage: pathlib.Path, final: pathlib.Path) -> None:
require_ready(stage)
if stage.parent != final.parent:
raise Refusal("publication must be one same-filesystem directory rename")
parent = open_directory_nofollow(stage.parent)
try:
rename_noreplace(parent, stage.name, parent, final.name)
os.fsync(parent)
finally:
os.close(parent)
class BundleFixture(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory(prefix="monero-parallel-fixture-")
self.root = pathlib.Path(self.temporary.name)
self.source = self.root / "legacy"
self.stage = self.root / ".candidate.staging"
self.final = self.root / "candidate"
self.source.mkdir(mode=0o700)
self.stage.mkdir(mode=0o700)
(self.stage / "wallet").mkdir(mode=0o700)
for name, content in (("test", b"synthetic-wallet\0bytes"), ("test.keys", b"synthetic-key\0bytes")):
path = self.source / name
path.write_bytes(content)
path.chmod(0o600)
def tearDown(self) -> None:
self.temporary.cleanup()
def ready(self) -> None:
marker = self.stage / "READY"
descriptor = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | O_NOFOLLOW, 0o600)
try:
os.write(descriptor, b"network=stagenet\nrpc_bind=127.0.0.1:38084\n")
os.fchmod(descriptor, 0o600)
os.fsync(descriptor)
finally:
os.close(descriptor)
stage_fd = open_directory_nofollow(self.stage)
try:
os.fsync(stage_fd)
finally:
os.close(stage_fd)
def test_partial_stage_is_not_a_candidate(self) -> None:
copy_pair(self.source, self.stage / "wallet")
with self.assertRaises(Refusal):
publish(self.stage, self.final)
self.assertFalse(self.final.exists())
self.assertTrue(self.stage.exists())
def test_ready_bundle_is_one_atomic_nonreplacing_publish(self) -> None:
copy_pair(self.source, self.stage / "wallet")
self.ready()
publish(self.stage, self.final)
self.assertFalse(self.stage.exists())
self.assertEqual((self.final / "wallet" / "stagenet").read_bytes(), b"synthetic-wallet\0bytes")
self.assertEqual((self.final / "wallet" / "stagenet.keys").read_bytes(), b"synthetic-key\0bytes")
def test_existing_final_is_never_replaced(self) -> None:
self.ready()
self.final.mkdir(mode=0o700)
sentinel = self.final / "sentinel"
sentinel.write_text("older generation", encoding="ascii")
with self.assertRaises(OSError) as problem:
publish(self.stage, self.final)
self.assertEqual(problem.exception.errno, errno.EEXIST)
self.assertEqual(sentinel.read_text(encoding="ascii"), "older generation")
self.assertTrue(self.stage.exists())
def test_symlink_component_is_refused(self) -> None:
linked = self.root / "linked-source"
linked.symlink_to(self.source, target_is_directory=True)
with self.assertRaises(OSError):
copy_pair(linked, self.stage / "wallet")
def test_hard_linked_key_is_refused(self) -> None:
os.link(self.source / "test.keys", self.root / "second-link")
with self.assertRaises(Refusal):
copy_pair(self.source, self.stage / "wallet")
self.assertFalse((self.stage / "wallet" / "stagenet.keys").exists())
def test_source_change_is_detected_before_publication(self) -> None:
source_fd = open_directory_nofollow(self.source)
destination_fd = open_directory_nofollow(self.stage / "wallet")
descriptor, expected = open_source(source_fd, "test")
try:
def mutate() -> None:
with open(self.source / "test", "ab") as output:
output.write(b"changed")
with self.assertRaises(Refusal):
copy_one(descriptor, expected, destination_fd, "stagenet", mutate)
self.assertFalse((self.stage / "wallet" / "stagenet").exists())
finally:
os.close(descriptor)
os.close(source_fd)
os.close(destination_fd)
def test_marker_contains_no_synthetic_secret(self) -> None:
self.ready()
self.assertNotIn(b"synthetic-key", (self.stage / "READY").read_bytes())
self.assertNotIn(b"synthetic-wallet", (self.stage / "READY").read_bytes())
def test_linked_readiness_marker_is_refused(self) -> None:
external = self.root / "external-marker"
external.write_text("network=stagenet\n", encoding="ascii")
external.chmod(0o600)
os.link(external, self.stage / "READY")
with self.assertRaises(Refusal):
publish(self.stage, self.final)
self.assertFalse(self.final.exists())
if __name__ == "__main__":
os.umask(0o077)
unittest.main(verbosity=2)
+374
View File
@@ -0,0 +1,374 @@
#!/usr/bin/env python3
"""Read-only preflight for a parallel Monero stagenet wallet-RPC candidate.
This script does not read systemd unit text, process arguments, wallet bytes, keys, passwords, or
mnemonics. It does not create, modify, move, or delete anything. It validates only public RPC
identity, process/listener identity, path metadata, capacity, and inert candidate prerequisites.
"""
from __future__ import annotations
import argparse
import ctypes
import grp
import http.client
import json
import os
import pathlib
import pwd
import resource
import socket
import stat
import subprocess
import sys
import urllib.error
import urllib.request
LEGACY_UNIT = "monero-wallet-rpc.service"
LEGACY_USER = "jw"
LEGACY_GROUP = "jw"
LEGACY_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
LEGACY_DIR = pathlib.Path("/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4")
LEGACY_WALLET = LEGACY_DIR / "test"
LEGACY_KEYS = LEGACY_DIR / "test.keys"
LEGACY_RPC_PORT = 38083
LEGACY_RPC_URL = "http://127.0.0.1:38083/json_rpc"
CANDIDATE_UNIT = "monerogue-stagenet-wallet-candidate.service"
CANDIDATE_USER = "monerogue-wallet-candidate"
CANDIDATE_GROUP = "monerogue-wallet-candidate"
CANDIDATE_HOME = pathlib.Path("/var/lib/monerogue-stagenet-wallet-candidate")
CANDIDATE_STAGE = pathlib.Path("/var/lib/.monerogue-stagenet-wallet-candidate.staging")
CANDIDATE_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
CANDIDATE_RPC_PORT = 38084
VERIFY_RPC_PORT = 39084
ADDRESS_REQUEST = {
"jsonrpc": "2.0",
"id": "parallel-preflight",
"method": "get_address",
"params": {"account_index": 0},
}
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
LIBC = ctypes.CDLL(None, use_errno=True)
class Refusal(RuntimeError):
pass
def refuse(message: str) -> None:
raise Refusal(message)
def harden_process() -> None:
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
try:
if LIBC.prctl(4, 0, 0, 0, 0) != 0: # PR_SET_DUMPABLE
refuse("could not disable process dumps")
except AttributeError:
refuse("could not disable process dumps")
def identity(user: str, group: str) -> tuple[int, int]:
try:
return pwd.getpwnam(user).pw_uid, grp.getgrnam(group).gr_gid
except KeyError:
refuse(f"required identity is missing: {user}:{group}")
def open_directory_nofollow(path: pathlib.Path) -> int:
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
refuse("path is not absolute and normalized")
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
try:
for component in path.parts[1:]:
following = os.open(
component,
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
dir_fd=descriptor,
)
os.close(descriptor)
descriptor = following
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
refuse("opened path is not a directory")
return descriptor
except Exception:
os.close(descriptor)
raise
def require_directory(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
try:
descriptor = open_directory_nofollow(path)
except (FileNotFoundError, NotADirectoryError, OSError):
refuse(f"required real directory is unavailable: {path}")
try:
details = os.fstat(descriptor)
expected = (*identity(user, group), mode)
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
if actual != expected:
refuse(f"directory metadata mismatch: {path}")
return details
finally:
os.close(descriptor)
def require_regular(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
parent = open_directory_nofollow(path.parent)
try:
try:
descriptor = os.open(path.name, os.O_RDONLY | O_NOFOLLOW, dir_fd=parent)
except OSError:
refuse(f"required real file is unavailable: {path}")
try:
details = os.fstat(descriptor)
expected = (*identity(user, group), mode)
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1 or actual != expected:
refuse(f"file metadata mismatch or hard link refused: {path}")
if details.st_size <= 0:
refuse(f"required file is empty: {path}")
return details
finally:
os.close(descriptor)
finally:
os.close(parent)
def systemctl(*arguments: str) -> subprocess.CompletedProcess[bytes]:
try:
return subprocess.run(
["/usr/bin/systemctl", *arguments],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
check=False,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired):
refuse("systemd state check failed")
def main_pid(unit: str) -> int:
result = systemctl("show", unit, "--property=MainPID", "--value")
if result.returncode != 0:
refuse("systemd MainPID check failed")
try:
return int(result.stdout.strip())
except ValueError:
refuse("systemd returned an invalid MainPID")
def process_uid(pid: int) -> int:
try:
with open(f"/proc/{pid}/status", "r", encoding="ascii") as source:
for line in source:
if line.startswith("Uid:"):
values = line.split()[1:]
if len(values) == 4 and len(set(values)) == 1:
return int(values[0])
except (OSError, UnicodeDecodeError, ValueError):
pass
refuse("legacy process UID is unavailable")
def process_start_time(pid: int) -> str:
try:
raw = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="ascii")
fields = raw[raw.rfind(")") + 2 :].split()
return fields[19]
except (OSError, UnicodeDecodeError, IndexError):
refuse("legacy process start time is unavailable")
def process_has_unit_cgroup(pid: int, unit: str) -> bool:
try:
lines = pathlib.Path(f"/proc/{pid}/cgroup").read_text(encoding="ascii").splitlines()
except (OSError, UnicodeDecodeError):
refuse("legacy process cgroup is unavailable")
return any(line.rsplit(":", 1)[-1].endswith(f"/{unit}") for line in lines)
def parse_listener_table(path: str, port: int, ipv6: bool) -> list[tuple[str, int]]:
found: list[tuple[str, int]] = []
try:
with open(path, "r", encoding="ascii") as source:
next(source, None)
for line in source:
fields = line.split()
if len(fields) < 10 or fields[3] != "0A":
continue
address_hex, port_hex = fields[1].split(":")
if int(port_hex, 16) != port:
continue
if ipv6:
address = socket.inet_ntop(socket.AF_INET6, bytes.fromhex(address_hex))
else:
address = socket.inet_ntoa(bytes.fromhex(address_hex)[::-1])
found.append((address, int(fields[9])))
except (OSError, UnicodeDecodeError, ValueError, IndexError):
refuse("kernel listener state is unavailable")
return found
def listeners(port: int) -> list[tuple[str, int]]:
return parse_listener_table("/proc/net/tcp", port, False) + parse_listener_table(
"/proc/net/tcp6", port, True
)
def process_owns_socket(pid: int, inode: int) -> bool:
expected = f"socket:[{inode}]"
try:
for entry in os.scandir(f"/proc/{pid}/fd"):
try:
if os.readlink(entry.path) == expected:
return True
except OSError:
continue
except OSError:
refuse("legacy process descriptors are unavailable")
return False
def require_exact_listener(pid: int, port: int) -> None:
active = listeners(port)
if len(active) != 1 or active[0][0] != "127.0.0.1":
refuse(f"port {port} is not one exact IPv4 loopback listener")
if not process_owns_socket(pid, active[0][1]):
refuse(f"port {port} is not owned by the expected process")
def require_unused_port(port: int) -> None:
if listeners(port):
refuse(f"candidate port {port} is already in use")
def validate_public_address() -> None:
body = json.dumps(ADDRESS_REQUEST, separators=(",", ":")).encode("ascii")
request = urllib.request.Request(
LEGACY_RPC_URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
# Never honor ambient proxy settings for a loopback identity check.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=5) as response:
if response.status != 200:
refuse("legacy wallet RPC returned an unexpected status")
raw = response.read(1024 * 1024 + 1)
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException):
refuse("legacy wallet RPC public address check failed")
if len(raw) > 1024 * 1024:
refuse("legacy wallet RPC response is oversized")
try:
payload = json.loads(raw.decode("utf-8"))
address = payload["result"]["address"]
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError):
refuse("legacy wallet RPC returned an invalid public address response")
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
if (
not isinstance(address, str)
or len(address) != 95
or not address.startswith("5")
or any(character not in alphabet for character in address)
):
refuse("legacy wallet RPC address is not a primary stagenet address")
def validate_legacy() -> tuple[os.stat_result, os.stat_result]:
if os.geteuid() != 0:
refuse("run as root so process ownership checks cannot be hidden")
if systemctl("is-active", "--quiet", LEGACY_UNIT).returncode != 0:
refuse("legacy wallet service must be active")
pid = main_pid(LEGACY_UNIT)
if pid <= 1:
refuse("legacy wallet service has no stable MainPID")
started = process_start_time(pid)
expected_uid, _ = identity(LEGACY_USER, LEGACY_GROUP)
if process_uid(pid) != expected_uid:
refuse("legacy wallet process UID is unexpected")
try:
executable = pathlib.Path(os.readlink(f"/proc/{pid}/exe"))
except OSError:
refuse("legacy wallet process executable is unavailable")
if executable != LEGACY_BINARY:
refuse("legacy wallet process executable is unexpected")
if not process_has_unit_cgroup(pid, LEGACY_UNIT):
refuse("legacy wallet process cgroup is unexpected")
require_exact_listener(pid, LEGACY_RPC_PORT)
require_directory(LEGACY_DIR, LEGACY_USER, LEGACY_GROUP, 0o775)
wallet = require_regular(LEGACY_WALLET, LEGACY_USER, LEGACY_GROUP, 0o600)
keys = require_regular(LEGACY_KEYS, LEGACY_USER, LEGACY_GROUP, 0o600)
validate_public_address()
if main_pid(LEGACY_UNIT) != pid or process_start_time(pid) != started:
refuse("legacy wallet process changed during preflight")
require_exact_listener(pid, LEGACY_RPC_PORT)
return wallet, keys
def validate_candidate_prerequisites(wallet: os.stat_result, keys: os.stat_result) -> None:
try:
record = pwd.getpwnam(CANDIDATE_USER)
group = grp.getgrnam(CANDIDATE_GROUP)
except KeyError:
refuse("parallel candidate service identity is not provisioned")
if (
record.pw_uid >= 1000
or record.pw_gid != group.gr_gid
or os.getgrouplist(CANDIDATE_USER, record.pw_gid) != [record.pw_gid]
or record.pw_dir != os.fspath(CANDIDATE_HOME)
or record.pw_shell not in ("/usr/sbin/nologin", "/bin/false")
):
refuse("parallel candidate identity is not a dedicated system account")
require_regular(CANDIDATE_BINARY, "root", "root", 0o755)
require_directory(CANDIDATE_HOME.parent, "root", "root", 0o755)
if os.path.lexists(CANDIDATE_STAGE) or os.path.lexists(CANDIDATE_HOME):
refuse("candidate staging/final path already exists; do not overwrite it")
if systemctl("is-active", "--quiet", CANDIDATE_UNIT).returncode == 0:
refuse("candidate unit must be inactive")
require_unused_port(CANDIDATE_RPC_PORT)
require_unused_port(VERIFY_RPC_PORT)
capacity = os.statvfs(CANDIDATE_HOME.parent)
available = capacity.f_bavail * capacity.f_frsize
required = wallet.st_size + keys.st_size + 64 * 1024 * 1024
if available < required:
refuse("insufficient space for a non-replacing candidate wallet copy")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Read-only Monero stagenet parallel preflight")
parser.add_argument(
"scope",
choices=("legacy", "candidate-prereqs"),
help="legacy checks the current RPC; candidate-prereqs also checks inert new-path gates",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
options = parse_args(sys.argv[1:] if argv is None else argv)
harden_process()
wallet, keys = validate_legacy()
if options.scope == "candidate-prereqs":
validate_candidate_prerequisites(wallet, keys)
print(f"PASSED: read-only {options.scope} preflight")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Refusal as problem:
print(f"REFUSED: {problem}", file=sys.stderr)
raise SystemExit(1)
except Exception:
# Do not render process or RPC data in an unexpected exception.
print("REFUSED: unexpected read-only preflight failure", file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,11 @@
# Install as /etc/monerogue-wallet/wallet-rpc.conf, owned root:monerogue-wallet, mode 0640.
# Replace CHANGE_ME with an independently generated password. The same rpc-login credentials
# must be set as WALLET_RPC_USER/WALLET_RPC_PASSWORD in the app's protected .env file.
stagenet=1
wallet-file=/var/lib/monerogue-wallet/stagenet
password-file=/etc/monerogue-wallet/wallet-password
rpc-bind-port=38083
rpc-bind-ip=127.0.0.1
daemon-address=127.0.0.1:38081
rpc-login=monerogue:CHANGE_ME_STRONG_RPC_PASSWORD
non-interactive=1
@@ -0,0 +1,54 @@
# Manual-only, parallel Monero STAGENET wallet-RPC candidate.
#
# REVIEW STATUS: INERT TEMPLATE. Do not install until the bundle producer and its crash-injection
# evidence satisfy README-monero-stagenet-parallel-candidate.md. This unit intentionally has no
# installation stanza, so it cannot be enabled into a boot target.
[Unit]
Description=Monerogue parallel wallet RPC candidate (stagenet, manual only)
After=network-online.target monerod.service
Wants=network-online.target monerod.service
ConditionPathExists=/var/lib/monerogue-stagenet-wallet-candidate/READY
[Service]
Type=simple
User=monerogue-wallet-candidate
Group=monerogue-wallet-candidate
SupplementaryGroups=
WorkingDirectory=/var/lib/monerogue-stagenet-wallet-candidate/wallet
# The protected config fixes stagenet, wallet/password paths, daemon 127.0.0.1:38081,
# RPC 127.0.0.1:38084, and Digest rpc-login. No secret belongs in this unit or argv.
ExecStart=/usr/bin/monero-wallet-rpc --config-file /var/lib/monerogue-stagenet-wallet-candidate/config/wallet-rpc.conf --log-file /dev/null
# A candidate must be restarted deliberately and revalidated; it is not a production dependency.
Restart=no
TimeoutStopSec=60
UMask=0077
LimitCORE=0
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
PrivateUsers=no
RemoveIPC=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
RestrictRealtime=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
MemoryDenyWriteExecute=yes
KeyringMode=private
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
ReadWritePaths=/var/lib/monerogue-stagenet-wallet-candidate/wallet
+30 -3
View File
@@ -5,18 +5,45 @@
# two are fully independent. NPM (Nginx Proxy Manager) already forwards monerogue.app -> :3001.
[Unit]
Description=Monerogue Game Server (Monero Stagenet)
After=network.target postgresql.service monero-wallet-rpc.service
After=network-online.target postgresql.service monero-wallet-rpc.service
Wants=network-online.target
Wants=monero-wallet-rpc.service
[Service]
Type=simple
User=monerogue
Group=monerogue
WorkingDirectory=/var/www/monerogue/app/src
EnvironmentFile=/var/www/monerogue/app/src/.env
WorkingDirectory=/var/www/monerogue/current/src
EnvironmentFile=/etc/monerogue/app.env
EnvironmentFile=/etc/monerogue/wallet-rpc.env
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
# Process/filesystem hardening. Application state lives in PostgreSQL, so the checkout is
# intentionally read-only; PrivateTmp provides the only disposable writable filesystem.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
# Install as /etc/wownerogue/wallet-rpc.conf, owned root:wownerogue-wallet, mode 0640.
# Replace CHANGE_ME with an independently generated password. Configure the same credentials
# as WALLET_RPC_USER/WALLET_RPC_PASSWORD in the app's protected .env file.
wallet-file=/var/lib/wownerogue-wallet/mainnet
password-file=/etc/wownerogue/wallet-password
rpc-bind-port=34570
rpc-bind-ip=127.0.0.1
daemon-address=127.0.0.1:34568
rpc-login=wownerogue:CHANGE_ME_STRONG_RPC_PASSWORD
non-interactive=1
+42
View File
@@ -0,0 +1,42 @@
# Wownero MAINNET wallet RPC for play.wowne.ro.
# Install to /etc/systemd/system/wownero-wallet-rpc.service.
[Unit]
Description=Wownero Wallet RPC (Mainnet) - wownerogue
After=network-online.target wownerod.service
Wants=network-online.target
Wants=wownerod.service
[Service]
User=wownerogue-wallet
Group=wownerogue-wallet
Type=simple
ExecStart=/usr/bin/wownero-wallet-rpc --config-file /etc/wownerogue/wallet-rpc.conf
Restart=always
RestartSec=10
TimeoutStopSec=60
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
ReadWritePaths=/var/lib/wownerogue-wallet
[Install]
WantedBy=multi-user.target
+44
View File
@@ -0,0 +1,44 @@
# play.wowne.ro game instance (Wownero mainnet, free + paid prestige, no payouts).
# Install to /etc/systemd/system/wownerogue.service.
[Unit]
Description=Wownerogue Game Server (Wownero Mainnet)
After=network-online.target postgresql.service wownero-wallet-rpc.service
Wants=network-online.target
Wants=wownero-wallet-rpc.service
[Service]
Type=simple
User=wownerogue
Group=wownerogue
WorkingDirectory=/var/www/wownerogue/current/src
EnvironmentFile=/etc/wownerogue/app.env
EnvironmentFile=/etc/wownerogue/wallet-rpc.env
ExecStartPre=/usr/bin/node scripts/preflight.js
ExecStart=/usr/bin/node index.js
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
+33
View File
@@ -0,0 +1,33 @@
[Unit]
Description=Validated PostgreSQL backups for Wowngeon instances
After=postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=postgres
Group=postgres
Environment=BACKUP_ROOT=/var/backups/wowngeon/daily
Environment=BACKUP_RETENTION_DAYS=14
ExecStart=/usr/local/sbin/wowngeon-db-backup
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
SystemCallArchitectures=native
ReadWritePaths=/var/backups/wowngeon/daily
+48
View File
@@ -0,0 +1,48 @@
#!/bin/sh
set -eu
# Atomic daily custom-format dumps for both production instances. Run as the postgres OS user;
# the service unit grants write access only to this dedicated directory.
backup_root=${BACKUP_ROOT:-/var/backups/wowngeon/daily}
retention_days=${BACKUP_RETENTION_DAYS:-14}
stamp=$(/usr/bin/date -u +%Y%m%dT%H%M%SZ)
[ "$backup_root" = /var/backups/wowngeon/daily ] || {
echo "Refusing an unapproved backup root: $backup_root" >&2
exit 64
}
case "$retention_days" in
''|*[!0-9]*) echo "BACKUP_RETENTION_DAYS must be a positive integer" >&2; exit 64 ;;
esac
[ "$retention_days" -gt 0 ] || { echo "BACKUP_RETENTION_DAYS must be positive" >&2; exit 64; }
[ -d "$backup_root" ] && [ ! -L "$backup_root" ] || {
echo "Backup root is missing or is a symlink" >&2
exit 66
}
for database_name in wownerogue monerogue; do
final_path="${backup_root}/${database_name}-${stamp}.dump"
temporary_path="${backup_root}/.${database_name}-${stamp}.dump.tmp"
checksum_path="${final_path}.sha256"
checksum_temporary="${checksum_path}.tmp"
/usr/bin/pg_dump --format=custom --no-owner --file="$temporary_path" "$database_name"
/usr/bin/pg_restore --list "$temporary_path" >/dev/null
/usr/bin/chmod 0600 "$temporary_path"
/usr/bin/mv "$temporary_path" "$final_path"
(
cd "$backup_root"
/usr/bin/sha256sum "$(/usr/bin/basename "$final_path")"
) >"$checksum_temporary"
/usr/bin/chmod 0600 "$checksum_temporary"
/usr/bin/mv "$checksum_temporary" "$checksum_path"
done
# Local copies are an operational convenience, not the retention archive. The independently
# encrypted restic vault keeps GFS history; bound this same-disk directory so it cannot fill `/`.
/usr/bin/find "$backup_root" -maxdepth 1 -type f \
\( -name 'monerogue-*.dump' -o -name 'monerogue-*.dump.sha256' \
-o -name 'wownerogue-*.dump' -o -name 'wownerogue-*.dump.sha256' \) \
-mtime "+$retention_days" -delete
/usr/bin/find "$backup_root" -maxdepth 1 -type f -name '.*.tmp' -mtime +1 -delete
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Daily Wowngeon PostgreSQL backups
[Timer]
OnCalendar=*-*-* 03:20:00
RandomizedDelaySec=20m
Persistent=true
Unit=wowngeon-db-backup.service
[Install]
WantedBy=timers.target
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=Wowngeon application and wallet RPC firewall
After=network.target
Before=wownerogue.service monerogue.service wownero-wallet-rpc.service monero-wallet-rpc.service
[Service]
Type=oneshot
EnvironmentFile=/etc/wowngeon/firewall.env
ExecStart=/usr/local/sbin/wowngeon-firewall
RemainAfterExit=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=no
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictSUIDSGID=yes
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
LockPersonality=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
# Idempotent application/RPC isolation. Install mode 0755 at
# /usr/local/sbin/wowngeon-firewall and provide NPM_SOURCE_IPV4 through the unit's EnvironmentFile.
: "${NPM_SOURCE_IPV4:?Set NPM_SOURCE_IPV4 to the reverse proxy address}"
ensure_rule() {
command_name=$1
shift
if ! "$command_name" -C INPUT "$@" 2>/dev/null; then
"$command_name" -I INPUT 1 "$@"
fi
}
# Only the reverse proxy (and local probes) may reach either Node listener.
ensure_rule /usr/sbin/iptables ! -i lo ! -s "$NPM_SOURCE_IPV4" \
-p tcp -m multiport --dports 3000,3001 -j REJECT
ensure_rule /usr/sbin/ip6tables ! -i lo \
-p tcp -m multiport --dports 3000,3001 -j REJECT
# Wallet and daemon JSON-RPC are local control planes. P2P ports are intentionally untouched.
ensure_rule /usr/sbin/iptables ! -i lo \
-p tcp -m multiport --dports 34568,34570,38081,38083 -j REJECT
ensure_rule /usr/sbin/ip6tables ! -i lo \
-p tcp -m multiport --dports 34568,34570,38081,38083 -j REJECT
+86 -34
View File
@@ -2,7 +2,8 @@
# Wownerogue Server Configuration
# =============================================================================
# Copy this file to .env and customize for your environment.
# The server validates these settings at startup and warns about misconfigurations.
# The server validates these settings at startup. Production fails closed on missing secrets,
# ambiguous payout flags, simulated blocks, or missing wallet/daemon RPC endpoints.
#
# QUICK START CONFIGURATIONS:
# ---------------------------
@@ -51,6 +52,9 @@ PORT=3000
# - development: verbose logging, can use simulated blocks
# - debug: maximum logging, debug hotkeys enabled
NODE_ENV=production
# External renderer downloads are disabled by default. When true, pinned Pixi/Three
# bundles may be loaded from jsDelivr; vendored, self-hosted assets are preferred.
RENDERER_CDN_ENABLED=false
# Network selection (only matters for Monero - Wownero only has mainnet)
MONERO_NETWORK=mainnet
@@ -166,7 +170,7 @@ PAYMENTS_ENABLED=true
# DIRECT_REQUIRES_ADDRESS=false CREDITS_REQUIRES_ADDRESS=false (no payout = no address)
# • Test/stagenet (fake money): full pay-to-play WITH payouts is fine.
# PAYMENTS_ENABLED=true PAYOUTS_ENABLED=true *_PAYOUTS_ENABLED=true
FREE_PLAY_ENABLED=false
FREE_PLAY_ENABLED=true
# Which payment modes to enable (comma-separated)
PAYMENT_MODES=direct,credits
@@ -185,14 +189,14 @@ DIRECT_GAME_PRICE=100000000000
# Must user provide a payout address before playing?
# true = User must set address first (ensures they can receive winnings)
# false = User can play without address (but can't receive payouts!)
DIRECT_REQUIRES_ADDRESS=true
DIRECT_REQUIRES_ADDRESS=false
# Allow guest play without any address? (Usually false for paid modes)
DIRECT_ALLOW_GUEST_PLAY=false
# Payout multipliers for direct mode wins:
# If user pays 1 WOW and ESCAPE multiplier is 2.0, they get 2 WOW on escape.
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUTS_ENABLED=false
DIRECT_PAYOUT_ESCAPE=2.0 # Escape dungeon = 2x payment
DIRECT_PAYOUT_TREASURE=3.0 # Escape WITH treasure = 3x payment
@@ -208,7 +212,7 @@ CREDITS_ENABLED=true
CREDITS_PER_GAME=1
# Must user provide address for credits mode?
CREDITS_REQUIRES_ADDRESS=true
CREDITS_REQUIRES_ADDRESS=false
# When both modes enabled:
ALLOW_MIXED_MODE=true # Show both options to user
@@ -219,6 +223,10 @@ CREDITS_PAYOUTS_ENABLED=false
CREDITS_PAYOUT_BASE=50000000000 # Base amount for credit wins
CREDITS_PAYOUT_ESCAPE=2.0 # Same as direct mode
CREDITS_PAYOUT_TREASURE=3.0 # Same as direct mode
# Optional credit-payout bounds. The global PAYOUT_MAX_PER_GAME below is always
# the outer safety cap, even when this mode-specific maximum is larger.
# CREDITS_PAYOUT_MIN=10000000000
# CREDITS_PAYOUT_MAX=10000000000000
# Credit packages - JSON array of available bundles
# Fields:
@@ -245,9 +253,11 @@ CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"900000000000","bonus":0},
# a "grants" field. Example:
# CREDITS_PACKAGES='[{"id":"supporter_25","credits":20,"bonus":5,"price":"1000000000000","grants":{"packs":["generated-skins","kenney-3d-characters"],"premiumLevel":"supporter"}}]'
# Standalone cosmetic products (no game entry or credits required). Each product
# may grant packs and/or a premium tier via "grants". JSON array; empty by default.
# Standalone products (no game entry or credits required). Each product may grant packs,
# a premium tier, and/or funded race tickets. Crypto-match tickets must declare an atomic
# value exactly equal to MATCH_ENTRY_FEE_ATOMIC, and the product price must cover every ticket.
# COSMETIC_PRODUCTS='[{"id":"pack_3d","label":"3D Character Pack","price":"250000000000","grants":{"packs":["kenney-3d-characters"]}}]'
# COSMETIC_PRODUCTS='[{"id":"race_2","label":"Two race entries","price":"10000000000","grants":{"race_entries":2,"race_entry_value_atomic":"5000000000"}}]'
# =============================================================================
# SECTION 6b: EARLY ENTRY
@@ -273,8 +283,14 @@ EARLY_ENTRY_CREDITS_MODE=true
# Controls how winnings are sent back to players.
# Payouts are batched for efficiency and to reduce transaction fees.
# Master payout switch (disable to accept payments but never pay out - EVIL!)
PAYOUTS_ENABLED=true
# Master payout switch. The safe mainnet product sells entry/credits for leaderboard prestige
# without crypto winnings. The separate XMR stagenet instance enables this for 2x/3x demo payouts.
# This switch suppresses payout creation, block/batch dispatch, and retry workers.
PAYOUTS_ENABLED=false
# Production mainnet payouts are additionally fail-closed. Only set this true after legal review,
# bankroll/risk review, and a real wallet-RPC integration test. It is not needed on stagenet.
ALLOW_MAINNET_PAYOUTS=false
# Minimum payout amount (atomic units). Prevents dust transactions.
# 10000000000 = 0.1 WOW
@@ -287,6 +303,10 @@ PAYOUT_MAX_PER_GAME=10000000000000
# How often to process payout queue (seconds). 300 = every 5 minutes.
PAYOUT_BATCH_INTERVAL=300
# Confirmed incoming outputs must reach at least this many chain confirmations before granting
# a game, credits, cosmetics, or race tickets.
PAYMENT_CONFIRMATIONS=1
# Retry failed payouts this many times before marking as permanently failed.
# Failed payouts are logged with error details. Check logs or query:
# SELECT * FROM payouts WHERE status = 'failed';
@@ -322,18 +342,23 @@ GAME_COOLDOWN_SECONDS=5 # Minimum seconds between games
#
# wownero-wallet-rpc \
# --wallet-file /path/to/wallet \
# --password "wallet-password" \
# --password-file /path/to/root-readable-wallet-password \
# --rpc-bind-ip 127.0.0.1 \
# --rpc-bind-port 34570 \
# --rpc-login user:password \
# --disable-rpc-login # OR use rpc-login for security
# --rpc-login user:strong-random-rpc-password
#
# Production requires rpc-login even on loopback. The application deliberately proves that an
# unauthenticated request receives a Digest challenge; configured credentials do not make an
# unauthenticated wallet daemon safe. Never combine rpc-login with --disable-rpc-login, and never
# expose wallet RPC beyond the local host.
#
# The wallet must be synced with a daemon to see incoming transactions.
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
# If your wallet-rpc uses --rpc-login, set credentials here:
# WALLET_RPC_USER=user
# WALLET_RPC_PASSWORD=password
# Required together in production:
# WALLET_RPC_USER=wownerogue
# WALLET_RPC_PASSWORD=replace-with-a-distinct-strong-rpc-password
# =============================================================================
# SECTION 10: BLOCKCHAIN DAEMON RPC
@@ -355,9 +380,21 @@ RPC_POLL_INTERVAL=2000
# Secure endpoints for refunds, credit adjustments, and user management.
# Generate a strong random key and keep it secret!
# Generate with: openssl rand -hex 32
# Required in production. Generate with: openssl rand -hex 32
# ADMIN_API_KEY=your-64-character-hex-key-here
# Public operator and paid-play disclosure controls. Production paid instances fail startup
# until these are explicitly set to truthful, non-placeholder values.
# OPERATOR_NAME=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
# Optional plain-language location restriction notice supplied after legal review:
# RESTRICTED_LOCATIONS_NOTICE=
# Use in requests:
# curl -H "X-Admin-Key: your-key" http://localhost:3000/api/admin/...
@@ -369,6 +406,7 @@ RPC_POLL_INTERVAL=2000
# true, the real client IP is read from the X-Forwarded-For header for rate limiting and
# req.ip. Leave unset/false for direct exposure, otherwise clients could spoof their IP.
# TRUST_PROXY=true
# TRUST_PROXY_HOPS=1 # NPM directly in front of Node
# Anti-instant-death grace (ms) before a block-timeout can end a game. This is NOT a
# fairness floor — random block timing is the core mechanic. It only prevents the
@@ -376,13 +414,6 @@ RPC_POLL_INTERVAL=2000
# renders / before the first move is possible). Default 2000 (2s); set 0 to disable.
# GAME_START_GRACE_MS=2000
# =============================================================================
# SECTION 12: SERVER & MULTI-INSTANCE
# =============================================================================
# Port for HTTP server (default 3000)
# Use different ports for multiple instances (e.g., 3000 for WOW, 3001 for XMR)
PORT=3000
# =============================================================================
# SECTION 13: ADMIN ALERTS (Resend)
# =============================================================================
@@ -421,6 +452,11 @@ BALANCE_WARN=100000000000
# Critical threshold - halts new games (default: 0.01 WOW/XMR)
BALANCE_CRITICAL=10000000000
# Scan recently expired invoices for transfers that mined after the 30-minute watcher stopped.
# Valid receipts create manual-review records only; they never auto-grant stale entries/products.
LATE_PAYMENT_RECONCILE_HOURS=72
LATE_PAYMENT_RECONCILE_BATCH=100
# Legacy alias (superseded by BALANCE_WARN, kept for backwards compatibility)
# LOW_BALANCE_THRESHOLD=100000000000
@@ -431,6 +467,10 @@ BALANCE_CRITICAL=10000000000
# Alert when pending/failed payouts exceed this count
HIGH_PENDING_THRESHOLD=10
# Alert when payout processing or refund requested/processing remains nonterminal this long.
# These alerts never retry a transfer. Default: 900000 (15 minutes).
FINANCIAL_REVIEW_STALE_MS=900000
# Alert cooldown in milliseconds (avoid spam)
# Default: 3600000 (1 hour)
ALERT_COOLDOWN_MS=3600000
@@ -484,7 +524,7 @@ CRYPTO_TYPE=WOW
# Tavern & Multiplayer modes (optional)
# =============================================================================
# Tavern: a walkable social room (chat, avatars, spectator camera).
TAVERN_ENABLED=false
TAVERN_ENABLED=true
# Server-tick / snapshot broadcast cadence in ms (default 100 = 10 Hz)
# TAVERN_TICK_MS=100
@@ -492,14 +532,23 @@ TAVERN_ENABLED=false
# Match mode (multiplayer races)
# =============================================================================
# Enable real-time multiplayer races. Solo/Tavern are unaffected.
MATCH_ENABLED=false
MATCH_ENABLED=true
# Max players per race (2–32). Default 4.
# MATCH_MAX_PLAYERS=4
# Max players per match, also bounded by the selected ruleset (built-ins max at 8).
MATCH_MAX_PLAYERS=4
# Server tick interval in ms (default 250 = 4 Hz).
# MATCH_TICK_MS=250
# Server-selected match ruleset. Clients cannot choose this value.
# Built-ins: race, last-alive, score-attack, coop-escape. Default: race.
MATCH_RULESET_ID=race
# Paid matches freeze entrants at H, target H+2, and activate at target+1 by default.
# Production requires explicit safe integers from 2 through 100 for both settings.
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
# Minimum race duration in ms before the next block can end it (default 20s).
# MATCH_MIN_DURATION_MS=20000
@@ -509,12 +558,15 @@ MATCH_ENABLED=false
# Credits cost for prestige-only races (default 1).
# MATCH_CREDITS_COST=1
# Crypto race house fee percent (default 5).
# MATCH_HOUSE_FEE_PERCENT=5
# Crypto race house fee percent. Production crypto matches require an explicit value from
# 0 (inclusive) through 100 (exclusive).
MATCH_HOUSE_FEE_PERCENT=5
# Optional override for the crypto-race per-player entry fee in atomic units.
# If unset, the existing per-game price (DIRECT_GAME_PRICE / SINGLE_GAME_PRICE) is used.
# MATCH_ENTRY_FEE_ATOMIC=5000000000
# Required crypto-race per-player entry fee in atomic units when payout matches are enabled.
MATCH_ENTRY_FEE_ATOMIC=5000000000
# Enable crypto race-entry ticket economy (requires paid instance + ticket products).
# MATCH_CRYPTO_RACE_ENABLED=false
# Crypto admission requires all three explicit gates plus a worst-case winner cap. Co-op has no
# winner-takes-pot split contract and is therefore rejected for crypto matches.
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
MATCH_PAYOUT_MAX=50000000000
+86
View File
@@ -0,0 +1,86 @@
# play.wowne.ro production profile: Wownero mainnet, free + paid prestige boards, no payouts.
# Copy to .env, replace every CHANGE_ME value, then run `npm run preflight`.
NODE_ENV=production
RENDERER_CDN_ENABLED=false
HOST=0.0.0.0
PORT=3000
TRUST_PROXY=true
TRUST_PROXY_HOPS=1
BLOCK_SOURCE=daemon
CRYPTO_TYPE=WOW
MONERO_NETWORK=mainnet
SMIRK_ENABLED=false
HOSTED_BY=https://play.wowne.ro
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=wownerogue
DB_USER=wownerogue
DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD
ADMIN_API_KEY=CHANGE_ME_WITH_OPENSSL_RAND_HEX_32
# Public operator/disclosure metadata. These are startup-required for production payments.
# Do not copy placeholder identity/contact values into a live instance.
OPERATOR_NAME=CHANGE_ME_RESPONSIBLE_OPERATOR
OPERATOR_CONTACT_URL=mailto:CHANGE_ME_SUPPORT_ADDRESS
OPERATOR_CONTACT_LABEL=Support
LEGAL_POLICY_VERSION=2026-07-21-v1
TERMS_EFFECTIVE_DATE=2026-07-21
MINIMUM_AGE=18
PAID_ACKNOWLEDGEMENT_REQUIRED=true
# Optional plain-language notice supplied after jurisdiction-specific legal review.
# RESTRICTED_LOCATIONS_NOTICE=
# Free runs rank on the Pleb board; direct/credit runs rank on the paid prestige board.
PAYMENTS_ENABLED=true
FREE_PLAY_ENABLED=true
PAYMENT_MODES=direct,credits
ALLOW_MIXED_MODE=true
PREFER_CREDITS_FIRST=true
DIRECT_PAYMENT_ENABLED=true
DIRECT_GAME_PRICE=100000000000
DIRECT_REQUIRES_ADDRESS=false
DIRECT_ALLOW_GUEST_PLAY=false
DIRECT_PAYOUTS_ENABLED=false
CREDITS_ENABLED=true
CREDITS_PER_GAME=1
CREDITS_REQUIRES_ADDRESS=false
CREDITS_PAYOUTS_ENABLED=false
CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"900000000000","bonus":0}]'
# Master interlock: the mainnet prestige profile must never create or dispatch winnings.
PAYOUTS_ENABLED=false
ALLOW_MAINNET_PAYOUTS=false
PAYMENT_CONFIRMATIONS=1
LATE_PAYMENT_RECONCILE_HOURS=72
LATE_PAYMENT_RECONCILE_BATCH=100
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
WALLET_RPC_USER=wownerogue
WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
RPC_POLL_INTERVAL=2000
DIFFICULTY_PRESET=normal
EARLY_ENTRY_ENABLED=true
EARLY_ENTRY_FREE_MODE=true
EARLY_ENTRY_CREDITS_MODE=true
TAVERN_ENABLED=true
TAVERN_TICK_MS=100
MATCH_ENABLED=true
MATCH_RULESET_ID=race
MATCH_MAX_PLAYERS=4
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
MATCH_TICK_MS=250
MATCH_MIN_DURATION_MS=20000
MATCH_HARD_CEILING_MS=240000
MATCH_CREDITS_COST=1
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
+23
View File
@@ -10,6 +10,15 @@ MATCH_MAX_PLAYERS=4
# Server tick interval in milliseconds (default 250ms = 4 Hz)
MATCH_TICK_MS=250
# Server-selected ruleset. Built-ins: race, last-alive, score-attack, coop-escape.
# Unknown or solo-only values fail closed to race.
MATCH_RULESET_ID=race
# Paid entrant freezes target H+2 and activate only after the target plus one confirmation.
# Production requires explicit safe integers from 2 through 100 for both settings.
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
# Minimum duration in ms before a race can be ended by the next block (default 20s)
MATCH_MIN_DURATION_MS=20000
@@ -21,3 +30,17 @@ MATCH_CREDITS_COST=1
# Enable crypto race-entry ticket economy (requires a paid instance)
MATCH_CRYPTO_RACE_ENABLED=false
# Independently stop new on-chain payout matches without affecting accepted liabilities.
MATCH_PAYOUTS_ENABLED=false
# Required outer winner-payout cap (atomic units) when crypto payouts are enabled.
MATCH_PAYOUT_MAX=50000000000
# Explicit crypto economics (required for production preflight when enabled).
MATCH_ENTRY_FEE_ATOMIC=5000000000
MATCH_HOUSE_FEE_PERCENT=5
# Also add a separately priced funded ticket product to COSMETIC_PRODUCTS. Its per-ticket
# backing must equal MATCH_ENTRY_FEE_ATOMIC and its price must cover the full grant.
# COSMETIC_PRODUCTS='[{"id":"race_2","label":"Two race entries","price":"10000000000","grants":{"race_entries":2,"race_entry_value_atomic":"5000000000"}}]'
+107
View File
@@ -0,0 +1,107 @@
# Monerogue production profile: XMR stagenet, free + paid solo, Tavern, and PvP race.
# Copy to .env, replace every CHANGE_ME value, then run `npm run preflight`.
NODE_ENV=production
RENDERER_CDN_ENABLED=false
HOST=0.0.0.0
PORT=3001
TRUST_PROXY=true
TRUST_PROXY_HOPS=1
BLOCK_SOURCE=daemon
CRYPTO_TYPE=XMR
MONERO_NETWORK=stagenet
SMIRK_ENABLED=false
HOSTED_BY=https://monerogue.app
EXPLORER_TX_URL=
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME=monerogue
DB_USER=monerogue
DB_PASSWORD=CHANGE_ME_STRONG_DATABASE_PASSWORD
ADMIN_API_KEY=CHANGE_ME_WITH_OPENSSL_RAND_HEX_32
# Public operator/disclosure metadata. These are startup-required for production payments.
# Do not copy placeholder identity/contact values into a live instance.
OPERATOR_NAME=CHANGE_ME_RESPONSIBLE_OPERATOR
OPERATOR_CONTACT_URL=mailto:CHANGE_ME_SUPPORT_ADDRESS
OPERATOR_CONTACT_LABEL=Support
LEGAL_POLICY_VERSION=2026-07-21-v1
TERMS_EFFECTIVE_DATE=2026-07-21
MINIMUM_AGE=18
PAID_ACKNOWLEDGEMENT_REQUIRED=true
# Optional plain-language notice supplied after jurisdiction-specific legal review.
# RESTRICTED_LOCATIONS_NOTICE=
# The original solo game remains available for free and through both paid entry paths.
PAYMENTS_ENABLED=true
FREE_PLAY_ENABLED=true
PAYMENT_MODES=direct,credits
ALLOW_MIXED_MODE=true
PREFER_CREDITS_FIRST=true
# 0.01 stagenet XMR per direct run; escape pays 2x, treasure escape pays 3x.
DIRECT_PAYMENT_ENABLED=true
DIRECT_GAME_PRICE=10000000000
DIRECT_REQUIRES_ADDRESS=true
DIRECT_ALLOW_GUEST_PLAY=false
DIRECT_PAYOUTS_ENABLED=true
DIRECT_PAYOUT_ESCAPE=2.0
DIRECT_PAYOUT_TREASURE=3.0
CREDITS_ENABLED=true
CREDITS_PER_GAME=1
CREDITS_REQUIRES_ADDRESS=true
CREDITS_PAYOUTS_ENABLED=true
CREDITS_PAYOUT_BASE=10000000000
CREDITS_PAYOUT_ESCAPE=2.0
CREDITS_PAYOUT_TREASURE=3.0
CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"90000000000","bonus":0},{"id":"medium","credits":20,"price":"170000000000","bonus":2}]'
# Provision this product before enabling the crypto-match gates below. The full price is
# allocated as two independently tracked 0.01 XMR race-ticket backing lots.
COSMETIC_PRODUCTS='[{"id":"race_2","label":"Two stagenet race entries","price":"20000000000","grants":{"race_entries":2,"race_entry_value_atomic":"10000000000"}}]'
# Stagenet-only money switch and bankroll caps. Mainnet acknowledgement deliberately stays off.
PAYOUTS_ENABLED=true
ALLOW_MAINNET_PAYOUTS=false
PAYOUT_MIN_AMOUNT=1000000000
PAYOUT_MAX_PER_GAME=50000000000
PAYOUT_BATCH_INTERVAL=300
PAYMENT_CONFIRMATIONS=1
PAYOUT_MAX_RETRIES=3
MAX_PAYOUT_BATCH_SIZE=50
BALANCE_WARN=100000000000
BALANCE_CRITICAL=50000000000
LATE_PAYMENT_RECONCILE_HOURS=72
LATE_PAYMENT_RECONCILE_BATCH=100
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083
WALLET_RPC_USER=monerogue
WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:38081
RPC_POLL_INTERVAL=2000
DIFFICULTY_PRESET=casino
EARLY_ENTRY_ENABLED=true
EARLY_ENTRY_FREE_MODE=true
EARLY_ENTRY_CREDITS_MODE=true
TAVERN_ENABLED=true
TAVERN_TICK_MS=100
MATCH_ENABLED=true
MATCH_RULESET_ID=race
MATCH_MAX_PLAYERS=4
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
MATCH_TICK_MS=250
MATCH_MIN_DURATION_MS=20000
MATCH_HARD_CEILING_MS=240000
MATCH_CREDITS_COST=1
MATCH_HOUSE_FEE_PERCENT=5
# Enable only after provisioning race-entry ticket products and validating escrow end to end.
MATCH_CRYPTO_RACE_ENABLED=false
MATCH_PAYOUTS_ENABLED=false
MATCH_ENTRY_FEE_ATOMIC=10000000000
MATCH_PAYOUT_MAX=50000000000
+8
View File
@@ -0,0 +1,8 @@
const { isTestNetworkFor } = require('../game/helpers/gameModeUtils');
/** Smirk is an explicit mainnet-only feature. Missing, malformed, or truthy-ish values stay off. */
function isSmirkEnabled(env = process.env) {
return env.SMIRK_ENABLED === 'true' && !isTestNetworkFor(env.MONERO_NETWORK);
}
module.exports = { isSmirkEnabled };
+187
View File
@@ -0,0 +1,187 @@
/**
* Public commerce disclosures and paid-action acknowledgement validation.
*
* This is a product safety control, not a jurisdiction or identity check. The server derives
* the words shown to players from the active runtime switches, and independently refuses paid
* actions unless the browser echoes the current policy version and every required acknowledgement.
*/
const DEFAULT_POLICY_VERSION = '2026-07-21';
const DEFAULT_MINIMUM_AGE = 18;
const ALLOWED_CONTACT_PROTOCOLS = new Set(['https:', 'http:', 'mailto:']);
const ACKNOWLEDGEMENT_KEYS = new Set([
'policyVersion',
'ageEligible',
'termsRead',
'riskAccepted',
'testnetUnderstood'
]);
function isTrue(value) {
return ['true', '1', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
}
function minimumAge(env = process.env) {
const value = Number(env.MINIMUM_AGE);
return Number.isInteger(value) && value >= 18 && value <= 120
? value
: DEFAULT_MINIMUM_AGE;
}
function safeContactUrl(value) {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const parsed = new URL(raw);
return ALLOWED_CONTACT_PROTOCOLS.has(parsed.protocol) ? parsed.href : null;
} catch (_) {
return null;
}
}
function paidAcknowledgementRequired(env, gameModeManager) {
// Payment intake can be paused while already-purchased prestige credits remain spendable in
// PvP. Keep the public browser policy enabled for that value-bearing action too; free actions
// bypass the acknowledgement explicitly at their individual server handlers.
let matchEconomies = null;
try {
matchEconomies = gameModeManager?._getMatchEconomies?.() || null;
} catch (_) {
// Public disclosure generation must remain available during partial startup. The
// conservative fallback below still treats an enabled paid product as value-bearing.
matchEconomies = null;
}
const matchCanConsumePaidEntitlement = matchEconomies
? Boolean(matchEconomies.credits_prestige || matchEconomies.crypto_race)
: Boolean(
gameModeManager?.creditsModeEnabled
|| gameModeManager?.directModeEnabled
|| gameModeManager?.freePlayEnabled
);
const paidMatchEconomyAvailable = isTrue(env.MATCH_ENABLED)
&& matchCanConsumePaidEntitlement;
const paidSoloEntitlementAvailable = Boolean(
gameModeManager?.creditsModeEnabled || gameModeManager?.directModeEnabled
);
const productionPaid = String(env.NODE_ENV || '').toLowerCase() === 'production'
&& Boolean(
gameModeManager?.paymentsEnabled
|| paidSoloEntitlementAvailable
|| paidMatchEconomyAvailable
);
return productionPaid || isTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED);
}
function buildCommerceDisclosure(gameModeManager, env = process.env) {
const cryptoType = String(gameModeManager?.cryptoType || env.CRYPTO_TYPE || 'WOW').toUpperCase();
const network = String(gameModeManager?.network || env.MONERO_NETWORK || 'mainnet').toLowerCase();
const isTestNetwork = Boolean(gameModeManager?.isTestNetwork)
|| (cryptoType === 'XMR' && network !== 'mainnet');
const paymentsEnabled = Boolean(gameModeManager?.paymentsEnabled);
const directEnabled = paymentsEnabled && Boolean(gameModeManager?.directModeEnabled);
const creditsEnabled = paymentsEnabled && Boolean(gameModeManager?.creditsModeEnabled);
// A stale subordinate payout flag for a disabled product must not be advertised as an active
// reward. Runtime startup policy uses the same mode-enabled distinction.
const directPayouts = directEnabled
&& Boolean(gameModeManager?.isPayoutEnabledForMode?.('PAID_SINGLE'));
const creditsPayouts = creditsEnabled
&& Boolean(gameModeManager?.isPayoutEnabledForMode?.('PAID_CREDITS'));
const cryptoMatchPayouts = isTrue(env.MATCH_ENABLED)
&& isTrue(env.MATCH_CRYPTO_RACE_ENABLED)
&& isTrue(env.MATCH_PAYOUTS_ENABLED)
&& Boolean(gameModeManager?.payoutsEnabled);
const anyPayouts = directPayouts || creditsPayouts || cryptoMatchPayouts;
const currencyLabel = String(gameModeManager?.currencyLabel || (isTestNetwork ? `s${cryptoType}` : cryptoType));
const operatorContactUrl = safeContactUrl(env.OPERATOR_CONTACT_URL);
return Object.freeze({
policyVersion: String(env.LEGAL_POLICY_VERSION || DEFAULT_POLICY_VERSION).trim(),
termsEffectiveDate: String(env.TERMS_EFFECTIVE_DATE || '').trim() || null,
minimumAge: minimumAge(env),
paidAcknowledgementRequired: paidAcknowledgementRequired(env, gameModeManager),
operator: Object.freeze({
name: String(env.OPERATOR_NAME || '').trim() || 'Site operator (identity not configured)',
contactUrl: operatorContactUrl,
contactLabel: String(env.OPERATOR_CONTACT_LABEL || '').trim()
|| (operatorContactUrl ? operatorContactUrl.replace(/^mailto:/, '') : 'Operator contact is not configured')
}),
service: Object.freeze({
gameName: String(gameModeManager?.gameName || 'Wowngeon'),
cryptoType,
currencyLabel,
network,
isTestNetwork,
freePlayEnabled: Boolean(gameModeManager?.freePlayEnabled),
paymentsEnabled,
directPaidEntryEnabled: directEnabled,
paidCreditsEnabled: creditsEnabled,
soloPayoutsEnabled: directPayouts || creditsPayouts,
cryptoMatchPayoutsEnabled: cryptoMatchPayouts,
anyPayoutsEnabled: anyPayouts,
paidPrestigeOnly: (directEnabled || creditsEnabled) && !anyPayouts
}),
notices: Object.freeze({
jurisdiction: String(env.RESTRICTED_LOCATIONS_NOTICE || '').trim() || null,
legalReview: 'These product disclosures are not a legal opinion, licence, or representation that the service is lawful in every location.',
identityControl: 'The acknowledgement records a player statement; it does not verify age, identity, or location.'
}),
links: Object.freeze({
terms: '/terms',
privacy: '/privacy',
responsiblePlay: '/responsible-play'
})
});
}
function validatePaidAcknowledgement(raw, disclosure, options = {}) {
const required = options.required === true || disclosure?.paidAcknowledgementRequired === true;
if (!required) return { ok: true, code: 'NOT_REQUIRED', acknowledgement: null };
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { ok: false, code: 'PAID_ACK_REQUIRED', message: 'Review and acknowledge the paid-play disclosures before continuing.' };
}
const prototype = Object.getPrototypeOf(raw);
const keys = Object.keys(raw);
if ((prototype !== Object.prototype && prototype !== null)
|| keys.length !== ACKNOWLEDGEMENT_KEYS.size
|| keys.some(key => !ACKNOWLEDGEMENT_KEYS.has(key))) {
return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' };
}
if (typeof raw.policyVersion !== 'string' || raw.policyVersion.length < 1 || raw.policyVersion.length > 64) {
return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' };
}
if (String(raw.policyVersion || '') !== String(disclosure.policyVersion || '')) {
return { ok: false, code: 'PAID_ACK_VERSION', message: 'The paid-play disclosures changed. Review the current version before continuing.' };
}
if (typeof raw.ageEligible !== 'boolean'
|| typeof raw.termsRead !== 'boolean'
|| typeof raw.riskAccepted !== 'boolean'
|| typeof raw.testnetUnderstood !== 'boolean') {
return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' };
}
if (raw.ageEligible !== true || raw.termsRead !== true || raw.riskAccepted !== true) {
return { ok: false, code: 'PAID_ACK_INCOMPLETE', message: 'Every paid-play acknowledgement is required before continuing.' };
}
if (disclosure.service?.isTestNetwork && raw.testnetUnderstood !== true) {
return { ok: false, code: 'PAID_ACK_TESTNET', message: 'Confirm that this test network uses valueless test coins and never send mainnet funds.' };
}
if (!disclosure.service?.isTestNetwork && raw.testnetUnderstood !== false) {
return { ok: false, code: 'PAID_ACK_MALFORMED', message: 'The paid-play acknowledgement payload is invalid. Reload the disclosures and try again.' };
}
const acknowledgement = {
policyVersion: disclosure.policyVersion,
ageEligible: true,
termsRead: true,
riskAccepted: true,
testnetUnderstood: disclosure.service?.isTestNetwork === true
};
return { ok: true, code: 'ACKNOWLEDGED', acknowledgement: Object.freeze(acknowledgement) };
}
module.exports = {
DEFAULT_POLICY_VERSION,
buildCommerceDisclosure,
minimumAge,
paidAcknowledgementRequired,
safeContactUrl,
validatePaidAcknowledgement
};
+558 -51
View File
@@ -1,90 +1,597 @@
/**
* Environment Validator
* Validates payment configuration and emits warnings for suspicious settings.
* Startup configuration validation.
*
* Development remains forgiving so a contributor can boot individual subsystems. Production is
* deliberately fail-closed: an ambiguous money or block-source setting must never silently fall
* back to a dangerous default.
*/
const { isSupported, familyFor } = require('../chain/chainProfile');
const { resolveMatchRuleset } = require('../game/rulesets');
const money = require('../money/atomic');
const {
PG_BIGINT_MAX,
cryptoRulesetSupported,
parseHouseFeeBasisPoints,
parsePositiveAtomic,
playerContract
} = require('../network/matchEconomyPolicy');
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
const BOOLEAN_VALUES = new Set(['true', 'false', '1', '0', 'yes', 'no', 'y', 'n', 'on', 'off']);
const PLACEHOLDER_SECRET = /(change[_-]?me|replace[_-]?with|your[_-]|example|password|secret)/i;
const BOOLEAN_ENV_KEYS = [
'PAYMENTS_ENABLED', 'PAYOUTS_ENABLED', 'DIRECT_PAYMENT_ENABLED',
'DIRECT_PAYOUTS_ENABLED', 'CREDITS_ENABLED', 'CREDITS_PAYOUTS_ENABLED',
'CREDITS_PAYOUT_ENABLED', 'FREE_PLAY_ENABLED', 'DIRECT_REQUIRES_ADDRESS',
'DIRECT_ALLOW_GUEST_PLAY', 'CREDITS_REQUIRES_ADDRESS', 'ALLOW_MIXED_MODE',
'PREFER_CREDITS_FIRST', 'EARLY_ENTRY_ENABLED', 'EARLY_ENTRY_FREE_MODE',
'EARLY_ENTRY_CREDITS_MODE', 'ALLOW_MAINNET_PAYOUTS', 'TRUST_PROXY',
'SMIRK_ENABLED', 'TAVERN_ENABLED', 'MATCH_ENABLED', 'MATCH_CRYPTO_RACE_ENABLED',
'MATCH_PAYOUTS_ENABLED',
'SIMULATED_BLOCKS', 'FORCE_SIMULATED_BLOCKS', 'DEBUG_HOTKEYS',
'PAID_ACKNOWLEDGEMENT_REQUIRED'
];
const ATOMIC_ENV_KEYS = [
'DIRECT_GAME_PRICE', 'SINGLE_GAME_PRICE', 'CREDITS_PACKAGE_PRICE',
'CREDITS_PAYOUT_BASE', 'PAYOUT_MIN_AMOUNT', 'PAYOUT_MAX_PER_GAME',
'CREDITS_PAYOUT_MIN', 'CREDITS_PAYOUT_MAX', 'MAX_CREDIT_PURCHASE_PER_DAY',
'BALANCE_WARN', 'BALANCE_CRITICAL', 'LOW_BALANCE_THRESHOLD', 'MATCH_ENTRY_FEE_ATOMIC',
'MATCH_PAYOUT_MAX'
];
const POSITIVE_ATOMIC_ENV_KEYS = new Set([
'DIRECT_GAME_PRICE', 'SINGLE_GAME_PRICE', 'CREDITS_PACKAGE_PRICE',
'CREDITS_PAYOUT_BASE', 'PAYOUT_MAX_PER_GAME', 'CREDITS_PAYOUT_MAX',
'MAX_CREDIT_PURCHASE_PER_DAY', 'MATCH_ENTRY_FEE_ATOMIC', 'MATCH_PAYOUT_MAX'
]);
function isTrue(value) {
return TRUE_VALUES.has(String(value || '').trim().toLowerCase());
}
function canonicalInteger(value) {
return String(value ?? '').trim().replace(/_/g, '');
}
function raceTicketGrant(product) {
const grants = product?.grants;
if (!grants || typeof grants !== 'object') return null;
const hasEntries = Object.prototype.hasOwnProperty.call(grants, 'race_entries')
|| Object.prototype.hasOwnProperty.call(grants, 'raceEntries');
if (!hasEntries) return null;
return {
productId: String(product.id || '(unnamed)'),
entries: grants.race_entries ?? grants.raceEntries,
valueAtomic: grants.race_entry_value_atomic ?? grants.raceEntryValueAtomic,
price: product.price
};
}
function isLoopbackHost(hostname) {
const normalized = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
}
class EnvironmentValidator {
constructor(options = {}) {
this.logger = options.logger || console;
this.env = options.env || process.env;
}
_endpointIssue(name, value) {
if (!value) return `${name} is not set.`;
try {
const endpoint = new URL(value);
if (!['http:', 'https:'].includes(endpoint.protocol)) {
return `${name} must use http:// or https://.`;
}
return null;
} catch (_) {
return `${name} must be a valid URL.`;
}
}
_pushEndpointTransportWarning(warnings, name, value) {
if (!value) return;
try {
const endpoint = new URL(value);
if (endpoint.protocol === 'http:' && !isLoopbackHost(endpoint.hostname)) {
warnings.push(`${name} uses plaintext HTTP on a non-loopback host; use TLS or a private authenticated network.`);
}
} catch (_) {
// Syntax errors are reported separately.
}
}
validate(config) {
const env = this.env;
const warnings = [];
const errors = [];
const production = String(env.NODE_ENV || '').toLowerCase() === 'production';
const paymentsEnabled = Boolean(config?.paymentsEnabled);
const directEnabled = paymentsEnabled && Boolean(config?.modes?.direct?.enabled);
const creditsEnabled = paymentsEnabled && Boolean(config?.modes?.credits?.enabled);
const payoutMasterEnabled = Boolean(config?.payouts?.enabled);
const directPayoutsEnabled = payoutMasterEnabled && directEnabled
&& Boolean(config?.payouts?.rules?.direct?.enabled);
const creditsPayoutsEnabled = payoutMasterEnabled && creditsEnabled
&& Boolean(config?.payouts?.rules?.credits?.enabled);
const matchModeEnabled = isTrue(env.MATCH_ENABLED);
const matchCryptoEnabled = matchModeEnabled && isTrue(env.MATCH_CRYPTO_RACE_ENABLED);
const matchPayoutsEnabled = payoutMasterEnabled && matchCryptoEnabled
&& isTrue(env.MATCH_PAYOUTS_ENABLED);
const anyPayoutsEnabled = directPayoutsEnabled || creditsPayoutsEnabled || matchPayoutsEnabled;
// PAYOUTS_ENABLED also authorizes settlement of already-created durable rows even when
// every admission switch is off, so payout-only recovery has the same wallet boundary.
const walletRequired = paymentsEnabled || payoutMasterEnabled;
const cryptoType = String(env.CRYPTO_TYPE || config?.currency?.symbol || 'WOW').trim().toUpperCase();
const network = String(env.MONERO_NETWORK || 'mainnet').trim().toLowerCase();
// Check for conflicting legacy/new config
if (process.env.GAME_MODE && process.env.PAYMENT_MODES) {
if (env.GAME_MODE && env.PAYMENT_MODES) {
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
}
// Wallet RPC required for payments
if (config.paymentsEnabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('Payments enabled but PRIMARY_WALLET_ENDPOINT not set. Wallet RPC will fail.');
if (!isSupported(cryptoType)) {
errors.push(`CRYPTO_TYPE=${cryptoType || '(empty)'} is not a supported chain profile.`);
} else if (walletRequired && familyFor(cryptoType) !== 'monero') {
errors.push(`Paid production adapters are not implemented for CRYPTO_TYPE=${cryptoType}; use WOW or XMR.`);
}
// Blockchain RPC required for payments
if (config.paymentsEnabled && !process.env.PRIMARY_RPC_ENDPOINT) {
warnings.push('Payments enabled but PRIMARY_RPC_ENDPOINT missing. Block sync will fail.');
if (cryptoType === 'WOW' && network !== 'mainnet') {
errors.push('Wownero only supports MONERO_NETWORK=mainnet.');
}
if (cryptoType === 'XMR' && !['mainnet', 'stagenet', 'testnet'].includes(network)) {
errors.push('MONERO_NETWORK must be mainnet, stagenet, or testnet for XMR.');
}
// Direct mode price validation
if (config.modes.direct.enabled && config.modes.direct.price <= 0n) {
errors.push('Direct payment mode enabled but price is not positive.');
const walletIssue = this._endpointIssue('PRIMARY_WALLET_ENDPOINT', env.PRIMARY_WALLET_ENDPOINT);
if (walletRequired && walletIssue) {
(production ? errors : warnings).push(`Financial workers require a wallet but ${walletIssue}`);
}
// Credits mode package validation
if (config.modes.credits.enabled && !config.modes.credits.packages.length) {
errors.push('Credits mode enabled but no credit packages defined.');
if (Boolean(env.WALLET_RPC_USER) !== Boolean(env.WALLET_RPC_PASSWORD)) {
(production ? errors : warnings).push('WALLET_RPC_USER and WALLET_RPC_PASSWORD must be set together.');
}
// Validate CREDITS_PACKAGES JSON format
if (process.env.CREDITS_PACKAGES) {
if (production && paymentsEnabled) {
if (!isTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED)) {
errors.push('Production payments require PAID_ACKNOWLEDGEMENT_REQUIRED=true; the server will not accept a paid action without the current acknowledgement.');
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(String(env.LEGAL_POLICY_VERSION || ''))) {
errors.push('LEGAL_POLICY_VERSION must be an explicit 1-64 character version identifier for production payments.');
}
const effectiveDate = String(env.TERMS_EFFECTIVE_DATE || '');
const parsedEffectiveDate = /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate)
? new Date(`${effectiveDate}T00:00:00Z`)
: null;
if (!parsedEffectiveDate || Number.isNaN(parsedEffectiveDate.getTime())
|| parsedEffectiveDate.toISOString().slice(0, 10) !== effectiveDate) {
errors.push('TERMS_EFFECTIVE_DATE must be an explicit valid YYYY-MM-DD date for production payments.');
}
const configuredMinimumAge = Number(env.MINIMUM_AGE);
if (!Number.isInteger(configuredMinimumAge) || configuredMinimumAge < 18 || configuredMinimumAge > 120) {
errors.push('MINIMUM_AGE must be an explicit integer from 18 through 120 for production payments.');
}
const operatorName = String(env.OPERATOR_NAME || '').trim();
if (operatorName.length < 2 || operatorName.length > 120 || PLACEHOLDER_SECRET.test(operatorName)) {
errors.push('OPERATOR_NAME must identify the responsible operator without placeholder text for production payments.');
}
try {
const parsed = JSON.parse(process.env.CREDITS_PACKAGES);
if (!Array.isArray(parsed)) {
const contact = new URL(String(env.OPERATOR_CONTACT_URL || ''));
if (!['https:', 'mailto:'].includes(contact.protocol)) {
errors.push('OPERATOR_CONTACT_URL must use https:// or mailto: for production payments.');
}
if (PLACEHOLDER_SECRET.test(String(env.OPERATOR_CONTACT_URL || ''))) {
errors.push('OPERATOR_CONTACT_URL must not contain placeholder text for production payments.');
}
} catch (_) {
errors.push('OPERATOR_CONTACT_URL must be an explicit valid https:// or mailto: URL for production payments.');
}
}
if (production && walletRequired) {
if (!env.WALLET_RPC_USER || !env.WALLET_RPC_PASSWORD) {
errors.push('Production financial workers require WALLET_RPC_USER and WALLET_RPC_PASSWORD for wallet-rpc Digest authentication.');
} else if (String(env.WALLET_RPC_PASSWORD).length < 24
|| PLACEHOLDER_SECRET.test(String(env.WALLET_RPC_PASSWORD))) {
errors.push('WALLET_RPC_PASSWORD must be a strong non-placeholder secret of at least 24 characters in production.');
}
if (env.PRIMARY_WALLET_ENDPOINT) {
try {
const walletEndpoint = new URL(env.PRIMARY_WALLET_ENDPOINT);
if (walletEndpoint.protocol === 'http:' && !isLoopbackHost(walletEndpoint.hostname)) {
errors.push('Production wallet-rpc over plaintext HTTP must bind to a loopback address.');
}
} catch (_) {
// Endpoint syntax is reported by _endpointIssue above.
}
}
}
if (env.HOSTED_BY) {
try {
const hostedBy = new URL(env.HOSTED_BY);
if (!['http:', 'https:'].includes(hostedBy.protocol)) {
(production ? errors : warnings).push('HOSTED_BY must use http:// or https://.');
}
if (production && hostedBy.protocol !== 'https:') {
errors.push('HOSTED_BY must use https:// in production.');
}
} catch (_) {
(production ? errors : warnings).push('HOSTED_BY must be a valid public URL.');
}
}
const daemonIssue = this._endpointIssue('PRIMARY_RPC_ENDPOINT', env.PRIMARY_RPC_ENDPOINT);
if (production && daemonIssue) {
errors.push(`Production block timing requires a daemon RPC: ${daemonIssue}`);
} else if (paymentsEnabled && daemonIssue) {
warnings.push(`Payments are enabled but ${daemonIssue}`);
}
this._pushEndpointTransportWarning(warnings, 'PRIMARY_WALLET_ENDPOINT', env.PRIMARY_WALLET_ENDPOINT);
this._pushEndpointTransportWarning(warnings, 'PRIMARY_RPC_ENDPOINT', env.PRIMARY_RPC_ENDPOINT);
if (config?.modes?.direct?.enabled && config.modes.direct.price <= 0n) {
errors.push('Direct payment mode is enabled but its price is not positive.');
}
if (config?.modes?.credits?.enabled && !config.modes.credits.packages?.length) {
errors.push('Credits mode is enabled but no credit packages are defined.');
}
if (env.CREDITS_PACKAGES) {
try {
const packages = JSON.parse(env.CREDITS_PACKAGES);
if (!Array.isArray(packages)) {
errors.push('CREDITS_PACKAGES must be a JSON array.');
} else if (parsed.length === 0) {
warnings.push('CREDITS_PACKAGES is an empty array. Using defaults.');
} else if (packages.length === 0) {
errors.push('CREDITS_PACKAGES must not be empty when explicitly configured.');
} else {
// Validate each package has required fields
parsed.forEach((pkg, i) => {
if (!pkg.id) warnings.push(`CREDITS_PACKAGES[${i}] missing "id" field.`);
if (!pkg.credits && pkg.credits !== 0) warnings.push(`CREDITS_PACKAGES[${i}] missing "credits" field.`);
if (!pkg.price) warnings.push(`CREDITS_PACKAGES[${i}] missing "price" field.`);
packages.forEach((pkg, index) => {
if (!pkg || typeof pkg !== 'object') {
errors.push(`CREDITS_PACKAGES[${index}] must be an object.`);
return;
}
if (!pkg.id) errors.push(`CREDITS_PACKAGES[${index}] is missing "id".`);
if (!Number.isInteger(Number(pkg.credits)) || Number(pkg.credits) <= 0) {
errors.push(`CREDITS_PACKAGES[${index}].credits must be a positive integer.`);
}
try {
if (BigInt(pkg.price) <= 0n) throw new Error('not positive');
} catch (_) {
errors.push(`CREDITS_PACKAGES[${index}].price must be a positive atomic-unit integer.`);
}
});
}
} catch (e) {
errors.push(`CREDITS_PACKAGES is not valid JSON: ${e.message}`);
} catch (error) {
errors.push(`CREDITS_PACKAGES is not valid JSON: ${error.message}`);
}
} else if (config.modes.credits.enabled) {
this.logger.info?.('ℹ️ Credits mode enabled with default packages. Set CREDITS_PACKAGES for production.');
} else if (creditsEnabled) {
warnings.push('Credits mode is using built-in package defaults; set CREDITS_PACKAGES explicitly for production pricing.');
}
// Warn if direct payouts enabled but no wallet RPC
if (config.payouts.rules.direct.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('DIRECT_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.');
if (!payoutMasterEnabled
&& (Boolean(config?.payouts?.rules?.direct?.enabled) || Boolean(config?.payouts?.rules?.credits?.enabled))) {
warnings.push('Payout rules are enabled but PAYOUTS_ENABLED is off; the master switch suppresses all payout creation and dispatch.');
}
// Warn if credits payouts enabled but no wallet RPC
if (config.payouts.rules.credits.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
warnings.push('CREDITS_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.');
if (production) {
for (const key of BOOLEAN_ENV_KEYS) {
if (Object.prototype.hasOwnProperty.call(env, key)
&& !BOOLEAN_VALUES.has(String(env[key]).trim().toLowerCase())) {
errors.push(`${key} must be an explicit boolean (true or false).`);
}
}
for (const key of ATOMIC_ENV_KEYS) {
if (!Object.prototype.hasOwnProperty.call(env, key) || env[key] === '') continue;
const raw = canonicalInteger(env[key]);
if (!/^\d+$/.test(raw)) {
errors.push(`${key} must be a non-negative integer in atomic units.`);
continue;
}
try {
if (POSITIVE_ATOMIC_ENV_KEYS.has(key) && BigInt(raw) <= 0n) {
errors.push(`${key} must be greater than zero.`);
}
} catch (_) {
errors.push(`${key} must be a valid atomic-unit integer.`);
}
}
if (env.GAME_MODE && !['FREE', 'PAID_SINGLE', 'PAID_CREDITS'].includes(String(env.GAME_MODE).trim().toUpperCase())) {
errors.push('GAME_MODE must be FREE, PAID_SINGLE, or PAID_CREDITS.');
}
if (env.PAYMENT_MODES) {
const configuredModes = String(env.PAYMENT_MODES).split(',')
.map(mode => mode.trim().toLowerCase()).filter(Boolean);
const invalidModes = configuredModes.filter(mode => !['direct', 'credits'].includes(mode));
if (configuredModes.length === 0 || invalidModes.length > 0) {
errors.push('PAYMENT_MODES must contain only direct and/or credits.');
}
}
for (const key of [
'DIRECT_PAYOUT_ESCAPE', 'DIRECT_PAYOUT_TREASURE',
'CREDITS_PAYOUT_ESCAPE', 'CREDITS_PAYOUT_TREASURE'
]) {
if (!Object.prototype.hasOwnProperty.call(env, key) || env[key] === '') continue;
const multiplier = Number(env[key]);
if (!Number.isFinite(multiplier) || multiplier <= 0) {
errors.push(`${key} must be a finite number greater than zero.`);
}
}
if (Object.prototype.hasOwnProperty.call(env, 'MATCH_HOUSE_FEE_PERCENT')) {
if (parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT) === null) {
errors.push('MATCH_HOUSE_FEE_PERCENT must be at least 0 and less than 100.');
}
}
if (isTrue(env.TRUST_PROXY)) {
const proxyHops = Number(env.TRUST_PROXY_HOPS || 1);
if (!Number.isInteger(proxyHops) || proxyHops < 1 || proxyHops > 8) {
errors.push('TRUST_PROXY_HOPS must be an integer from 1 through 8.');
}
}
if (matchModeEnabled) {
for (const key of [
'MATCH_PAID_ENTROPY_DELAY_BLOCKS',
'MATCH_PAID_ENTROPY_CONFIRMATIONS'
]) {
const raw = String(env[key] ?? '').trim();
const value = /^\d+$/.test(raw) ? Number(raw) : NaN;
if (!Number.isSafeInteger(value) || value < 2 || value > 100) {
errors.push(`${key} must be an explicit safe integer from 2 through 100 when production match mode is enabled.`);
}
}
}
if (matchCryptoEnabled && !Object.prototype.hasOwnProperty.call(env, 'MATCH_PAYOUTS_ENABLED')) {
errors.push('Production crypto matches require an explicit MATCH_PAYOUTS_ENABLED=true|false setting.');
}
if (isTrue(env.MATCH_PAYOUTS_ENABLED) && !matchCryptoEnabled) {
errors.push('MATCH_PAYOUTS_ENABLED=true requires MATCH_ENABLED=true and MATCH_CRYPTO_RACE_ENABLED=true.');
}
if (matchPayoutsEnabled) {
if (!directEnabled && !creditsEnabled) {
errors.push('Crypto match payouts require at least one enabled paid product mode for race-entry tickets.');
}
const payoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX);
if (payoutCap === null) {
errors.push('MATCH_PAYOUT_MAX must be an explicit positive PostgreSQL BIGINT atomic-unit cap when crypto match payouts are enabled.');
}
const ruleset = resolveMatchRuleset(env.MATCH_RULESET_ID || 'race');
if (!cryptoRulesetSupported(ruleset)) {
errors.push(`MATCH_RULESET_ID=${ruleset.id} does not have supported single-winner crypto payout semantics.`);
}
const requestedMaxRaw = String(env.MATCH_MAX_PLAYERS ?? '').trim();
const requestedMax = /^\d+$/.test(requestedMaxRaw)
? Number(requestedMaxRaw)
: NaN;
const contract = playerContract(ruleset, env.MATCH_MAX_PLAYERS || ruleset.players.max);
if (!Number.isInteger(requestedMax)
|| requestedMax < contract.minPlayers
|| requestedMax > ruleset.players.max
|| requestedMax > 32) {
errors.push(`MATCH_MAX_PLAYERS must be an integer from ${contract.minPlayers} through ${Math.min(32, ruleset.players.max)} for ruleset ${ruleset.id}.`);
}
const entryFee = parsePositiveAtomic(env.MATCH_ENTRY_FEE_ATOMIC);
const feeBasisPoints = parseHouseFeeBasisPoints(env.MATCH_HOUSE_FEE_PERCENT);
if (entryFee === null) {
errors.push('MATCH_ENTRY_FEE_ATOMIC must be an explicit positive PostgreSQL BIGINT atomic-unit value when crypto match payouts are enabled.');
}
if (feeBasisPoints === null) {
errors.push('MATCH_HOUSE_FEE_PERCENT must be explicitly set from 0 (inclusive) to 100 (exclusive) when crypto match payouts are enabled.');
}
const catalog = [
...(config?.modes?.credits?.packages || []),
...(config?.products?.cosmetic || [])
];
const ticketProducts = catalog.map(raceTicketGrant).filter(Boolean);
if (ticketProducts.length === 0) {
errors.push('Crypto match payouts require at least one configured race-entry product with explicit per-ticket atomic backing.');
}
for (const ticket of ticketProducts) {
const rawEntries = String(ticket.entries ?? '').trim();
let ticketEntries = null;
if (/^\d+$/.test(rawEntries)) {
try {
const parsed = BigInt(rawEntries);
if (parsed > 0n && parsed <= 2147483647n) ticketEntries = parsed;
} catch (_) { /* reported below */ }
}
if (ticketEntries === null) {
errors.push(`Race-entry product ${ticket.productId} must grant a positive integer number of tickets.`);
continue;
}
const ticketValue = parsePositiveAtomic(ticket.valueAtomic);
if (ticketValue === null) {
errors.push(`Race-entry product ${ticket.productId} must set grants.race_entry_value_atomic to a positive PostgreSQL BIGINT atomic-unit value.`);
continue;
}
if (entryFee !== null && ticketValue !== entryFee) {
errors.push(`Race-entry product ${ticket.productId} backing must equal MATCH_ENTRY_FEE_ATOMIC (${entryFee} atomic units per ticket).`);
}
const price = parsePositiveAtomic(ticket.price);
const requiredBacking = ticketValue * ticketEntries;
if (price === null || price < requiredBacking) {
errors.push(`Race-entry product ${ticket.productId} price must fund at least ${requiredBacking} atomic units of ticket backing.`);
}
}
if (entryFee !== null && payoutCap !== null && feeBasisPoints !== null
&& Number.isInteger(requestedMax)) {
const pot = entryFee * BigInt(requestedMax);
const feeBp = BigInt(feeBasisPoints);
const maximumLiability = pot - ((pot * feeBp) / 10000n);
if (pot > PG_BIGINT_MAX) {
errors.push(`MATCH_ENTRY_FEE_ATOMIC * MATCH_MAX_PLAYERS exceeds PostgreSQL BIGINT (${PG_BIGINT_MAX}).`);
}
if (maximumLiability > payoutCap) {
errors.push(`MATCH_PAYOUT_MAX is below the configured ${requestedMax}-player winner liability (${maximumLiability} atomic units).`);
}
}
}
if (payoutMasterEnabled) {
const balanceCritical = parsePositiveAtomic(env.BALANCE_CRITICAL);
const balanceWarn = parsePositiveAtomic(env.BALANCE_WARN);
const acceptedLiabilities = [];
if (directPayoutsEnabled || creditsPayoutsEnabled) {
const soloPayoutCap = parsePositiveAtomic(env.PAYOUT_MAX_PER_GAME);
if (soloPayoutCap === null) {
errors.push('PAYOUT_MAX_PER_GAME must be an explicit positive PostgreSQL BIGINT atomic-unit cap when solo payouts are enabled.');
} else {
acceptedLiabilities.push({ label: 'PAYOUT_MAX_PER_GAME', amount: soloPayoutCap });
}
}
if (matchPayoutsEnabled) {
const matchPayoutCap = parsePositiveAtomic(env.MATCH_PAYOUT_MAX);
if (matchPayoutCap !== null) {
acceptedLiabilities.push({ label: 'MATCH_PAYOUT_MAX', amount: matchPayoutCap });
}
}
if (balanceCritical === null) {
errors.push('BALANCE_CRITICAL must be an explicit positive PostgreSQL BIGINT atomic-unit reserve when payouts are enabled.');
} else {
const uncovered = acceptedLiabilities
.filter(liability => balanceCritical < liability.amount)
.sort((left, right) => left.amount < right.amount ? 1 : -1);
if (uncovered.length > 0) {
const largest = uncovered[0];
errors.push(`BALANCE_CRITICAL must be at least ${largest.label} (${largest.amount} atomic units) so one accepted payout is always covered.`);
}
}
if (balanceWarn === null) {
errors.push('BALANCE_WARN must be an explicit positive PostgreSQL BIGINT atomic-unit warning threshold when payouts are enabled.');
} else if (balanceCritical !== null && balanceWarn < balanceCritical) {
errors.push(`BALANCE_WARN must be greater than or equal to BALANCE_CRITICAL (${balanceCritical} atomic units).`);
}
}
// Never let production advertise a multiplier outcome which settlement would cap or
// suppress. Browser disclosures and immutable game terms must describe the same amount.
const validateSoloOutcomes = (label, enabled, base, rule) => {
if (!enabled) return;
let baseAtomic;
let minAtomic;
let maxAtomic;
try {
baseAtomic = money.toBig(base);
minAtomic = money.toBig(rule?.minPayout);
maxAtomic = money.toBig(rule?.maxPayout);
} catch (_) {
errors.push(`${label} payout base/min/max must be exact atomic-unit integers.`);
return;
}
for (const [outcome, multiplier] of [
['escape', rule?.multipliers?.escape],
['escapeWithTreasure', rule?.multipliers?.escapeWithTreasure]
]) {
let amount;
try { amount = money.mulByDecimal(baseAtomic, multiplier); }
catch (_) {
errors.push(`${label} ${outcome} multiplier must be a non-negative decimal.`);
continue;
}
if (amount < minAtomic || amount <= 0n) {
errors.push(`${label} ${outcome} payout (${amount} atomic units) is below its configured minimum (${minAtomic}).`);
}
if (amount > maxAtomic) {
errors.push(`${label} ${outcome} payout (${amount} atomic units) exceeds its configured maximum (${maxAtomic}).`);
}
}
};
validateSoloOutcomes(
'Direct', directPayoutsEnabled,
config?.modes?.direct?.price,
config?.payouts?.rules?.direct
);
validateSoloOutcomes(
'Credits', creditsPayoutsEnabled,
config?.payouts?.rules?.credits?.baseValue,
config?.payouts?.rules?.credits
);
if (!Object.prototype.hasOwnProperty.call(env, 'PAYMENTS_ENABLED') && !env.GAME_MODE) {
errors.push('Production requires an explicit PAYMENTS_ENABLED=true|false setting.');
}
if (!Object.prototype.hasOwnProperty.call(env, 'PAYOUTS_ENABLED')) {
errors.push('Production requires an explicit PAYOUTS_ENABLED=true|false setting.');
}
if (directEnabled && !Object.prototype.hasOwnProperty.call(env, 'DIRECT_PAYOUTS_ENABLED')) {
errors.push('Production direct mode requires an explicit DIRECT_PAYOUTS_ENABLED=true|false setting.');
}
if (creditsEnabled
&& !Object.prototype.hasOwnProperty.call(env, 'CREDITS_PAYOUTS_ENABLED')
&& !Object.prototype.hasOwnProperty.call(env, 'CREDITS_PAYOUT_ENABLED')) {
errors.push('Production credits mode requires an explicit CREDITS_PAYOUTS_ENABLED=true|false setting.');
}
const adminKey = String(env.ADMIN_API_KEY || '');
if (adminKey.length < 32 || PLACEHOLDER_SECRET.test(adminKey) || new Set(adminKey).size < 8) {
errors.push('ADMIN_API_KEY must be a strong non-placeholder secret of at least 32 characters in production.');
}
if (!env.DB_PASSWORD || PLACEHOLDER_SECRET.test(String(env.DB_PASSWORD))) {
errors.push('DB_PASSWORD must be set to a non-placeholder value in production.');
}
const simulated = isTrue(env.SIMULATED_BLOCKS)
|| isTrue(env.FORCE_SIMULATED_BLOCKS)
|| String(env.BLOCK_SOURCE || '').toLowerCase() === 'simulated';
if (simulated) {
errors.push('Simulated blocks are forbidden when NODE_ENV=production.');
}
if (isTrue(env.DEBUG_HOTKEYS)) {
errors.push('DEBUG_HOTKEYS must not be enabled in production.');
}
if (payoutMasterEnabled && network === 'mainnet' && !isTrue(env.ALLOW_MAINNET_PAYOUTS)) {
errors.push('Mainnet payouts require the explicit safety acknowledgement ALLOW_MAINNET_PAYOUTS=true.');
}
if (network !== 'mainnet' && isTrue(env.SMIRK_ENABLED)) {
warnings.push('SMIRK_ENABLED is ignored on test networks; set it false to keep operator intent clear.');
}
if (isTrue(env.TRUST_PROXY) === false) {
warnings.push('TRUST_PROXY is not enabled; this is correct only when the Node process is directly exposed.');
}
}
// Info about FREE mode
if (!config.paymentsEnabled) {
this.logger.info?.('ℹ️ Payments disabled. Server running in FREE mode.');
if (!paymentsEnabled) {
this.logger.info?.('ℹ️ Payments disabled. Server will run in free mode.');
}
warnings.forEach(message => this.logger.warn?.(`⚠️ ${message}`));
errors.forEach(message => this.logger.error?.(`❌ ${message}`));
// Emit warnings and errors
if (warnings.length) {
warnings.forEach(msg => this.logger.warn?.(`⚠️ ${msg}`));
return {
warnings,
errors,
production,
money: {
paymentsEnabled,
payoutsEnabled: anyPayoutsEnabled,
payoutWorkerEnabled: payoutMasterEnabled,
walletRequired,
directPayoutsEnabled,
creditsPayoutsEnabled,
matchPayoutsEnabled
}
};
}
assertValid(config) {
const result = this.validate(config);
if (result.errors.length > 0) {
const error = new Error(`Invalid production configuration (${result.errors.length} error${result.errors.length === 1 ? '' : 's'}).`);
error.name = 'EnvironmentValidationError';
error.code = 'INVALID_ENVIRONMENT';
error.validation = result;
throw error;
}
if (errors.length) {
errors.forEach(msg => this.logger.error?.(`❌ ${msg}`));
}
return { warnings, errors };
return result;
}
}
+42 -18
View File
@@ -48,8 +48,7 @@ const DEFAULT_CONFIG = Object.freeze({
enabled: true,
multipliers: {
escape: 2.0,
escapeWithTreasure: 3.0,
perfectRun: 5.0
escapeWithTreasure: 3.0
},
minPayout: 1000000000n,
maxPayout: 10000000000000n
@@ -58,8 +57,7 @@ const DEFAULT_CONFIG = Object.freeze({
enabled: false,
multipliers: {
escape: 2.0,
escapeWithTreasure: 3.0,
perfectRun: 3.0
escapeWithTreasure: 3.0
},
baseValue: 100000000000n,
minPayout: 1000000000n,
@@ -163,10 +161,12 @@ function parseAtomicValue(value, fallback) {
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) {
// A Number above MAX_SAFE_INTEGER has already lost atomic units before it reaches
// this parser. Reject it instead of freezing a rounded value into the config.
if (!Number.isSafeInteger(value)) {
return fallback;
}
return BigInt(Math.round(value));
return BigInt(value);
}
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
@@ -174,7 +174,9 @@ function parseAtomicValue(value, fallback) {
return fallback;
}
if (normalized.startsWith('0x')) {
// Parse integer text with BigInt before touching Number. Atomic-unit configuration often
// exceeds Number.MAX_SAFE_INTEGER; routing it through Number first silently rounds money.
if (/^[+-]?\d+$/.test(normalized) || /^0x[0-9a-f]+$/i.test(normalized)) {
try {
return BigInt(normalized);
} catch (error) {
@@ -182,16 +184,9 @@ function parseAtomicValue(value, fallback) {
}
}
const numeric = Number(normalized);
if (Number.isFinite(numeric)) {
return BigInt(Math.round(numeric));
}
try {
return BigInt(normalized);
} catch (error) {
return fallback;
}
// Atomic-unit configuration is integral by definition. Decimal/exponential text must
// not be rounded through Number, especially once values exceed 2^53.
return fallback;
}
function parseFloatValue(value, fallback) {
@@ -255,7 +250,12 @@ class PaymentConfigManager {
loadConfig() {
const config = cloneConfig(DEFAULT_CONFIG);
config.paymentsEnabled = parseBoolean(process.env.PAYMENTS_ENABLED, config.paymentsEnabled);
// PAYMENTS_ENABLED is the authoritative master switch. Keep its explicit value
// separate while parsing the subordinate mode selectors below; otherwise
// PAYMENT_MODES/DIRECT_PAYMENT_ENABLED/CREDITS_ENABLED can accidentally turn the
// payment system back on after an operator has set PAYMENTS_ENABLED=false.
const paymentsMaster = parseBoolean(process.env.PAYMENTS_ENABLED, null);
config.paymentsEnabled = paymentsMaster === null ? config.paymentsEnabled : paymentsMaster;
const gameModeOverride = process.env.GAME_MODE ? process.env.GAME_MODE.toUpperCase() : null;
if (gameModeOverride === 'FREE') {
@@ -299,6 +299,12 @@ class PaymentConfigManager {
if (creditsEnabled !== undefined) {
config.modes.credits.enabled = creditsEnabled;
}
if (paymentsMaster === false) {
config.paymentsEnabled = false;
config.modes.direct.enabled = false;
config.modes.credits.enabled = false;
}
config.modes.credits.creditsPerGame = parseInteger(process.env.CREDITS_PER_GAME, config.modes.credits.creditsPerGame);
config.modes.credits.requiresAddress = parseBoolean(process.env.CREDITS_REQUIRES_ADDRESS, config.modes.credits.requiresAddress);
config.modes.credits.allowMixedMode = parseBoolean(process.env.ALLOW_MIXED_MODE, config.modes.credits.allowMixedMode);
@@ -340,8 +346,21 @@ class PaymentConfigManager {
config.payouts.rules.credits.multipliers.escape = parseFloatValue(process.env.CREDITS_PAYOUT_ESCAPE, config.payouts.rules.credits.multipliers.escape);
config.payouts.rules.credits.multipliers.escapeWithTreasure = parseFloatValue(process.env.CREDITS_PAYOUT_TREASURE, config.payouts.rules.credits.multipliers.escapeWithTreasure);
config.payouts.rules.credits.baseValue = parseAtomicValue(process.env.CREDITS_PAYOUT_BASE, config.payouts.rules.credits.baseValue);
config.payouts.rules.credits.minPayout = parseAtomicValue(process.env.CREDITS_PAYOUT_MIN ?? process.env.PAYOUT_MIN_AMOUNT, config.payouts.rules.credits.minPayout);
// PAYOUT_MAX_PER_GAME is an outer safety bound for every solo payout. A dedicated
// credits override is supported, but absent one the global cap must not protect only
// direct entries while leaving credit wins at a larger default.
const globalPayoutMax = parseAtomicValue(process.env.PAYOUT_MAX_PER_GAME, null);
const creditsPayoutMax = parseAtomicValue(process.env.CREDITS_PAYOUT_MAX, config.payouts.rules.credits.maxPayout);
config.payouts.rules.credits.maxPayout = globalPayoutMax !== null && globalPayoutMax < creditsPayoutMax
? globalPayoutMax
: creditsPayoutMax;
config.payouts.processing.batchInterval = parseInteger(process.env.PAYOUT_BATCH_INTERVAL, config.payouts.processing.batchInterval);
config.payouts.processing.confirmations = parseInteger(
process.env.PAYMENT_CONFIRMATIONS,
config.payouts.processing.confirmations
);
config.payouts.processing.maxRetries = parseInteger(process.env.PAYOUT_MAX_RETRIES, config.payouts.processing.maxRetries);
config.limits.maxGamesPerHour = parseInteger(process.env.MAX_GAMES_PER_HOUR, config.limits.maxGamesPerHour);
@@ -400,6 +419,11 @@ class PaymentConfigManager {
this.logger.warn?.('⚠️ Payments enabled but no payment modes active. Falling back to FREE mode.');
}
if (!Number.isSafeInteger(this.config.payouts.processing.confirmations)
|| this.config.payouts.processing.confirmations < 1) {
throw new Error('Payment confirmations must be an integer of at least 1');
}
this.validatePrices();
return true;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Pure runtime safety policies shared by startup, public status, and background workers.
* Keeping these predicates side-effect free makes the money kill switches regression-testable.
*/
function isPayoutProcessingEnabled(config, gameModeManager, options = {}) {
if (!config?.payouts?.enabled || gameModeManager?.payoutsEnabled === false) return false;
const direct = Boolean(config.modes?.direct?.enabled)
&& Boolean(config.payouts?.rules?.direct?.enabled)
&& Boolean(gameModeManager?.directPayoutEnabled);
const credits = Boolean(config.modes?.credits?.enabled)
&& Boolean(config.payouts?.rules?.credits?.enabled)
&& Boolean(gameModeManager?.creditsPayoutEnabled);
// Admission switches are intentionally not consulted for durable liabilities. Once any
// paid game has created a payout row, turning off a product or match mode must stop new
// liabilities without stranding the existing one. Only settlement-worker callers opt into
// this behavior, and PAYOUTS_ENABLED plus the manager emergency switch remain authoritative.
const acceptedLiabilities = options.settleAcceptedLiabilities === true
// Compatibility for callers/tests written before the dispatcher was audited as generic.
|| options.settleAcceptedMatchLiabilities === true;
return direct || credits || acceptedLiabilities;
}
function isWalletRequired(config, gameModeManager, options = {}) {
// Intake needs subaddress creation/receipt checks. Settlement still needs transfer RPC when
// operators pause every new invoice, so payout-only recovery must initialize the same
// authenticated wallet boundary instead of falling through to a default endpoint.
return Boolean(config?.paymentsEnabled)
|| isPayoutProcessingEnabled(config, gameModeManager, options);
}
module.exports = { isPayoutProcessingEnabled, isWalletRequired };
+11
View File
@@ -36,6 +36,8 @@ class DatabaseManager {
constructor() {
this.pool = null;
this.connected = false;
this.lastSuccessfulQueryAt = 0;
this.lastFailedQueryAt = 0;
this.queryValidator = new QueryValidator();
}
@@ -78,6 +80,7 @@ class DatabaseManager {
client.release();
this.connected = true;
this.lastSuccessfulQueryAt = Date.now();
console.log('✅ Database connected successfully');
return true;
@@ -155,6 +158,7 @@ class DatabaseManager {
try {
const start = Date.now();
const result = await this.pool.query(text, params);
this.lastSuccessfulQueryAt = Date.now();
const duration = Date.now() - start;
if (process.env.NODE_ENV === 'development') {
@@ -163,6 +167,7 @@ class DatabaseManager {
return result;
} catch (error) {
this.lastFailedQueryAt = Date.now();
console.error('❌ Database query error:', error.message);
console.error('Query:', text);
throw error;
@@ -244,6 +249,12 @@ class DatabaseManager {
return this.connected;
}
isHealthy(maxStaleMs = Number(process.env.DB_HEALTH_MAX_STALE_MS) || 30000) {
return this.connected
&& this.lastSuccessfulQueryAt > 0
&& (Date.now() - this.lastSuccessfulQueryAt) <= maxStaleMs;
}
/**
* Health check
*/
+27 -15
View File
@@ -4,7 +4,6 @@
* Keeps debug/development code separate from production logic
*/
const rpc = require('../rpc/rpccalls.js');
const packageConfig = require('../package.json');
class DebugManager {
@@ -40,6 +39,7 @@ class DebugManager {
this.debugInterval = null;
this.statusInterval = null;
this.lastProductionBlockHeight = 0;
this.lastSuccessfulRpcAt = 0;
// Log configuration on startup
this.logConfig();
@@ -119,13 +119,17 @@ class DebugManager {
// Initialize RPC service if available
const RpcService = require('../rpc/rpcService');
this.rpcService = new RpcService();
// Real blockchain monitoring with new RPC service
const poll = async () => {
try {
const currentHeight = await this.rpcService.getBlockHeight();
if (this.rpcService.healthy) {
this.lastSuccessfulRpcAt = Date.now();
}
if (!currentHeight) {
if (!currentHeight || !this.rpcService.healthy) {
if (this.CONSOLE_LOGGING) {
console.log("❌ Failed to get block count from daemon");
}
@@ -151,18 +155,6 @@ class DebugManager {
console.error("❌ RPC Error:", error.message);
}
// Fallback to legacy RPC if new service fails
rpc.daemonCall("get_block_count", "", (result) => {
if (result && result.result && result.result.count) {
const currentHeight = result.result.count;
this.broadcastManager.broadcastBlockHeight(currentHeight);
if (currentHeight > this.lastProductionBlockHeight) {
this.onNewBlock(currentHeight);
this.lastProductionBlockHeight = currentHeight;
}
}
});
}
};
// immediate first poll so UI shows real height quickly
@@ -181,6 +173,24 @@ class DebugManager {
return this.lastProductionBlockHeight;
}
/** Whether the production daemon has answered recently enough for readiness. */
isChainHealthy(maxStaleMs = Number(process.env.RPC_HEALTH_MAX_STALE_MS) || 15000) {
if (this.SIMULATED_BLOCKS) return true;
return this.lastSuccessfulRpcAt > 0 && (Date.now() - this.lastSuccessfulRpcAt) <= maxStaleMs;
}
/** Sanitized chain identity for readiness; never exposes RPC URLs or credentials. */
getChainIdentity() {
return this.rpcService?.getIdentityStatus?.() || {
required: this.IS_PRODUCTION,
verified: false,
expected: null,
actual: null,
reason: 'not_checked',
checkedAt: 0
};
}
/**
* Set debug block height (debug mode only)
* @param {number} height - New block height
@@ -263,6 +273,8 @@ class DebugManager {
currentBlockHeight: this.getCurrentBlockHeight(),
isRunning: !!this.debugInterval,
lastProductionBlock: this.lastProductionBlockHeight,
chainHealthy: this.isChainHealthy(),
lastSuccessfulRpcAt: this.lastSuccessfulRpcAt,
debugBlock: this.debugBlockHeight
};
}
+7 -1
View File
@@ -42,6 +42,9 @@ const DUNGEON_CONFIGS = {
* Dungeon generation utilities
*/
class DungeonGenerator {
static get GENERATOR_VERSION() { return 'dungeon-generator-v1'; }
static get FINGERPRINT_VERSION() { return 1; }
// Get the configuration object with difficulty settings applied
static getConfig(cryptoType = null) {
const difficulty = getDifficultyConfig(cryptoType || process.env.CRYPTO_TYPE || 'WOW');
@@ -99,7 +102,10 @@ class DungeonGenerator {
* @param {object} dungeon - A dungeon object from generate()/regenerateFromSeed()
* @returns {string} SHA-256 hex of the layout-defining fields
*/
static layoutFingerprint(dungeon) {
static layoutFingerprint(dungeon, fingerprintVersion = this.FINGERPRINT_VERSION) {
if (Number(fingerprintVersion) !== 1) {
throw new Error(`Unsupported dungeon fingerprint version: ${fingerprintVersion}`);
}
const payload = JSON.stringify({
map: dungeon.map,
entrance: dungeon.entrance,
+133
View File
@@ -0,0 +1,133 @@
/**
* One-time, socket-bound server-seed commitments for solo games.
*
* The server publishes hash(serverSeed) before a client chooses its clientSeed. A start request
* consumes exactly one published offer; the secret serverSeed is then handed to Game internally
* and revealed only after the run. Keeping issuance separate from consumption prevents the server
* from choosing/grinding its seed after seeing the player's contribution.
*/
const crypto = require('crypto');
const { generateSeed, hashSeed, normalizeClientSeed } = require('./provablyFair');
const DEFAULT_TTL_MS = 15 * 60 * 1000;
class FairnessOfferManager {
constructor({ ttlMs = DEFAULT_TTL_MS, now = () => Date.now(), seedFactory = generateSeed, idFactory = () => crypto.randomUUID() } = {}) {
this.ttlMs = Math.max(1000, Number(ttlMs) || DEFAULT_TTL_MS);
this.now = now;
this.seedFactory = seedFactory;
this.idFactory = idFactory;
this.offers = new Map(); // offerId -> private offer
this.currentBySocket = new Map(); // socketId -> offerId
this.consumed = new Map(); // offerId -> expiry (explicit replay detection)
}
_prune() {
const now = this.now();
for (const [id, offer] of this.offers.entries()) {
if (offer.expiresAt <= now) {
this.offers.delete(id);
if (this.currentBySocket.get(offer.socketId) === id) this.currentBySocket.delete(offer.socketId);
}
}
for (const [id, expiresAt] of this.consumed.entries()) {
if (expiresAt <= now) this.consumed.delete(id);
}
}
_public(offer) {
return {
offerId: offer.offerId,
commitment: offer.commitment,
issuedAt: offer.issuedAt,
expiresAt: offer.expiresAt,
algorithm: 'HMAC-SHA256(serverSeed, clientSeed)',
proofVersion: 2
};
}
/** Return the existing unexpired offer for a socket, or publish a fresh one. */
ensureOffer(socketId) {
this._prune();
const owner = String(socketId || '');
if (!owner) throw new Error('socketId is required for a fairness offer');
const currentId = this.currentBySocket.get(owner);
const current = currentId ? this.offers.get(currentId) : null;
if (current) return this._public(current);
const issuedAt = this.now();
const serverSeed = this.seedFactory();
const offer = {
offerId: this.idFactory(),
socketId: owner,
serverSeed,
commitment: hashSeed(serverSeed),
issuedAt,
expiresAt: issuedAt + this.ttlMs
};
this.offers.set(offer.offerId, offer);
this.currentBySocket.set(owner, offer.offerId);
return this._public(offer);
}
/**
* Consume a published offer once. Missing offerId is accepted only for legacy clients with
* an empty client seed; modern clients must echo the offer they actually received.
*/
consume(socketId, { offerId = null, clientSeed = '' } = {}) {
this._prune();
const owner = String(socketId || '');
const normalizedSeed = normalizeClientSeed(clientSeed);
if (clientSeed != null && String(clientSeed).trim() !== '' && normalizedSeed === null) {
return { success: false, code: 'INVALID_CLIENT_SEED', reason: 'Client seed must be 1-64 hexadecimal characters.' };
}
if (!offerId && normalizedSeed) {
return { success: false, code: 'OFFER_REQUIRED', reason: 'A published fairness offer is required for a client seed.' };
}
const resolvedId = offerId || this.currentBySocket.get(owner);
if (!resolvedId) {
return { success: false, code: 'OFFER_MISSING', reason: 'No active fairness offer. Request a new offer and retry.' };
}
if (this.consumed.has(resolvedId)) {
return { success: false, code: 'OFFER_REPLAYED', reason: 'That fairness offer was already used.' };
}
const offer = this.offers.get(resolvedId);
if (!offer) {
return { success: false, code: 'OFFER_EXPIRED', reason: 'That fairness offer expired. Request a new offer and retry.' };
}
if (offer.socketId !== owner) {
return { success: false, code: 'OFFER_OWNER_MISMATCH', reason: 'That fairness offer belongs to another connection.' };
}
// Synchronous delete/mark makes consumption atomic in Node's event loop. No await may
// occur between checking and claiming this offer.
this.offers.delete(resolvedId);
if (this.currentBySocket.get(owner) === resolvedId) this.currentBySocket.delete(owner);
this.consumed.set(resolvedId, Math.max(offer.expiresAt, this.now() + this.ttlMs));
return {
success: true,
proofInput: {
proofVersion: 2,
offerId: offer.offerId,
offerIssuedAt: offer.issuedAt,
serverSeed: offer.serverSeed,
commitment: offer.commitment,
clientSeed: normalizedSeed || ''
}
};
}
discardSocket(socketId) {
const owner = String(socketId || '');
const id = this.currentBySocket.get(owner);
if (id) this.offers.delete(id);
this.currentBySocket.delete(owner);
}
}
module.exports = FairnessOfferManager;
+118
View File
@@ -0,0 +1,118 @@
const DungeonGenerator = require('./dungeon');
const { levelSeed } = require('./provablyFair');
function parseJson(value) {
if (value == null || value === '') return null;
if (typeof value === 'object') return value;
try { return JSON.parse(value); } catch (_) { return null; }
}
/**
* Regenerate and compare the versioned layout manifest attached to an already-verified
* seed relationship. Legacy rows with only `layout_fingerprint` remain level-one verifiable;
* new rows must contain one contiguous entry for every advertised depth.
*/
function attachDungeonVerification(result, {
effectiveSeed,
proofContext = null,
expectedFingerprint = null,
expectedFingerprints = null,
generatorVersion = null
} = {}) {
if (!result.valid || !effectiveSeed) return result;
try {
const context = parseJson(proofContext) || {};
const options = context.generationOptions && typeof context.generationOptions === 'object'
? context.generationOptions
: {};
const recordedGenerator = generatorVersion || context.generatorVersion || DungeonGenerator.GENERATOR_VERSION;
if (recordedGenerator !== DungeonGenerator.GENERATOR_VERSION) {
throw Object.assign(new Error('unsupported_generator_version'), { code: 'UNSUPPORTED_GENERATOR' });
}
const parsedManifest = parseJson(expectedFingerprints);
const hasManifest = Array.isArray(parsedManifest) && parsedManifest.length > 0;
const maxDepth = Math.max(1, parseInt(context.maxDepth, 10) || 1);
let manifest;
if (hasManifest) {
manifest = parsedManifest.slice().sort((a, b) => Number(a.depth) - Number(b.depth));
const depths = manifest.map(item => Number(item.depth));
const expectedDepths = Array.from({ length: maxDepth }, (_, i) => i + 1);
if (manifest.length !== maxDepth || depths.some((depth, i) => depth !== expectedDepths[i])) {
throw Object.assign(new Error('incomplete_layout_manifest'), { code: 'INCOMPLETE_MANIFEST' });
}
} else {
manifest = [{
depth: 1,
fingerprintVersion: 1,
generatorVersion: recordedGenerator,
fingerprint: expectedFingerprint ? String(expectedFingerprint).trim() : null
}];
}
const levels = manifest.map((entry) => {
const depth = Number(entry.depth);
const entryGenerator = entry.generatorVersion || recordedGenerator;
if (entryGenerator !== DungeonGenerator.GENERATOR_VERSION) {
throw Object.assign(new Error('unsupported_generator_version'), { code: 'UNSUPPORTED_GENERATOR' });
}
const fingerprintVersion = Number(entry.fingerprintVersion || context.fingerprintVersion || 1);
const dungeon = DungeonGenerator.regenerateFromSeed(
levelSeed(effectiveSeed, depth),
context.cryptoType || process.env.CRYPTO_TYPE || 'WOW',
options
);
if (depth < maxDepth) dungeon.treasure = null;
const fingerprint = DungeonGenerator.layoutFingerprint(dungeon, fingerprintVersion);
const expected = entry.fingerprint ? String(entry.fingerprint).trim() : null;
return {
depth,
fingerprintVersion,
generatorVersion: entryGenerator,
fingerprint,
expectedFingerprint: expected,
matches: expected ? fingerprint === expected : null,
dungeonSize: { width: dungeon.map[0].length, height: dungeon.map.length },
entrance: dungeon.entrance,
exit: dungeon.exit,
treasure: dungeon.treasure
};
});
const compared = levels.filter(level => level.expectedFingerprint);
const allMatch = compared.length > 0 ? compared.every(level => level.matches) : null;
result.generatorVersion = recordedGenerator;
result.layoutManifestVersion = hasManifest ? 1 : 0;
result.verificationScope = hasManifest ? 'all_depths' : 'legacy_level_1_only';
result.levels = levels;
result.verifiedDepths = levels.map(level => level.depth);
result.layoutFingerprint = levels[0]?.fingerprint || null;
result.expectedLayoutFingerprint = levels[0]?.expectedFingerprint || null;
result.layoutMatches = allMatch;
result.dungeonSize = levels[0]?.dungeonSize || null;
result.entrance = levels[0]?.entrance || null;
result.exit = levels[0]?.exit || null;
result.treasure = levels[0]?.treasure ?? null;
if (allMatch === false) {
result.valid = false;
result.message = '❌ Seed derivation verified, but one or more regenerated depth fingerprints do not match the played run.';
} else if (hasManifest && allMatch === true) {
result.message = `✅ Game proof and all ${levels.length} dungeon depth fingerprints verified.`;
}
} catch (error) {
result.valid = false;
if (error?.code === 'UNSUPPORTED_GENERATOR') {
result.message = '❌ This proof uses an unsupported dungeon generator version.';
result.regenerationError = 'unsupported_generator_version';
} else if (error?.code === 'INCOMPLETE_MANIFEST') {
result.message = '❌ The persisted all-depth layout manifest is incomplete.';
result.regenerationError = 'incomplete_layout_manifest';
} else {
result.message = '❌ Proof fields verified, but the persisted dungeon context could not be regenerated.';
result.regenerationError = 'regeneration_failed';
}
}
return result;
}
module.exports = { attachDungeonVerification };
+92 -13
View File
@@ -18,6 +18,11 @@ class Game {
* @param {object} gameOptions - Optional game configuration overrides
*/
constructor(socketId, user, gameOptions = {}) {
const suppliedOptions = (gameOptions && typeof gameOptions === 'object') ? gameOptions : {};
const fairnessProof = suppliedOptions.fairnessProof || null;
// Internal proof material (including the secret server seed) must never ride into dungeon
// options, snapshots, logs, or the persisted public generation context.
const { fairnessProof: _privateFairnessProof, clientSeed: optionClientSeed, ...dungeonOptions } = suppliedOptions;
this.user = user;
this.id = uuidv4();
this.socketId = socketId;
@@ -28,7 +33,11 @@ class Game {
this.startedAt = Date.now();
// Provably fair: Generate game proof before anything else
this.gameProof = createGameProof(this.id);
this.gameProof = createGameProof(
this.id,
fairnessProof?.clientSeed ?? optionClientSeed ?? '',
fairnessProof
);
// Per-game deterministic RNG derived from the committed seed. ALL game randomness
// (dungeon generation + monster movement) flows through this so the game is
// reproducible from the seed for provably-fair verification.
@@ -39,22 +48,30 @@ class Game {
const dungeonConfig = DungeonGenerator.getConfig();
// Determine dimensions - use provided options or defaults
const width = gameOptions.width || dungeonConfig.DEFAULT_WIDTH;
const height = gameOptions.height || dungeonConfig.DEFAULT_HEIGHT;
const width = dungeonOptions.width || dungeonConfig.DEFAULT_WIDTH;
const height = dungeonOptions.height || dungeonConfig.DEFAULT_HEIGHT;
// Merge game configuration
this.gameConfig = {
width: width,
height: height,
// Dungeon generation options (can be overridden by gameOptions)
floorVariation: gameOptions.floorVariation !== undefined ? gameOptions.floorVariation : dungeonConfig.FLOOR_VARIATION,
torchEnabled: gameOptions.torchEnabled !== undefined ? gameOptions.torchEnabled : dungeonConfig.TORCH_ENABLED,
torchDensity: gameOptions.torchDensity !== undefined ? gameOptions.torchDensity : dungeonConfig.TORCH_DENSITY,
primaryFloor: gameOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR,
secondaryFloor: gameOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR,
torchTile: gameOptions.torchTile || dungeonConfig.TORCH_TILE,
floorVariation: dungeonOptions.floorVariation !== undefined ? dungeonOptions.floorVariation : dungeonConfig.FLOOR_VARIATION,
torchEnabled: dungeonOptions.torchEnabled !== undefined ? dungeonOptions.torchEnabled : dungeonConfig.TORCH_ENABLED,
torchDensity: dungeonOptions.torchDensity !== undefined ? dungeonOptions.torchDensity : dungeonConfig.TORCH_DENSITY,
primaryFloor: dungeonOptions.primaryFloor || dungeonConfig.PRIMARY_FLOOR,
secondaryFloor: dungeonOptions.secondaryFloor || dungeonConfig.SECONDARY_FLOOR,
torchTile: dungeonOptions.torchTile || dungeonConfig.TORCH_TILE,
// Resolve every layout-affecting preset value now. Persisting these exact values lets an
// old game regenerate after operators change difficulty environment variables later.
roomWidthRange: dungeonOptions.roomWidthRange || dungeonConfig.ROOM_WIDTH_RANGE,
roomHeightRange: dungeonOptions.roomHeightRange || dungeonConfig.ROOM_HEIGHT_RANGE,
corridorLengthRange: dungeonOptions.corridorLengthRange || dungeonConfig.CORRIDOR_LENGTH_RANGE,
dugPercentage: dungeonOptions.dugPercentage ?? dungeonConfig.DUG_PERCENTAGE,
roomPositionRatio: dungeonOptions.roomPositionRatio ?? dungeonConfig.difficulty?.treasure?.roomPositionRatio,
cryptoType: dungeonOptions.cryptoType || process.env.CRYPTO_TYPE || 'WOW',
// Other game options
...gameOptions
...dungeonOptions
};
if (CONSOLE_LOGGING) {
@@ -64,6 +81,56 @@ class Game {
}
this.initializeGame();
// Bind the proof to every level in the committed run. Deeper layouts are independently
// regenerated from their per-depth seeds now, before play, so a crash or early death still
// leaves a complete, immutable verification manifest for the advertised descent.
this.gameProof.generatorVersion = DungeonGenerator.GENERATOR_VERSION;
this.gameProof.context = this.getProofContext();
this.gameProof.layoutFingerprints = this._buildLayoutFingerprintManifest();
// Backward-compatible level-one alias used by older verification clients/rows.
this.gameProof.layoutFingerprint = this.gameProof.layoutFingerprints[0]?.fingerprint || null;
}
_buildLayoutFingerprintManifest() {
const manifest = [];
for (let depth = 1; depth <= this.maxDepth; depth += 1) {
let dungeon;
if (depth === 1) {
dungeon = this.dungeon;
} else {
dungeon = DungeonGenerator.regenerateFromSeed(
levelSeed(this.gameProof.seed, depth),
this.gameConfig.cryptoType,
this.gameProof.context.generationOptions
);
if (depth < this.maxDepth) dungeon.treasure = null;
}
manifest.push({
depth,
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
generatorVersion: DungeonGenerator.GENERATOR_VERSION,
fingerprint: DungeonGenerator.layoutFingerprint(dungeon)
});
}
return manifest;
}
getProofContext() {
const keys = [
'width', 'height', 'floorVariation', 'torchEnabled', 'torchDensity', 'primaryFloor',
'secondaryFloor', 'torchTile', 'roomWidthRange', 'roomHeightRange',
'corridorLengthRange', 'dugPercentage', 'roomPositionRatio', 'cryptoType'
];
const generationOptions = {};
for (const key of keys) generationOptions[key] = this.gameConfig[key];
return {
cryptoType: this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW',
maxDepth: this.maxDepth,
generatorVersion: DungeonGenerator.GENERATOR_VERSION,
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
generationOptions
};
}
/**
@@ -100,7 +167,7 @@ class Game {
this.height = this.gameConfig.height;
// Get difficulty settings
const difficultyConfig = getDifficultyConfig(process.env.CRYPTO_TYPE || 'WOW');
const difficultyConfig = getDifficultyConfig(this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW');
this.difficultyConfig = difficultyConfig;
// Multi-level descent: a run spans `maxDepth` levels (a pacing knob ∝ block time, from the
@@ -151,6 +218,17 @@ class Game {
}
this.dungeon = dungeon;
// Once the constructor has committed its all-level manifest, assert every actually played
// descent still matches it. This turns accidental generator drift within a live process into
// a hard failure rather than silently serving an unverifiable paid layout.
const expected = this.gameProof.layoutFingerprints?.find(item => item.depth === depth);
if (expected) {
const actual = DungeonGenerator.layoutFingerprint(dungeon, expected.fingerprintVersion);
if (actual !== expected.fingerprint) {
throw new Error(`Generated level ${depth} does not match its committed fingerprint`);
}
}
// Place player at the entrance.
if (dungeon.entrance) {
this.player.moveTo(dungeon.entrance[0], dungeon.entrance[1]);
@@ -359,6 +437,7 @@ class Game {
* @param {object} gameStats - Additional statistics
*/
endGame(result, gameStats = {}) {
this.finalResult = result;
if (this.user) {
const score = gameStats.score || 0;
this.user.endGame(result, score, gameStats);
@@ -382,7 +461,7 @@ class Game {
*/
getProofReveal() {
return getPostGameReveal(this.gameProof, {
won: this.gameState === 'won',
won: this.finalResult === 'won' || this.gameState === 'won',
treasureFound: this.player?.hasTreasure ?? false,
moves: this.moveCount,
duration: Math.floor((Date.now() - this.startedAt) / 1000)
@@ -401,4 +480,4 @@ class Game {
}
}
module.exports = Game;
module.exports = Game;
+343 -112
View File
@@ -6,13 +6,30 @@
const Game = require('../game/game');
class GameManager {
constructor({ activeGames, io, broadcastManager, debugManager, gameModeManager, spectatorManager = null }) {
constructor({
activeGames,
io,
broadcastManager,
debugManager,
gameModeManager,
spectatorManager = null,
settlementRetryBaseMs = 1000,
settlementRetryMaxMs = 30000
}) {
this.activeGames = activeGames;
this.io = io;
this.broadcastManager = broadcastManager;
this.debugManager = debugManager;
this.gameModeManager = gameModeManager;
this.spectatorManager = spectatorManager;
this.suspendedGameManager = null;
this._pendingSettlements = new Map();
this._settlementRetryBaseMs = Math.max(1, Number(settlementRetryBaseMs) || 1000);
this._settlementRetryMaxMs = Math.max(
this._settlementRetryBaseMs,
Number(settlementRetryMaxMs) || 30000
);
this._isShuttingDown = false;
}
/**
@@ -23,6 +40,11 @@ class GameManager {
this.spectatorManager = spectatorManager;
}
/** Late binding avoids a constructor cycle with reconnect support. */
setSuspendedGameManager(suspendedGameManager) {
this.suspendedGameManager = suspendedGameManager;
}
/**
* Create a new game for a user
* @param {Object} user - User object
@@ -31,6 +53,13 @@ class GameManager {
* @returns {Object} Created game instance
*/
async createGameForUser(user, gameType = 'standard', options = {}) {
// Settlement-pending games intentionally remain in activeGames. This is the last-line
// guard against starting/charging a replacement run while the previous terminal result
// is not yet durable (and also closes ordinary double-start races between handlers).
if (this.activeGames.has(user.id)) {
throw new Error('User already has an active or settlement-pending game');
}
let game;
if (gameType === 'legacy') {
@@ -39,12 +68,18 @@ class GameManager {
game = Game.createStandardGame(user.id, user, options);
}
// Persist before exposing the game or charging entry. Paid-start processing updates this
// exact row; allowing play to continue after a failed INSERT can consume a payment/credit
// with no durable game or payout-liability anchor.
await this._insertGameRecord(game, user);
if (this.gameModeManager?.paymentsEnabled && !game.dbId) {
throw new Error('Durable game record is required before a paid game can start');
}
user.joinGame(game);
this.activeGames.set(user.id, game);
// Insert DB record and wait for it (needed for processGameStart to find the row)
await this._insertGameRecord(game, user);
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`);
}
@@ -61,130 +96,298 @@ class GameManager {
* @param {number} score - Game score
*/
async handleGameOver(socket, game, status, reason, message, score = 0) {
const socketId = socket?.id || socket;
if (!socketId || !game?.id) return { success: false, reason: 'Invalid game completion' };
if (game.settlementCommitted) return { success: true, reason: 'Game already completed' };
// Escape, monster collision, and block timeout may converge in one event-loop turn. The
// first caller freezes the authoritative terminal facts synchronously; every later caller
// shares that exact settlement attempt instead of overwriting it or paying twice.
const existing = this._pendingSettlements.get(game.id);
if (existing) return this._attemptSettlement(existing);
try {
const socketId = socket.id || socket;
// IMMEDIATELY remove from active games to prevent double-processing.
// If escape + block timeout fire in the same event loop tick, the second
// call would find the game still here during the first call's await.
this.activeGames.delete(socketId);
game.gameState = status;
const moves = game.moveCount || 0;
const durationSeconds = game.startedAt ? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000)) : null;
const durationSeconds = game.startedAt
? Math.max(0, Math.round((Date.now() - game.startedAt) / 1000))
: null;
const finalScore = score > 0 ? score : this._calculateScore(game, status, reason);
const gameStats = {
score: finalScore,
reason: reason,
treasuresFound: game.player.hasTreasure ? 1 : 0,
moves,
durationSeconds
};
game.endGame(status, gameStats);
// Process game completion with payment system
let payoutInfo = null;
if (this.gameModeManager) {
try {
payoutInfo = await this.gameModeManager.completeGame(
socketId,
game.id,
status === 'won',
game.player.hasTreasure || false,
{ moves, durationSeconds, score: finalScore }
);
if (this.debugManager.CONSOLE_LOGGING && payoutInfo) {
console.log(`💰 Payout processed for ${socketId}:`, payoutInfo);
}
} catch (error) {
console.error('Error processing game completion:', error);
}
}
// Emit game over event with provably fair reveal
const proofReveal = game.getProofReveal ? game.getProofReveal() : null;
this.io.to(socketId).emit('game_over', {
status: status,
reason: reason,
message: message,
const treasure = !!game.player?.hasTreasure;
const terminal = Object.freeze({
status,
reason,
message,
score: finalScore,
moves,
durationSeconds,
payout: payoutInfo,
treasure: game.player.hasTreasure || false,
proof: proofReveal
treasure,
won: status === 'won',
outcome: reason === 'escaped' ? 'escaped' : reason
});
// Persist completion details if DB available
await this._updateGameRecord(game, socketId, status, reason, moves, durationSeconds);
// Notify spectators that the game has ended and broadcast updated list
if (this.spectatorManager) {
this.spectatorManager.notifyGameEnded(game.id, {
status,
reason,
message,
// Mark terminal before any await. Keep ownership in activeGames until the transaction
// commits: movement and all existing start paths can then fail closed on this entry.
game.gameState = status;
game.settlementPending = true;
const settlement = {
game,
socketId,
terminal,
proof: null,
attempts: 0,
retryTimer: null,
inFlight: null,
pendingNoticeSent: false,
committed: false
};
this._pendingSettlements.set(game.id, settlement);
try {
game.endGame(status, {
score: finalScore,
reason,
treasuresFound: treasure ? 1 : 0,
moves,
durationSeconds,
treasure: game.player.hasTreasure || false
durationSeconds
});
} catch (error) {
// In-memory user stats and rendering state are secondary to the durable result.
// A local callback failure must not suppress the settlement retry.
console.error('In-memory game finalization failed:', error.message || error);
}
try {
settlement.proof = game.getProofReveal ? game.getProofReveal() : null;
} catch (error) {
console.error('Game proof reveal failed:', error.message || error);
}
return await this._attemptSettlement(settlement);
} catch (error) {
// Never release a terminal game merely because orchestration failed. If an intent was
// installed it remains retryable; otherwise retain the game and flag it for operators.
game.settlementPending = true;
console.error('GameManager.handleGameOver error:', error.message || error);
return { success: false, reason: 'Game settlement pending' };
}
}
async _attemptSettlement(settlement) {
if (!settlement || settlement.committed) return { success: true, reason: 'Game already completed' };
if (settlement.inFlight) return settlement.inFlight;
const attempt = (async () => {
settlement.attempts += 1;
const socketId = this._socketIdForGame(settlement.game, settlement.socketId);
settlement.socketId = socketId;
let payoutInfo;
try {
if (this.gameModeManager) {
const terminal = settlement.terminal;
payoutInfo = await this.gameModeManager.completeGame(
socketId,
settlement.game.id,
terminal.won,
terminal.treasure,
{
moves: terminal.moves,
durationSeconds: terminal.durationSeconds,
score: terminal.score,
reason: terminal.reason,
outcome: terminal.outcome
}
);
} else {
const terminal = settlement.terminal;
await this._updateGameRecord(
settlement.game,
socketId,
terminal.status,
terminal.reason,
terminal.moves,
terminal.durationSeconds
);
payoutInfo = { success: true, payout: null };
}
} catch (error) {
console.error('Error processing game completion:', error.message || error);
payoutInfo = { success: false };
}
// Broadcast leaderboard update if score > 0
if (finalScore > 0) {
try {
let displayName = null;
if (this.gameModeManager?.db) {
const userRow = await this.gameModeManager.db.query(
`SELECT COALESCE(display_name,
CASE WHEN payout_address IS NOT NULL
THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4)
ELSE 'Anon#' || id
END) as name
FROM users WHERE socket_id = $1`, [socketId]);
displayName = userRow.rows[0]?.name || 'Unknown';
}
this.io.emit('leaderboard_update', {
name: displayName || 'Unknown',
score: finalScore,
treasure: game.player.hasTreasure || false
});
// `completeGame` deliberately normalizes transaction errors into success:false.
// Treat every other shape as non-durable and retry; only explicit success releases
// the game and publishes a result.
if (!payoutInfo || payoutInfo.success !== true) {
this._emitSettlementPending(settlement);
this._scheduleSettlementRetry(settlement);
return payoutInfo || { success: false, reason: 'Game settlement pending' };
}
// Global "win feed": announce actual escapes to everyone so the room feels
// alive (the leaderboard_update above also fires for lost-with-treasure runs;
// the feed should only celebrate real escapes). Payout amount is intentionally
// omitted — it's processed asynchronously and not reliably known yet here.
if (status === 'won') {
this.io.emit('win_feed', {
name: displayName || 'Someone',
treasure: game.player.hasTreasure || false,
score: finalScore,
paid: !!(payoutInfo && payoutInfo.amount)
});
}
} catch (lbErr) {
// Non-critical, don't block game over
if (this.debugManager.CONSOLE_LOGGING) {
console.warn('Leaderboard broadcast failed:', lbErr.message);
}
settlement.committed = true;
settlement.game.settlementPending = false;
settlement.game.settlementCommitted = true;
if (settlement.retryTimer) {
clearTimeout(settlement.retryTimer);
settlement.retryTimer = null;
}
this._pendingSettlements.delete(settlement.game.id);
// Capture the reconnect-adjusted socket before releasing ownership. Remove only this
// exact game so a theoretically newer entry can never be deleted by a late retry.
const committedSocketId = this._socketIdForGame(settlement.game, settlement.socketId);
for (const [activeSocketId, activeGame] of this.activeGames.entries()) {
if (activeGame === settlement.game) this.activeGames.delete(activeSocketId);
}
const stableUserId = settlement.game.dbUserId ?? settlement.game.userId;
if (stableUserId != null) {
try {
this.suspendedGameManager?.removeSuspendedGame?.(stableUserId, { countExpired: false });
} catch (error) {
console.error('Committed suspended-game cleanup failed:', error.message || error);
}
}
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason}), score: ${finalScore}`);
}
await this._publishCommittedSettlement(settlement, payoutInfo, committedSocketId);
return payoutInfo;
})();
settlement.inFlight = attempt;
try {
return await attempt;
} finally {
if (settlement.inFlight === attempt) settlement.inFlight = null;
}
}
_scheduleSettlementRetry(settlement) {
if (this._isShuttingDown || !settlement || settlement.committed || settlement.retryTimer) return;
const exponent = Math.min(Math.max(settlement.attempts - 1, 0), 10);
const delay = Math.min(this._settlementRetryBaseMs * (2 ** exponent), this._settlementRetryMaxMs);
const timer = setTimeout(() => {
if (settlement.retryTimer === timer) settlement.retryTimer = null;
if (settlement.committed || !this._pendingSettlements.has(settlement.game.id)) return;
this._attemptSettlement(settlement).catch(error => {
console.error('Solo settlement retry failed:', error.message || error);
});
}, delay);
timer.unref?.();
settlement.retryTimer = timer;
}
_emitSettlementPending(settlement) {
if (settlement.pendingNoticeSent) return;
settlement.pendingNoticeSent = true;
const socketId = this._socketIdForGame(settlement.game, settlement.socketId);
try {
this.io.to(socketId).emit('game_settlement_pending', {
gameId: settlement.game.id,
code: 'GAME_FINISH_NOT_DURABLE',
retrying: true
});
} catch (error) {
console.error('GameManager.handleGameOver error:', error);
// Still try to clean up even if there was an error
const socketId = socket?.id || socket;
if (socketId) {
this.activeGames.delete(socketId);
// Socket delivery is best-effort. It must never prevent the authoritative DB retry.
console.error('Solo settlement-pending broadcast failed:', error.message || error);
}
}
_socketIdForGame(game, fallback) {
for (const [socketId, activeGame] of this.activeGames.entries()) {
if (activeGame === game) return socketId;
}
return game?.socketId || fallback;
}
async _publishCommittedSettlement(settlement, payoutInfo, socketId) {
const { game, terminal, proof } = settlement;
try {
this.io.to(socketId).emit('game_over', {
status: terminal.status,
reason: terminal.reason,
message: terminal.message,
score: terminal.score,
moves: terminal.moves,
durationSeconds: terminal.durationSeconds,
payout: payoutInfo,
treasure: terminal.treasure,
proof
});
} catch (error) {
console.error('Game-over broadcast failed:', error.message || error);
}
try {
this.spectatorManager?.notifyGameEnded(game.id, {
status: terminal.status,
reason: terminal.reason,
message: terminal.message,
score: terminal.score,
moves: terminal.moves,
durationSeconds: terminal.durationSeconds,
treasure: terminal.treasure
});
} catch (error) {
console.error('Spectator game-over broadcast failed:', error.message || error);
}
if (terminal.score > 0) {
try {
let displayName = null;
if (this.gameModeManager?.db) {
const userRow = await this.gameModeManager.db.query(
`SELECT COALESCE(display_name,
CASE WHEN payout_address IS NOT NULL
THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4)
ELSE 'Anon#' || id
END) as name
FROM users WHERE socket_id = $1`, [socketId]);
displayName = userRow.rows[0]?.name || 'Unknown';
}
this.io.emit('leaderboard_update', {
name: displayName || 'Unknown',
score: terminal.score,
treasure: terminal.treasure
});
if (terminal.status === 'won') {
this.io.emit('win_feed', {
name: displayName || 'Someone',
treasure: terminal.treasure,
score: terminal.score,
paid: !!payoutInfo?.payout?.amount
});
}
} catch (error) {
if (this.debugManager.CONSOLE_LOGGING) {
console.warn('Leaderboard broadcast failed:', error.message);
}
}
}
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`🎮 Game ${game.id} ended for ${socketId}: ${terminal.status} (${terminal.reason}), score: ${terminal.score}`);
}
}
/**
* Stop timers and make bounded, serialized final attempts before the DB pool closes.
* Returns unresolved count so the process shutdown path can log/escalate it truthfully.
*/
async shutdown({ timeoutMs = 4000, retryIntervalMs = 50 } = {}) {
this._isShuttingDown = true;
for (const settlement of this._pendingSettlements.values()) {
if (settlement.retryTimer) clearTimeout(settlement.retryTimer);
settlement.retryTimer = null;
}
const initial = this._pendingSettlements.size;
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
do {
const pending = Array.from(this._pendingSettlements.values());
if (pending.length === 0) break;
await Promise.allSettled(pending.map(settlement => this._attemptSettlement(settlement)));
if (this._pendingSettlements.size === 0 || Date.now() >= deadline) break;
await new Promise(resolve => setTimeout(resolve, Math.max(1, retryIntervalMs)));
} while (Date.now() < deadline);
return { initial, settled: initial - this._pendingSettlements.size, pending: this._pendingSettlements.size };
}
/**
@@ -317,10 +520,36 @@ class GameManager {
try {
const result = await db.query(`
INSERT INTO games (user_id, socket_id, game_mode, status, start_block_height, dungeon_seed, created_at)
VALUES ((SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5, NOW())
INSERT INTO games (
user_id, socket_id, game_mode, status, start_block_height, dungeon_seed,
proof_version, fairness_offer_id, fairness_offer_issued_at,
proof_commitment, server_seed, client_seed, effective_seed,
layout_fingerprint, layout_fingerprints, generator_version,
proof_context, created_at
)
VALUES (
(SELECT id FROM users WHERE socket_id = $1), $2, $3, 'active', $4, $5,
$6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()
)
RETURNING id, user_id
`, [socketId, socketId, gameMode, blockHeight, game.id]);
`, [
socketId,
socketId,
gameMode,
blockHeight,
game.id,
game.gameProof?.proofVersion || 1,
game.gameProof?.offerId || null,
game.gameProof?.offerIssuedAt ? new Date(game.gameProof.offerIssuedAt) : null,
game.gameProof?.commitment || null,
game.gameProof?.serverSeed || null,
game.gameProof?.clientSeed || '',
game.gameProof?.seed || null,
game.gameProof?.layoutFingerprint || null,
game.gameProof?.layoutFingerprints ? JSON.stringify(game.gameProof.layoutFingerprints) : null,
game.gameProof?.generatorVersion || null,
game.gameProof?.context ? JSON.stringify(game.gameProof.context) : null
]);
game.dbId = result.rows[0]?.id || null;
// Stamp the stable DB users.id onto the in-memory game object so
@@ -344,6 +573,7 @@ class GameManager {
} catch (err) {
console.error('Game insert failed:', err.message);
game.dbId = null;
throw err;
}
}
}
@@ -364,7 +594,8 @@ class GameManager {
try {
await db.query(`
UPDATE games SET status = $1, outcome = $2, treasure_found = $3, moves_made = $4,
duration_seconds = $5, score = $8, completed_at = NOW()
duration_seconds = $5, score = $8, completed_at = NOW(),
proof_revealed_at = NOW()
WHERE dungeon_seed = $6 AND socket_id = $7
`, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId, score]);
@@ -386,4 +617,4 @@ class GameManager {
}
}
module.exports = GameManager;
module.exports = GameManager;
+1339 -291
View File
File diff suppressed because it is too large Load Diff
+28 -21
View File
@@ -1,22 +1,17 @@
const ChainProfile = require('../../chain/chainProfile');
const money = require('../../money/atomic');
const DEFAULT_DECIMALS = 12;
const parseAtomicEnvValue = (val, fallback) => {
if (val === undefined || val === null || val === '') return fallback;
if (typeof val !== 'string') {
const numVal = Number(val);
return Number.isFinite(numVal) ? Math.trunc(numVal) : fallback;
}
const cleaned = val.replace(/_/g, '').trim();
if (!cleaned) {
const cleaned = typeof val === 'string' ? val.replace(/_/g, '').trim() : val;
try {
const parsed = money.toBig(cleaned);
return parsed >= 0n ? money.toSafe(parsed) : fallback;
} catch (_) {
return fallback;
}
const num = Number(cleaned);
if (!Number.isFinite(num) || num < 0) {
return fallback;
}
return Math.trunc(num);
};
// Delegates to the ChainProfile registry — one source for all chains (WOW 11, XMR 12, BTC/LTC 8,
@@ -53,24 +48,36 @@ const formatAtomic = ({ value, decimals, digits = 6 }) => {
if (value === undefined || value === null) {
return '0';
}
const divisor = getDecimalDivisor(decimals);
const quotient = Number(value) / divisor;
if (!Number.isFinite(quotient)) {
return value.toString();
try {
const atomic = money.toBig(value);
const negative = atomic < 0n;
const abs = negative ? -atomic : atomic;
const scale = 10n ** BigInt(decimals);
const whole = abs / scale;
const fraction = (abs % scale).toString().padStart(decimals, '0')
.slice(0, Math.max(0, digits)).replace(/0+$/, '');
return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`;
} catch (_) {
return String(value);
}
return quotient.toFixed(digits).replace(/0+$/, '').replace(/\.$/, '');
};
const formatAtomicHuman = ({ value, decimals, digits = 3 }) => {
if (value === undefined || value === null) {
return '0';
}
const divisor = getDecimalDivisor(decimals);
const quotient = Number(value) / divisor;
if (!Number.isFinite(quotient)) {
return value.toString();
try {
const atomic = money.toBig(value);
const negative = atomic < 0n;
const abs = negative ? -atomic : atomic;
const scale = 10n ** BigInt(decimals);
const whole = abs / scale;
const fraction = (abs % scale).toString().padStart(decimals, '0')
.slice(0, Math.max(0, digits)).padEnd(Math.max(0, digits), '0');
return `${negative ? '-' : ''}${whole}${digits > 0 ? `.${fraction}` : ''}`;
} catch (_) {
return String(value);
}
return quotient.toFixed(digits);
};
module.exports = {
+13
View File
@@ -11,9 +11,11 @@ class MovementManager {
this._lastMove = new Map(); // socketId -> timestamp
this.postMoveHook = typeof postMoveHook === 'function' ? postMoveHook : null;
this.spectatorManager = spectatorManager; // For broadcasting to spectators
this._acceptingMoves = true;
}
handleMove(socketId, moveData) {
if (!this._acceptingMoves) return;
if (!moveData || typeof moveData.direction !== 'string') return;
const now = Date.now();
const last = this._lastMove.get(socketId) || 0;
@@ -23,6 +25,11 @@ class MovementManager {
const game = this.activeGames.get(socketId);
if (!game) return; // not in a game
// A terminal result stays in activeGames while its atomic DB completion is retried. Keeping
// the entry blocks replacement games; this explicit guard prevents the retained Game object
// from accepting moves (Game.movePlayer historically does not inspect gameState itself).
if (game.settlementPending || game.settlementCommitted || game.gameState === 'ended') return;
const dir = moveData.direction;
let dx = 0, dy = 0;
switch (dir) {
@@ -95,6 +102,12 @@ class MovementManager {
this.spectatorManager.broadcastToSpectators(game.id, state);
}
}
/** Freeze gameplay before the graceful-shutdown settlement drain takes its snapshot. */
shutdown() {
this._acceptingMoves = false;
this._lastMove.clear();
}
}
module.exports = MovementManager;
+83 -14
View File
@@ -1,7 +1,7 @@
/**
* Provably Fair Gaming System
*
* This module implements cryptographic verification for fair game generation.
* This module implements cryptographic verification for committed dungeon generation.
*
* How it works:
* 1. Server generates a random seed before game starts
@@ -17,10 +17,13 @@
* 6. Player can verify: hash(seed) === pre-game commitment
* 7. Player can regenerate the dungeon using the seed to verify fairness
*
* This prevents the server from:
* This lets a player detect the server:
* - Generating impossible dungeons after seeing player's payment
* - Changing dungeon mid-game
* - Claiming different game outcomes
* - Substituting a different recorded dungeon layout after the run
*
* Outcome execution is a separate server-authoritative concern. Without a signed input/event
* transcript, this proof does not independently replay block timing, player moves, monster turns,
* the declared result, or payout delivery.
*/
const crypto = require('crypto');
@@ -42,6 +45,16 @@ function hashSeed(seed) {
return crypto.createHash('sha256').update(seed).digest('hex');
}
/**
* Normalize a client contribution. Empty is a supported legacy value; non-empty values must be
* short hex so they are unambiguous across browser/server implementations. `null` means invalid.
*/
function normalizeClientSeed(value) {
if (value === undefined || value === null || String(value).trim() === '') return '';
const seed = String(value).trim();
return /^[0-9a-fA-F]{1,64}$/.test(seed) ? seed.toLowerCase() : null;
}
/**
* Verify that a seed matches its commitment hash
* @param {string} seed - The revealed seed
@@ -156,17 +169,26 @@ function seededShuffle(rng, array) {
* @param {string} [clientSeed=''] - Optional client-supplied seed (non-breaking)
* @returns {object} Proof object with seeds, commitment, and creation time
*/
function createGameProof(gameId, clientSeed = '') {
const serverSeed = generateSeed();
function createGameProof(gameId, clientSeed = '', precommit = null) {
const normalizedClientSeed = normalizeClientSeed(clientSeed);
if (normalizedClientSeed === null) throw new Error('Invalid client seed');
const serverSeed = precommit?.serverSeed || generateSeed();
const commitment = hashSeed(serverSeed); // commits to serverSeed only
if (precommit?.commitment && precommit.commitment !== commitment) {
throw new Error('Fairness offer commitment does not match its server seed');
}
// Effective RNG seed folds in the client seed so neither party controls it.
const seed = deriveSeed(serverSeed, clientSeed);
const seed = deriveSeed(serverSeed, normalizedClientSeed);
const timestamp = Date.now();
return {
gameId,
proofVersion: precommit?.proofVersion || 1,
offerId: precommit?.offerId || null,
offerIssuedAt: precommit?.offerIssuedAt || null,
serverSeed, // Keep secret until game ends
clientSeed, // Client contribution (may be '')
clientSeed: normalizedClientSeed, // Client contribution (may be '')
seed, // Derived effective seed used to generate the dungeon
commitment, // Show to player before game starts
timestamp,
@@ -183,6 +205,10 @@ function createGameProof(gameId, clientSeed = '') {
function getPreGameCommitment(proof) {
return {
gameId: proof.gameId,
proofVersion: proof.proofVersion || 1,
offerId: proof.offerId || null,
offerIssuedAt: proof.offerIssuedAt || null,
clientSeed: proof.clientSeed ?? '',
commitment: proof.commitment,
timestamp: proof.timestamp,
message: "This hash commits to your game's layout. After the game, you'll receive the seed to verify fairness."
@@ -203,10 +229,18 @@ function getPostGameReveal(proof, gameResult = {}) {
const effectiveSeed = proof.seed ?? deriveSeed(serverSeed, clientSeed);
return {
gameId: proof.gameId,
proofVersion: proof.proofVersion || 1,
offerId: proof.offerId || null,
offerIssuedAt: proof.offerIssuedAt || null,
serverSeed,
clientSeed,
seed: effectiveSeed,
seed: effectiveSeed, // backward-compatible alias
effectiveSeed,
commitment: proof.commitment,
layoutFingerprint: proof.layoutFingerprint || null,
layoutFingerprints: proof.layoutFingerprints || null,
generatorVersion: proof.generatorVersion || proof.context?.generatorVersion || null,
context: proof.context || null,
timestamp: proof.timestamp,
gameResult: {
won: gameResult.won ?? false,
@@ -214,17 +248,50 @@ function getPostGameReveal(proof, gameResult = {}) {
moves: gameResult.moves ?? 0,
duration: gameResult.duration ?? 0
},
verificationUrl: `/verify/${effectiveSeed}`,
verificationUrl: `/verify/${proof.gameId}`,
instructions: [
"To verify this game was fair:",
"To verify this game's committed dungeon generation:",
`1. Compute SHA-256 of the serverSeed and confirm it equals the commitment: ${proof.commitment}`,
"2. Compute HMAC-SHA256(key=serverSeed, msg=clientSeed) to derive the effective seed",
"3. The effective seed deterministically generates the dungeon layout, treasure, and monster behaviour",
`4. Open ${`/verify/${effectiveSeed}`} to regenerate the dungeon from the effective seed and compare its layout fingerprint`
"3. The effective seed deterministically generates every dungeon depth and its recorded placements",
`4. Open ${`/verify/${proof.gameId}`} to regenerate every depth and compare the versioned layout fingerprints`,
"5. This layout proof does not replay player input, block timing, monster turns, the result, or payout delivery"
]
};
}
/** Verify both halves of a v2 proof: the published server commitment and derived RNG seed. */
function verifyGameProof({ serverSeed, clientSeed = '', effectiveSeed, seed, commitment } = {}) {
const normalizedClientSeed = normalizeClientSeed(clientSeed);
const revealedServerSeed = String(serverSeed || '');
const claimedEffectiveSeed = String(effectiveSeed || seed || '');
const expectedCommitment = String(commitment || '');
const shapeValid = /^[0-9a-f]{64}$/i.test(revealedServerSeed)
&& /^[0-9a-f]{64}$/i.test(claimedEffectiveSeed)
&& /^[0-9a-f]{64}$/i.test(expectedCommitment)
&& normalizedClientSeed !== null;
const computedCommitment = shapeValid ? hashSeed(revealedServerSeed) : '';
const computedEffectiveSeed = shapeValid ? deriveSeed(revealedServerSeed, normalizedClientSeed) : '';
const commitmentValid = shapeValid && computedCommitment === expectedCommitment;
const effectiveSeedValid = shapeValid && computedEffectiveSeed === claimedEffectiveSeed;
const valid = commitmentValid && effectiveSeedValid;
return {
valid,
commitmentValid,
effectiveSeedValid,
serverSeed: revealedServerSeed,
clientSeed: normalizedClientSeed === null ? String(clientSeed || '') : normalizedClientSeed,
effectiveSeed: claimedEffectiveSeed,
expectedCommitment,
computedCommitment,
computedEffectiveSeed,
message: valid
? '✅ Game proof verified: the server commitment and client-derived dungeon seed both match.'
: '❌ Game proof verification failed.'
};
}
/**
* Verification result for a game
* @param {string} seed - The revealed seed
@@ -250,6 +317,7 @@ function verifyGame(seed, expectedCommitment) {
module.exports = {
generateSeed,
hashSeed,
normalizeClientSeed,
verifySeed,
deriveSeed,
createSeededRNG,
@@ -260,5 +328,6 @@ module.exports = {
createGameProof,
getPreGameCommitment,
getPostGameReveal,
verifyGame
verifyGame,
verifyGameProof
};
+10 -1
View File
@@ -1,6 +1,13 @@
const { defineRuleset, WIN, ECONOMY } = require('./Ruleset');
const { resolveWinCondition } = require('./winConditions');
const { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts } = require('./registry');
const {
BUILTINS,
getRuleset,
listRulesets,
listMatchRulesets,
resolveMatchRuleset,
rulesetFromMatchOpts
} = require('./registry');
module.exports = {
defineRuleset,
@@ -10,5 +17,7 @@ module.exports = {
BUILTINS,
getRuleset,
listRulesets,
listMatchRulesets,
resolveMatchRuleset,
rulesetFromMatchOpts
};
+38 -6
View File
@@ -11,32 +11,37 @@ const BUILTINS = {
id: 'solo-classic', label: 'Classic Escape', mode: 'solo',
players: { min: 1, max: 1 },
winCondition: { type: WIN.FIRST_TO_EXIT },
economy: { model: ECONOMY.FREE }
economy: { model: ECONOMY.FREE },
metadata: { description: 'The original single-player escape dungeon.' }
}),
'race': defineRuleset({
id: 'race', label: 'Escape Race', mode: 'race',
world: { difficultyPreset: 'race' }, // matches DEFAULT_MATCH_PRESET (falls through difficultyConfig, preserved)
players: { min: 2, max: 8 },
winCondition: { type: WIN.FIRST_TO_EXIT },
economy: { model: ECONOMY.CRYPTO_RACE }
economy: { model: ECONOMY.CRYPTO_RACE },
metadata: { description: 'First player to reach the exit wins.' }
}),
'last-alive': defineRuleset({
id: 'last-alive', label: 'Last Alive', mode: 'pvp',
world: { difficultyPreset: 'normal' },
entities: { monster: true, pvpCombat: true }, // step onto a rival to strike them down
players: { min: 2, max: 8 },
winCondition: { type: WIN.LAST_ALIVE }
winCondition: { type: WIN.LAST_ALIVE },
metadata: { description: 'Outlast rivals or strike them down by moving into their tile.' }
}),
'score-attack': defineRuleset({
id: 'score-attack', label: 'Score Attack', mode: 'race',
players: { min: 1, max: 8 },
winCondition: { type: WIN.HIGH_SCORE }
winCondition: { type: WIN.HIGH_SCORE },
metadata: { description: 'Highest score at the deadline wins; escaping does not end the match.' }
}),
'coop-escape': defineRuleset({
id: 'coop-escape', label: 'Co-op Escape', mode: 'coop',
entities: { pvpCombat: false },
players: { min: 2, max: 8 },
winCondition: { type: WIN.ALL_ESCAPE }
winCondition: { type: WIN.ALL_ESCAPE },
metadata: { description: 'Work together until every surviving player escapes.' }
})
};
@@ -49,10 +54,30 @@ function getRuleset(id, overrides = null) {
function listRulesets() {
return Object.values(BUILTINS).map(r => ({
id: r.id, label: r.label, mode: r.mode,
description: r.metadata.description || '',
players: r.players, winCondition: r.winCondition.type, pvpCombat: r.entities.pvpCombat
}));
}
/**
* Match-mode catalog. `solo-classic` describes the separate solo engine and must never be
* selected for a multiplayer queue. Keeping this filter beside the registry gives the server,
* scheduler, and clients one stable allowlist.
*/
function listMatchRulesets() {
return listRulesets().filter(r => r.mode !== 'solo');
}
/**
* Resolve an operator-selected multiplayer ruleset. Unknown/solo ids fail closed to the classic
* race, preserving the historical behavior and preventing arbitrary client-authored specs.
*/
function resolveMatchRuleset(id) {
const key = typeof id === 'string' ? id.trim() : '';
const candidate = BUILTINS[key];
return candidate && candidate.mode !== 'solo' ? candidate : BUILTINS.race;
}
/**
* Map the legacy MatchRoom option bag ({economy, variant, difficultyPreset, maxPlayers}) to a
* ruleset that preserves the current race behavior — win condition stays first-to-exit regardless
@@ -71,4 +96,11 @@ function rulesetFromMatchOpts(opts = {}) {
});
}
module.exports = { BUILTINS, getRuleset, listRulesets, rulesetFromMatchOpts };
module.exports = {
BUILTINS,
getRuleset,
listRulesets,
listMatchRulesets,
resolveMatchRuleset,
rulesetFromMatchOpts
};
+1 -1
View File
@@ -4,7 +4,7 @@
*
* onExit(room, id) — a player reached the exit. Decide whether that ends the match / sets a winner.
* onDeath(room) — a player just died. Decide whether the match is now over.
* rank(room) — final ordering (best-first) used by finalize() for placement + winnerId.
* rank(room) — final ordering used for placement and competitive winnerId.
*
* FIRST_TO_EXIT reproduces the classic race behavior byte-for-byte (so existing matches are
* unchanged); LAST_ALIVE and HIGH_SCORE are the new PvP / score modes.
+472 -200
View File
@@ -1,44 +1,71 @@
// Load .env before reading any process setting (notably TRUST_PROXY). systemd's
// EnvironmentFile masked this ordering bug in production, while `npm run dev` did not.
require('dotenv').config();
const express = require('express');
const path = require('path');
const crypto = require('crypto');
const { createSocketOriginAllowRequest } = require('./network/socketOriginPolicy');
var app = express();
app.disable('x-powered-by');
// Trust the reverse proxy (for correct req.ip / X-Forwarded-For handling) only when
// explicitly enabled, so client IPs can't be spoofed in a direct-exposure deployment.
if (process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', true);
// Trust only the configured number of closest proxy hops. `true` trusts every value in
// X-Forwarded-For, allowing a client-supplied leftmost address to spoof req.ip. NPM is one
// hop in the production topology; multi-proxy deployments must opt in explicitly.
const trustedProxyHops = Math.max(1, Math.min(8, parseInt(process.env.TRUST_PROXY_HOPS, 10) || 1));
app.set('trust proxy', trustedProxyHops);
}
var http = require('http').Server(app);
var io = require('socket.io')(http);
// Load environment configuration
require('dotenv').config();
var io = require('socket.io')(http, {
allowRequest: createSocketOriginAllowRequest(process.env)
});
// Import payment system components
const DatabaseManager = require('./db/databaseManager');
const WalletRPCService = require('./payments/walletRPCService');
const PayoutRetryService = require('./payments/payoutRetryService');
const AlertService = require('./services/alertService');
const HealthService = require('./services/healthService');
const LatePaymentReconciler = require('./services/latePaymentReconciler');
const GameModeManager = require('./game/gameModeManager');
const RpcService = require('./rpc/rpcService');
const PaymentConfigManager = require('./config/paymentConfig');
const EnvironmentValidator = require('./config/environmentValidator');
const RuntimePolicy = require('./config/runtimePolicy');
const asyncHandler = require('./middleware/asyncHandler');
const createErrorMiddleware = require('./middleware/errorHandler');
const noStore = require('./middleware/noStore');
const { createCanaryDatabaseIdentityHandler } = require('./services/canaryDatabaseIdentity');
const { AppError, ValidationError, NotFoundError } = require('./utils/errors');
const money = require('./money/atomic');
// Import modular components
const BroadcastManager = require('./network/broadcastManager');
const DebugManager = require('./debug/debugManager');
const SocketHandlers = require('./network/socketHandlers');
const { verifyGame, hashSeed } = require('./game/provablyFair');
const { verifyGame, verifyGameProof } = require('./game/provablyFair');
const { attachDungeonVerification } = require('./game/fairnessVerifier');
const { renderVerifyPage } = require('./views/verifyPage');
const { renderPrivacy, renderResponsiblePlay, renderTerms } = require('./views/legalPages');
const { buildCommerceDisclosure } = require('./config/commerceDisclosurePolicy');
const createAdminRoutes = require('./routes/admin');
const createAuthRoutes = require('./routes/auth');
const { isSmirkEnabled } = require('./auth/smirkPolicy');
const {
normalizeLeaderboardPeriod,
queryPrestigeLeaderboard
} = require('./network/prestigeLeaderboard');
// Initialize payment configuration
const paymentConfigManager = new PaymentConfigManager({ logger: console });
const environmentValidator = new EnvironmentValidator({ logger: console });
environmentValidator.validate(paymentConfigManager.getConfig());
try {
environmentValidator.assertValid(paymentConfigManager.getConfig());
} catch (error) {
console.error(`❌ Startup aborted: ${error.message}`);
process.exit(1);
}
process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode();
// Initialize modular components first
@@ -49,19 +76,65 @@ broadcastManager.setDebugManager(debugManager);
// Initialize payment system components (debugManager is now available)
const databaseManager = new DatabaseManager();
const db = databaseManager; // Alias for convenience in API endpoints
const rpcService = new RpcService();
const walletRPCService = new WalletRPCService(debugManager);
const walletRPCService = new WalletRPCService(debugManager, {
minConfirmations: paymentConfigManager.getConfig().payouts.processing.confirmations
});
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
gameModeManager.io = io;
// One effective payout gate used by every dispatcher. PAYOUTS_ENABLED is the master switch;
// per-mode flags cannot turn dispatch back on underneath it.
function isPayoutProcessingEnabled() {
return RuntimePolicy.isPayoutProcessingEnabled(
paymentConfigManager.getConfig(),
gameModeManager,
{ settleAcceptedLiabilities: true }
);
}
// Set only after startup has authenticated and probed the wallet. Every dispatch path also
// rechecks live health so a later outage cannot keep sending from a stale startup result.
let payoutDispatchReady = false;
// Readiness, paid admission, and every payout dispatcher stay closed until all durable startup
// recovery passes have completed without an unresolved financial row.
let financialRecoveryReady = false;
function isFinancialRecoveryReady() {
return financialRecoveryReady
&& socketHandlers?.isFinancialRecoveryReady?.() === true;
}
function canDispatchPayouts() {
return isFinancialRecoveryReady()
&& payoutDispatchReady
&& walletRPCService.getHealthStatus().healthy === true
&& isPayoutProcessingEnabled();
}
// Match/solo completion can request the manager's debounced dispatcher directly. Give that
// internal path the same startup, liveness, and operator-policy predicate as timers/retries.
gameModeManager.payoutDispatchAllowed = canDispatchPayouts;
// Invoice creation and paid solo entry consume value too; close both whenever startup or
// periodic liability reconciliation is incomplete. Explicit free play remains available.
gameModeManager.financialAdmissionAllowed = isFinancialRecoveryReady;
// Last-line guard shared by every wallet caller, including manual refunds and provider adapters.
// Dispatchers still check earlier so they do not claim durable rows while the gate is closed.
walletRPCService.transferAllowed = canDispatchPayouts;
// Initialize remaining components
const activeGames = new Map(); // Maps socketId to Game objects
let alertService = null; // Initialized later when payment system starts
let payoutRetryService = null; // Initialized later when payment system starts
let batchPayoutInterval = null; // Initialized later when payment system starts
let paymentExpiryInterval = null; // Initialized later when payment system starts
let latePaymentReconciler = null;
let walletHealthInterval = null; // Keeps readiness tied to a live wallet, not startup state
let databaseHealthInterval = null; // Detects a database outage after startup
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
// Third-party renderer code executes with the same privileges as the game/payment UI. Keep CDN
// execution off by default; production operators must make the supply-chain tradeoff explicit.
const rendererCdnEnabled = /^true$/i.test(String(process.env.RENDERER_CDN_ENABLED || 'false'));
if (rendererCdnEnabled && process.env.NODE_ENV === 'production') {
console.warn('⚠️ RENDERER_CDN_ENABLED=true: third-party jsDelivr code is trusted by the production CSP');
}
// Security headers (defense in depth alongside output escaping).
// CSP locks down where scripts/styles/connections may come from. Inline scripts/styles
// are still permitted ('unsafe-inline') because the current pages rely on them heavily;
@@ -69,24 +142,52 @@ const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, deb
// same-origin Socket.IO websockets under CSP Level 3. blob: is limited to render-kit
// GLB texture decoding; GLTFLoader turns embedded textures into blob URLs.
app.use((req, res, next) => {
res.setHeader('Content-Security-Policy', [
const scriptSources = ["'self'", "'unsafe-inline'"];
const connectSources = ["'self'", 'blob:', 'https://smirk.cash'];
if (rendererCdnEnabled) {
scriptSources.push('https://cdn.jsdelivr.net');
connectSources.push('https://cdn.jsdelivr.net');
}
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.socket.io https://cdn.jsdelivr.net",
`script-src ${scriptSources.join(' ')}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self' blob: https://smirk.cash https://cdn.socket.io https://cdn.jsdelivr.net",
`connect-src ${connectSources.join(' ')}`,
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'self'",
"form-action 'self'"
].join('; '));
];
if (process.env.NODE_ENV === 'production') csp.push('upgrade-insecure-requests');
res.setHeader('Content-Security-Policy', csp.join('; '));
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin-allow-popups');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
res.setHeader('Origin-Agent-Cluster', '?1');
if (process.env.NODE_ENV === 'production') {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
// These surfaces return bearer-authenticated account, payment, or operator data. Tell both
// browsers and reverse proxies never to retain a response, including errors.
app.use(['/api/user', '/api/auth', '/api/admin', '/api/payment'], noStore);
// Same-origin, non-secret browser policy. Loading this before renderModes.js prevents an optional
// renderer selection from even attempting a third-party fetch when CDN execution is disabled.
app.get('/runtime-config.js', (req, res) => {
res.setHeader('Cache-Control', 'no-store, max-age=0');
res.type('application/javascript').send(
`window.WOWNGEON_RUNTIME=Object.freeze({rendererCdnEnabled:${rendererCdnEnabled}});`
);
});
// Configure static file serving
const htmlPath = path.join(__dirname, '../html');
app.use(express.static(htmlPath, {
@@ -105,6 +206,41 @@ app.get('/', function(req, res) {
res.sendFile('index.html', { root: htmlPath });
});
function currentCommerceDisclosure() {
return buildCommerceDisclosure(gameModeManager, process.env);
}
// These pages intentionally describe the active server instead of claiming every deployment has
// the same money rules. They are product disclosures, not a substitute for operator legal review.
app.get('/terms', function(req, res) {
res.setHeader('Cache-Control', 'no-cache');
res.type('html').send(renderTerms(currentCommerceDisclosure()));
});
app.get('/privacy', function(req, res) {
res.setHeader('Cache-Control', 'no-cache');
res.type('html').send(renderPrivacy(currentCommerceDisclosure()));
});
app.get('/responsible-play', function(req, res) {
res.setHeader('Cache-Control', 'no-cache');
res.type('html').send(renderResponsiblePlay(currentCommerceDisclosure()));
});
app.get('/api/disclosures', noStore, function(req, res) {
res.json(currentCommerceDisclosure());
});
// This route is absent unless an isolated XMR stagenet canary opts in with a protected one-run
// nonce. It lets the financial harness bind the application process to the exact PostgreSQL
// database it independently audited before any value-bearing action.
const canaryDatabaseIdentityHandler = createCanaryDatabaseIdentityHandler({
db: databaseManager,
env: process.env
});
if (canaryDatabaseIdentityHandler) {
app.get('/api/canary/database-identity', noStore,
asyncHandler(canaryDatabaseIdentityHandler));
}
// Human-friendly tavern route. The static file remains /tavern.html for direct asset serving.
app.get(['/tavern', '/tavern/'], function(req, res) {
res.sendFile('tavern.html', { root: htmlPath });
@@ -187,15 +323,16 @@ app.use('/api/user', userApiRateLimit);
// C2: session-ownership guard for /api/user/:socketId/*. The caller must present the session
// token (users.anon_token — emitted to the client as the 'session_token'/'session_resumed'
// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header
// (fallback ?t=). We only proceed for the row whose socket_id AND anon_token both match, so
// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header.
// Tokens are never accepted in URLs because reverse proxies and browser history log them.
// We only proceed for the row whose socket_id AND anon_token both match, so
// one client can't read or mutate another player's payments/payouts/address. failStatus is
// 403 for mutations, 401 for reads.
function requireSessionOwnership(failStatus) {
return async (req, res, next) => {
try {
const { socketId } = req.params;
const token = req.get('X-Session-Token') || req.query.t;
const token = req.get('X-Session-Token');
if (!socketId || !token) {
return res.status(failStatus).json({ error: 'Session token required' });
}
@@ -290,20 +427,15 @@ app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandl
});
}
// Basic address validation (XMR/WOW addresses)
const ADDRESS_REGEX = /((?:4|8)[1-9A-HJ-NP-Za-km-z]{90,110}|(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})/;
if (!ADDRESS_REGEX.test(address.trim())) {
throw new ValidationError('Invalid address format', {
safeMessage: 'The provided address does not appear to be a valid XMR/WOW address.'
});
}
try {
const success = await gameModeManager.setUserPayoutAddress(socketId, address.trim());
// Keep REST and Socket.IO on the same network-aware validation path. AddressManager checks
// XMR prefixes for mainnet/stagenet/testnet, then asks wallet-rpc to verify checksum/nettype
// before persistence.
const success = await socketHandlers.addressManager.saveAddress(socketId, address, { autoConfirm: true });
if (!success) {
throw new AppError('Failed to save address', {
statusCode: 500,
safeMessage: 'Unable to save payout address.'
statusCode: 400,
safeMessage: 'That address is not valid for this server network.'
});
}
res.json({
@@ -322,87 +454,46 @@ app.post('/api/user/:socketId/address', requireSessionOwnership(403), asyncHandl
}
}));
// Health check endpoint - enhanced for monitoring dashboard
app.get('/health', async (req, res) => {
const memUsage = process.memoryUsage();
// Get wallet balance - always attempt
let walletBalance = null;
let lowBalanceWarning = false;
const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default
try {
walletBalance = await walletRPCService.getBalance();
if (walletBalance.error) {
console.warn('Wallet balance returned error:', walletBalance.error);
} else {
lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold;
}
} catch (e) {
console.error('Failed to get wallet balance for health check:', e.message);
walletBalance = { balance: 0, unlocked_balance: 0, error: e.message };
}
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
nodeVersion: process.version,
environment: process.env.NODE_ENV || 'development',
// Memory details
memory: {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
rss: memUsage.rss,
external: memUsage.external || 0
},
// Game statistics
games: {
active: activeGames.size,
queued: socketHandlers?.queueManager?.getQueueLength?.() || 0,
connected: io.sockets.sockets.size || 0,
mode: gameModeManager.gameMode
},
// Blockchain
function publicHealthSnapshot() {
const walletHealth = walletRPCService.getHealthStatus();
return HealthService.buildPublicHealth({
databaseReady: databaseManager.isHealthy(),
blockHeight: debugManager.getCurrentBlockHeight(),
blockSource: process.env.BLOCK_SOURCE || 'daemon',
simulatedBlocks: debugManager.SIMULATED_BLOCKS,
chainHealthy: debugManager.isChainHealthy?.(),
blockSource: process.env.BLOCK_SOURCE || (debugManager.SIMULATED_BLOCKS ? 'simulated' : 'daemon'),
network: process.env.MONERO_NETWORK || 'mainnet',
// Wallet status
wallet: {
available: gameModeManager.paymentsEnabled,
status: walletRPCService.isHealthy ? 'connected' : 'disconnected',
endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null,
pendingPayouts: 0,
balance: walletBalance,
lowBalanceWarning
},
// Rate limiter stats (if available)
rateLimiter: socketHandlers?.rateLimiter ? {
trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0,
blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0
} : null,
// Limits
limits: {
maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60,
maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100
},
// Legacy fields for compatibility
gameMode: gameModeManager.gameMode,
paymentsEnabled: gameModeManager.paymentsEnabled,
directModeEnabled: gameModeManager.directModeEnabled,
creditsModeEnabled: gameModeManager.creditsModeEnabled,
walletHealthy: walletRPCService.isHealthy,
payoutsEnabled: isPayoutProcessingEnabled(),
walletHealthy: walletHealth.healthy,
identityRequired: process.env.NODE_ENV === 'production',
daemonIdentity: debugManager.getChainIdentity(),
walletIdentity: walletHealth.identity,
financialRecoveryReady: isFinancialRecoveryReady(),
activeGames: activeGames.size,
debugMode: debugManager.getDebugStatus().debugMode
};
res.json(health);
queuedGames: socketHandlers?.queueManager?.getQueueLength?.() || 0,
connectedPlayers: io.sockets.sockets.size || 0,
gameMode: gameModeManager.gameMode,
uptime: process.uptime()
});
}
// Public probes intentionally omit balances, RPC addresses, secrets, memory details, and abuse
// thresholds. Those remain available through the authenticated admin stats endpoints.
app.get('/health', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json(publicHealthSnapshot());
});
app.get('/health/live', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() });
});
app.get('/health/ready', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
const health = publicHealthSnapshot();
res.status(health.ready ? 200 : 503).json(health);
});
// Get payment options for a user (mixed mode support)
@@ -467,7 +558,7 @@ app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandl
[user.id]
);
const totalPaid = parseInt(countResult.rows[0].total_paid, 10) || 0;
const totalPaid = money.toSafe(money.toBig(countResult.rows[0].total_paid || 0));
res.json({
payments: result.rows.map(row => ({
@@ -558,7 +649,7 @@ app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandle
gameOutcome: row.game_outcome
})),
total: parseInt(countResult.rows[0].total, 10),
totalReceived: parseInt(totalResult.rows[0].total_received, 10),
totalReceived: money.toSafe(money.toBig(totalResult.rows[0].total_received || 0)),
totalReceivedFormatted: gameModeManager.formatAtomicHuman(totalResult.rows[0].total_received, 4),
limit,
offset,
@@ -578,25 +669,38 @@ app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandle
// =============================================================================
// Smirk is a browser extension wallet for Wownero/Monero (mainnet only).
// Forced off on any test network (stagenet/testnet), regardless of SMIRK_ENABLED.
const { isTestNetworkFor } = require('./game/helpers/gameModeUtils');
const smirkEnabled = process.env.SMIRK_ENABLED !== 'false' && !isTestNetworkFor(process.env.MONERO_NETWORK);
const smirkEnabled = isSmirkEnabled(process.env);
if (smirkEnabled) {
app.use(createAuthRoutes({ db: databaseManager }));
app.use(createAuthRoutes({
db: databaseManager,
sessionManager: socketHandlers.sessionManager
}));
}
// =============================================================================
// Admin API Endpoints (see src/routes/admin.js) — DI ctx; alertService is set later
// =============================================================================
const adminRouteCtx = { db, gameModeManager, walletRPCService, socketHandlers, io, alertService: null };
const adminRouteCtx = {
db,
gameModeManager,
walletRPCService,
socketHandlers,
io,
alertService: null,
canDispatchPayouts
};
app.use(createAdminRoutes(adminRouteCtx));
// =============================================================================
// LEADERBOARD
// =============================================================================
app.get('/api/leaderboard', asyncHandler(async (req, res) => {
const period = req.query.period || 'all';
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
const period = normalizeLeaderboardPeriod(req.query.period);
const requestedLimit = Number.parseInt(req.query.limit, 10);
const limit = Number.isFinite(requestedLimit)
? Math.max(1, Math.min(requestedLimit, 50))
: 20;
let timeFilter = '';
if (period === 'week') timeFilter = "AND g.completed_at > NOW() - INTERVAL '7 days'";
@@ -613,17 +717,7 @@ app.get('/api/leaderboard', asyncHandler(async (req, res) => {
else if (board === 'pleb') boardFilter = "AND g.game_mode = 'FREE'";
if (board === 'prestige') {
const result = await db.query(`
SELECT
user_id as id,
COALESCE(display_name, 'Anon#' || user_id) as name,
best_score,
wins,
races as games_played
FROM prestige_leaderboard
ORDER BY best_score DESC
LIMIT $1
`, [limit]);
const result = await queryPrestigeLeaderboard(db, { period, limit });
return res.json({ leaderboard: result.rows, period, board });
}
@@ -664,7 +758,6 @@ app.get('/api/stats', asyncHandler(async (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
const currencyLabel = gameModeManager.currencyLabel || config.currency?.symbol || 'WOW';
let gamesToday = 0, escapesToday = 0, totalPaidOut = 0;
@@ -678,8 +771,9 @@ app.get('/api/stats', asyncHandler(async (req, res) => {
]);
gamesToday = parseInt(games.rows[0]?.games || 0, 10);
escapesToday = parseInt(games.rows[0]?.escapes || 0, 10);
// amount is atomic units (BIGINT); divide for display. Number() is safe at these magnitudes.
totalPaidOut = Number(payouts.rows[0]?.total || 0) / divisor;
// Keep the aggregate exact across PostgreSQL BIGINT -> JSON. It is a decimal string in
// whole currency units; browsers can display/coerce it without losing server-side units.
totalPaidOut = money.format(payouts.rows[0]?.total || 0, decimals);
} catch (e) {
// Best-effort; serve whatever we have rather than 500 on a public widget.
}
@@ -690,7 +784,7 @@ app.get('/api/stats', asyncHandler(async (req, res) => {
totalPaidOut,
currencyLabel,
// Paid out is only meaningful where payouts are enabled (stagenet / payout instances).
payoutsEnabled: !!(gameModeManager.directPayoutEnabled || gameModeManager.creditsPayoutEnabled)
payoutsEnabled: isPayoutProcessingEnabled()
};
_statsCache = { at: now, data };
res.json({ ...data, online });
@@ -699,13 +793,15 @@ app.get('/api/stats', asyncHandler(async (req, res) => {
app.get('/api/game-modes', (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
const divisor = Number.isFinite(decimals) ? Math.pow(10, decimals) : 1;
const toDisplay = (value) => {
if (value === null || value === undefined) {
return 0;
}
const numeric = typeof value === 'bigint' ? Number(value) : Number(value);
return Number.isFinite(numeric) ? numeric / divisor : 0;
try {
return money.format(value, decimals);
} catch (_) {
return 0;
}
};
const directMode = config.modes.direct;
@@ -714,18 +810,17 @@ app.get('/api/game-modes', (req, res) => {
// Include hosted by info if configured
let hostedBy = null;
if (process.env.HOSTED_BY) {
let hostname = process.env.HOSTED_BY_NAME;
if (!hostname) {
try {
hostname = new URL(process.env.HOSTED_BY).hostname;
} catch (e) {
hostname = process.env.HOSTED_BY; // Use raw value if URL parsing fails
try {
const hostedUrl = new URL(process.env.HOSTED_BY);
if (hostedUrl.protocol === 'https:' || hostedUrl.protocol === 'http:') {
hostedBy = {
url: hostedUrl.href,
name: process.env.HOSTED_BY_NAME || hostedUrl.hostname
};
}
} catch (_) {
// Environment validation reports malformed production values. Never reflect an unsafe URL.
}
hostedBy = {
url: process.env.HOSTED_BY,
name: hostname
};
}
res.json({
@@ -742,8 +837,8 @@ app.get('/api/game-modes', (req, res) => {
name: 'Paid Single Game',
cost: toDisplay(directMode.price),
enabled: !!directMode.enabled,
payoutMultiplier: (directMode.enabled && config.payouts.rules.direct.enabled)
? config.payouts.rules.direct.multipliers
payoutMultiplier: (isPayoutProcessingEnabled() && directMode.enabled && config.payouts.rules.direct.enabled)
? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_SINGLE')
: 0
},
PAID_CREDITS: {
@@ -751,10 +846,11 @@ app.get('/api/game-modes', (req, res) => {
cost: toDisplay(creditsMode.packages?.[0]?.price ?? 0),
credits: creditsMode.packages?.[0]?.credits ?? creditsMode.creditsPerGame,
enabled: !!creditsMode.enabled,
payoutMultiplier: creditsMode.enabled && config.payouts.rules.credits.enabled
? config.payouts.rules.credits.multipliers
payoutMultiplier: isPayoutProcessingEnabled() && creditsMode.enabled && config.payouts.rules.credits.enabled
? gameModeManager.getImplementedPayoutMultipliersForMode('PAID_CREDITS')
: 0
},
rendererCdnEnabled,
hostedBy
});
});
@@ -776,7 +872,12 @@ app.get('/verify/:gameId', asyncHandler(async (req, res) => {
let gameRecord = null;
try {
const result = await databaseManager.query(
'SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at FROM games WHERE dungeon_seed = $1',
`SELECT dungeon_seed, status, treasure_found, moves_made, duration_seconds, created_at,
completed_at, proof_version, fairness_offer_id, fairness_offer_issued_at,
proof_commitment, client_seed, effective_seed, layout_fingerprint,
layout_fingerprints, generator_version, proof_context,
CASE WHEN completed_at IS NOT NULL THEN server_seed ELSE NULL END AS server_seed
FROM games WHERE dungeon_seed = $1`,
[gameId]
);
if (result.rows.length > 0) {
@@ -798,9 +899,59 @@ app.get('/verify/:gameId', asyncHandler(async (req, res) => {
}));
}));
// Authoritative one-click verification by game UUID. Active games never reveal serverSeed.
app.get('/api/verify/:gameId', asyncHandler(async (req, res) => {
const proofResult = await databaseManager.query(
`SELECT dungeon_seed, status, completed_at, proof_version, fairness_offer_id,
fairness_offer_issued_at, proof_commitment, server_seed, client_seed,
effective_seed, layout_fingerprint, layout_fingerprints,
generator_version, proof_context
FROM games WHERE dungeon_seed = $1 LIMIT 1`,
[req.params.gameId]
);
const record = proofResult.rows[0];
if (!record) throw new NotFoundError('Game proof not found', { safeMessage: 'Game proof not found.' });
if (!record.completed_at) {
return res.status(409).json({ valid: false, error: 'Proof is revealed only after the game ends.' });
}
if (!record.server_seed || !record.proof_commitment || !record.effective_seed) {
return res.status(422).json({ valid: false, error: 'This legacy game does not have a complete v2 proof.' });
}
const result = verifyGameProof({
serverSeed: record.server_seed.trim(),
clientSeed: record.client_seed || '',
effectiveSeed: record.effective_seed.trim(),
commitment: record.proof_commitment.trim()
});
attachDungeonVerification(result, {
effectiveSeed: record.effective_seed.trim(),
proofContext: record.proof_context,
expectedFingerprint: record.layout_fingerprint?.trim(),
expectedFingerprints: record.layout_fingerprints,
generatorVersion: record.generator_version
});
result.gameId = record.dungeon_seed;
result.proofVersion = record.proof_version;
result.offerId = record.fairness_offer_id;
result.offerIssuedAt = record.fairness_offer_issued_at;
res.json(result);
}));
// API endpoint for programmatic verification
app.get('/api/verify', (req, res) => {
const { seed, commitment } = req.query;
const { seed, serverSeed, clientSeed, effectiveSeed, commitment } = req.query;
// V2: verify the server's precommit AND the client-derived effective dungeon seed.
if (serverSeed || effectiveSeed || clientSeed !== undefined) {
if (!serverSeed || !effectiveSeed || !commitment) {
return res.status(400).json({
valid: false,
error: 'Missing required parameters: serverSeed, effectiveSeed, and commitment'
});
}
return res.json(verifyGameProof({ serverSeed, clientSeed: clientSeed || '', effectiveSeed, commitment }));
}
if (!seed || !commitment) {
return res.status(400).json({
@@ -811,7 +962,7 @@ app.get('/api/verify', (req, res) => {
const result = verifyGame(seed, commitment);
// PROVABLY FAIR (Phase 0.2): when the seed matches the commitment, regenerate the
// Legacy v1 path: when the seed matches the commitment, regenerate the
// dungeon from the seed and return a layout fingerprint so the player can confirm
// the layout is a deterministic function of the committed seed (not crafted after payment).
if (result.valid) {
@@ -839,41 +990,110 @@ debugManager.onNewBlockCallback((blockHeight) => {
Promise.resolve(socketHandlers.checkGamesTimeout(blockHeight)).catch(err => {
console.error('❌ checkGamesTimeout error:', err.message);
});
// Flush any pending payouts (safety net for batch timer)
gameModeManager._processPendingPayouts().catch(err => {
console.error('❌ Block-triggered payout processing error:', err.message);
});
// Flush pending payouts only when the master switch and at least one per-mode policy are on.
if (canDispatchPayouts()) {
gameModeManager._processPendingPayouts().catch(err => {
console.error('❌ Block-triggered payout processing error:', err.message);
});
}
});
// Initialize payment system
async function initializePaymentSystem() {
try {
console.log('🔧 Initializing payment system...');
const walletInitialized = await walletRPCService.initialize();
if (!walletInitialized) {
console.log('⚠️ Wallet RPC not available - falling back to FREE mode');
process.env.GAME_MODE = 'FREE';
paymentConfigManager.refresh();
gameModeManager.setLegacyGameMode('FREE');
gameModeManager.paymentsEnabled = false;
gameModeManager.directModeEnabled = false;
gameModeManager.creditsModeEnabled = false;
const production = process.env.NODE_ENV === 'production';
let paymentsRequested = Boolean(paymentConfigManager.getConfig().paymentsEnabled);
const walletRequired = RuntimePolicy.isWalletRequired(
paymentConfigManager.getConfig(),
gameModeManager,
{ settleAcceptedLiabilities: true }
);
let walletReady = false;
if (walletRequired) {
console.log('🔧 Initializing authenticated wallet boundary...');
walletReady = Boolean(await walletRPCService.initialize());
if (!walletReady) {
if (production) {
throw new Error('Wallet RPC is unavailable while payment intake or payout settlement is enabled.');
}
if (paymentsRequested) {
console.log('⚠️ Wallet RPC not available - falling back to FREE mode');
process.env.GAME_MODE = 'FREE';
paymentConfigManager.refresh();
gameModeManager.setLegacyGameMode('FREE');
gameModeManager.paymentsEnabled = false;
gameModeManager.directModeEnabled = false;
gameModeManager.creditsModeEnabled = false;
paymentsRequested = false;
} else {
console.log('⚠️ Wallet RPC not available - payout-only settlement remains stopped');
}
}
// A successful startup probe is not permanent health. Refresh the wallet liveness
// bit in the background so /health/ready turns degraded after an RPC outage.
let walletProbeRunning = false;
const requestedWalletProbeMs = Number(process.env.WALLET_HEALTH_INTERVAL_MS);
const walletProbeMs = Math.max(1000, Math.min(
Number.isFinite(requestedWalletProbeMs) && requestedWalletProbeMs > 0
? requestedWalletProbeMs
: 10000,
Math.max(1000, Math.floor(walletRPCService.identityMaxAgeMs / 2))
));
walletHealthInterval = setInterval(async () => {
if (walletProbeRunning) return;
walletProbeRunning = true;
try {
// A transport-only get_version response must never revive readiness. Recheck
// the wallet's validated chain/nettype identity on every liveness probe.
await walletRPCService.ensureNetworkIdentity({ force: true });
} catch (_) {
// ensureNetworkIdentity records isHealthy=false; readiness is fail-closed.
} finally {
walletProbeRunning = false;
}
}, walletProbeMs);
walletHealthInterval.unref?.();
} else {
console.log('ℹ️ Payments disabled; wallet RPC initialization skipped.');
}
// Initialize database connection and run migrations
await databaseManager.initialize();
if (!databaseManager.isConnected()) {
throw new Error('PostgreSQL is unavailable.');
}
console.log('✅ Database initialized successfully');
databaseHealthInterval = setInterval(() => {
databaseManager.healthCheck().catch?.(() => {});
}, Number(process.env.DB_HEALTH_INTERVAL_MS) || 15000);
databaseHealthInterval.unref?.();
// Test RPC service connectivity
const rpcHealth = await rpcService.healthCheck();
console.log(`✅ RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Warning - using fallback'}`);
// Probe the exact RpcService instance that drives block callbacks. A second independent
// client could pass startup while the polling instance points at the wrong chain.
const rpcHealth = debugManager.SIMULATED_BLOCKS
? { healthy: true, identity: debugManager.getChainIdentity() }
: await debugManager.rpcService.healthCheck();
if (production && (!rpcHealth.healthy || rpcHealth.identity?.verified !== true)) {
throw new Error('No configured blockchain daemon RPC has the expected chain/network identity.');
}
console.log(`${rpcHealth.healthy ? '✅' : '⚠️'} RPC Service: ${rpcHealth.healthy ? 'Connected' : 'Unavailable'}`);
console.log('🚀 Payment system ready!');
return true;
const paymentSystemReady = paymentsRequested && walletReady;
console.log(paymentSystemReady ? '🚀 Payment system ready!' : '🚀 Free-play services ready!');
return { paymentSystemReady, paymentsRequested, walletRequired, walletReady };
} catch (error) {
console.error('❌ Failed to initialize payment system:', error);
console.error('❌ Failed to initialize payment system:', error?.message || String(error));
if (process.env.NODE_ENV === 'production') {
throw error;
}
console.log('⚠️ Server will continue in FREE mode only');
return false;
return {
paymentSystemReady: false,
paymentsRequested: false,
walletRequired: false,
walletReady: false
};
}
}
@@ -901,19 +1121,23 @@ setInterval(debugRegisteredUsers, 10000);
async function startServer() {
try {
// Initialize payment system first
const paymentSystemReady = await initializePaymentSystem();
const financialRuntime = await initializePaymentSystem();
const { paymentSystemReady } = financialRuntime;
// C6: right after payment/recovery init at startup, finalize/refund any games left
// status='active' by a previous crash or restart. recoverOrphanedGames() is idempotent
// so it is safe to run on every boot; failures here must not block server startup.
try {
const sm = socketHandlers.sessionManager;
if (sm && typeof sm.recoverOrphanedGames === 'function') {
await sm.recoverOrphanedGames();
}
} catch (recoverErr) {
console.error('❌ recoverOrphanedGames failed at startup:', recoverErr.message);
// No listener or paid dispatcher is enabled until every due financial row has been
// reconciled. Recovery is idempotent and throws FINANCIAL_RECOVERY_INCOMPLETE with a
// sanitized unresolved-row summary on either a scan or per-row failure.
const sm = socketHandlers.sessionManager;
if (!sm || typeof sm.recoverOrphanedGames !== 'function') {
throw new Error('Orphaned solo-game financial recovery is unavailable.');
}
await sm.recoverOrphanedGames();
// Match queue recovery and payout-liability reconciliation require the migrations that
// initializePaymentSystem() just applied. Await them before accepting any sockets.
await socketHandlers.initializeMatchMode();
financialRecoveryReady = true;
payoutDispatchReady = financialRuntime.walletReady && isPayoutProcessingEnabled();
// Socket.io connection handler - ONLY after payment system is ready
io.on('connection', function(socket) {
@@ -950,8 +1174,10 @@ async function startServer() {
console.log(`🎮 Available game modes: ${enabledModes.join(', ')}`);
});
// Start batch payout processing if payment system is ready
if (paymentSystemReady) {
// Payout dispatch/retry is intentionally independent from payment intake. A prestige
// instance may accept paid credits while PAYOUTS_ENABLED=false and must never start a
// worker capable of sending old queued rows.
if (canDispatchPayouts()) {
const payoutConfig = paymentConfigManager.getConfig().payouts?.processing || {};
const payoutIntervalSeconds = Math.max(1, Number(payoutConfig.batchInterval || 300));
const payoutIntervalMs = payoutIntervalSeconds * 1000;
@@ -959,7 +1185,7 @@ async function startServer() {
try {
await gameModeManager._processPendingPayouts();
} catch (error) {
console.error('Error in batch payout processing:', error);
console.error('Error in batch payout processing:', error?.message || String(error));
}
}, payoutIntervalMs);
@@ -984,7 +1210,8 @@ async function startServer() {
debugManager,
maxRetries,
retryIntervalMs,
alertService // Pass alert service for failure notifications
alertService, // Pass alert service for failure notifications
isEnabled: canDispatchPayouts
});
payoutRetryService.start();
@@ -992,9 +1219,25 @@ async function startServer() {
alertService.startPeriodicChecks(300000);
}
// Periodic job: expire old pending payments (every 5 minutes)
// Reconcile transfers that mined just after invoice expiry into durable manual review.
// This worker never confirms an invoice or grants an entitlement.
if (financialRuntime.walletReady) {
latePaymentReconciler = new LatePaymentReconciler({
db: databaseManager,
gameModeManager,
walletService: walletRPCService,
lookbackHours: process.env.LATE_PAYMENT_RECONCILE_HOURS,
batchSize: process.env.LATE_PAYMENT_RECONCILE_BATCH
});
latePaymentReconciler.runOnce().catch(error => {
console.error('[LatePayment] Startup reconciliation error:', error.message);
});
}
// Periodic job: reconcile, then expire old pending payments (every 5 minutes).
paymentExpiryInterval = setInterval(async () => {
try {
if (latePaymentReconciler) await latePaymentReconciler.runOnce();
const result = await databaseManager.query(`
UPDATE payments SET status = 'expired'
WHERE status = 'pending' AND expires_at < NOW()
@@ -1031,16 +1274,46 @@ app.use(createErrorMiddleware({ logger: console }));
startServer();
// Graceful shutdown — clean up timers, wait for in-flight payouts, close DB pool
async function gracefulShutdown(signal) {
let shutdownStarted = false;
async function gracefulShutdown(signal, exitCode = 0) {
if (shutdownStarted) return;
shutdownStarted = true;
payoutDispatchReady = false;
// Freeze solo input first. Any game-over callback already executing has synchronously
// installed its settlement intent; no new terminal result can appear after the drain sees an
// empty map.
socketHandlers?.beginShutdown?.();
console.log(`\n${signal} received. Shutting down gracefully...`);
// A broken dependency must not leave a corrupt process hanging forever. systemd will
// restart the service after this bounded drain window.
const forcedExit = setTimeout(() => {
console.error('Graceful shutdown timed out; forcing process exit.');
process.exit(exitCode || 1);
}, 15000);
forcedExit.unref?.();
// Stop accepting new connections
http.close();
// Stop scheduled timers
if (batchPayoutInterval) clearInterval(batchPayoutInterval);
if (paymentExpiryInterval) clearInterval(paymentExpiryInterval);
if (gameModeManager._batchPayoutTimer) clearTimeout(gameModeManager._batchPayoutTimer);
if (walletHealthInterval) clearInterval(walletHealthInterval);
if (databaseHealthInterval) clearInterval(databaseHealthInterval);
// Terminal solo games are held in a serialized settlement-pending state when their atomic
// completion transaction fails. Drain those retries before disabling GameModeManager or
// closing PostgreSQL so a normal deploy/restart cannot turn a known result into an orphan.
if (socketHandlers?.gameManager?.shutdown) {
const soloDrain = await socketHandlers.gameManager.shutdown({ timeoutMs: 4000 });
if (soloDrain.pending > 0) {
console.error(` ${soloDrain.pending} solo settlement(s) remain unresolved after shutdown drain`);
} else if (soloDrain.initial > 0) {
console.log(` Settled ${soloDrain.settled} terminal solo game(s) during shutdown drain`);
}
}
gameModeManager.shutdown();
if (payoutRetryService) payoutRetryService.stop();
if (alertService) alertService.stopPeriodicChecks?.();
@@ -1063,35 +1336,34 @@ async function gracefulShutdown(signal) {
}
console.log('Shutdown complete.');
process.exit(0);
clearTimeout(forcedExit);
process.exit(exitCode);
}
// Last-resort process guards. A single bad socket event or an unhandled promise rejection
// must NOT crash this process, because it holds the live wallet RPC session and in-flight
// payout/batch state — being killed mid-payout is a far worse failure than leaking one
// broken request. TRADEOFF: standard Node guidance is to exit on 'uncaughtException' (process
// state may be corrupt); here we deliberately stay up so payouts can settle and sockets stay
// alive, and we log (and alert, if the alert service is up) so nothing fails silently.
// Last-resort process guards. After an uncaught exception, Node process state is not trustworthy.
// Payout claims are durable/ambiguous-safe, so drain briefly and let the service manager restart.
process.on('uncaughtException', (err) => {
console.error('❌ uncaughtException (kept alive):', err && err.stack ? err.stack : err);
console.error('❌ uncaughtException (fatal):', err && err.stack ? err.stack : err);
try {
alertService?.sendAlert?.('process_uncaught_exception', {
subject: 'uncaughtException (server kept alive)',
subject: 'uncaughtException (server restarting)',
body: String(err && err.stack ? err.stack : err),
level: 'error'
})?.catch?.(() => {});
} catch (_) {}
gracefulShutdown('uncaughtException', 1).catch(() => process.exit(1));
});
process.on('unhandledRejection', (reason) => {
console.error('❌ unhandledRejection (kept alive):', reason && reason.stack ? reason.stack : reason);
console.error('❌ unhandledRejection (fatal):', reason && reason.stack ? reason.stack : reason);
try {
alertService?.sendAlert?.('process_unhandled_rejection', {
subject: 'unhandledRejection (server kept alive)',
subject: 'unhandledRejection (server restarting)',
body: String(reason && reason.stack ? reason.stack : reason),
level: 'error'
})?.catch?.(() => {});
} catch (_) {}
gracefulShutdown('unhandledRejection', 1).catch(() => process.exit(1));
});
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM', 0));
process.on('SIGINT', () => gracefulShutdown('SIGINT', 0));
+11
View File
@@ -0,0 +1,11 @@
'use strict';
/** Prevent browsers and intermediary caches from retaining authenticated API responses. */
function noStore(_req, res, next) {
res.setHeader('Cache-Control', 'no-store, max-age=0');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
next();
}
module.exports = noStore;
+2 -1
View File
@@ -6,7 +6,8 @@
-- Design notes:
-- - One fact, one table: matches, entrants, events, queue, and ticket ledger are separate.
-- - All money movement uses DatabaseManager.withTransaction() at the application layer.
-- - Provably fair: seed_hash stored at creation, seed revealed on finish (like solo games).
-- - Verifiable commitment: seed_hash stored at creation and seed revealed on finish. Production
-- paid matches additionally persist chain-block derivation metadata inside dungeon JSON.
-- - Tickets (race_entries) are non-refundable crypto-race entry tokens; they avoid the
-- need for on-chain refunds when a player leaves the queue before a match starts.
+10
View File
@@ -0,0 +1,10 @@
-- Persist the operator-selected gameplay ruleset independently from the coarse legacy
-- matches.variant ('race'/'pvp'). Existing rows are classic races; new built-ins such as
-- score-attack and coop-escape remain fully auditable without widening money/economy keys.
ALTER TABLE matches
ADD COLUMN IF NOT EXISTS ruleset_id VARCHAR(64) NOT NULL DEFAULT 'race';
CREATE INDEX IF NOT EXISTS idx_matches_ruleset_finished
ON matches (ruleset_id, ended_at DESC)
WHERE status = 'finished';
@@ -0,0 +1,22 @@
-- Durable two-party proof material for solo games. The server seed is stored privately for
-- crash/audit recovery but is exposed by verification routes only after the game completes.
ALTER TABLE games
ADD COLUMN IF NOT EXISTS proof_version SMALLINT NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS fairness_offer_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS fairness_offer_issued_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS proof_commitment CHAR(64),
ADD COLUMN IF NOT EXISTS server_seed CHAR(64),
ADD COLUMN IF NOT EXISTS client_seed VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS effective_seed CHAR(64),
ADD COLUMN IF NOT EXISTS layout_fingerprint CHAR(64),
ADD COLUMN IF NOT EXISTS proof_context JSONB,
ADD COLUMN IF NOT EXISTS proof_revealed_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS idx_games_fairness_offer_once
ON games (fairness_offer_id)
WHERE fairness_offer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_games_proof_commitment
ON games (proof_commitment)
WHERE proof_commitment IS NOT NULL;
@@ -0,0 +1,56 @@
-- Durable, idempotent admin payment refunds.
--
-- Only `requested` rows may be claimed by the application. A `processing` row means a
-- wallet call may have been made and must never be retried automatically: if the process
-- dies or loses the wallet response, an operator must reconcile it first.
CREATE TABLE IF NOT EXISTS payment_refunds (
id BIGSERIAL PRIMARY KEY,
payment_id INTEGER NOT NULL REFERENCES payments(id) ON DELETE RESTRICT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL,
amount BIGINT NOT NULL,
payout_address VARCHAR(200),
credits_deducted BIGINT NOT NULL DEFAULT 0,
reason TEXT NOT NULL,
tx_hash VARCHAR(128),
error_message TEXT,
requested_at TIMESTAMPTZ,
processing_started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
needs_review_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT payment_refunds_payment_once UNIQUE (payment_id),
CONSTRAINT payment_refunds_status_check
CHECK (status IN ('recorded', 'requested', 'processing', 'completed', 'needs_review')),
CONSTRAINT payment_refunds_amount_check CHECK (amount >= 0),
CONSTRAINT payment_refunds_credits_check CHECK (credits_deducted >= 0),
CONSTRAINT payment_refunds_state_shape_check CHECK (
(status = 'recorded')
OR (status = 'requested' AND requested_at IS NOT NULL)
OR (
status = 'processing'
AND requested_at IS NOT NULL
AND processing_started_at IS NOT NULL
)
OR (
status = 'completed'
AND requested_at IS NOT NULL
AND processing_started_at IS NOT NULL
AND completed_at IS NOT NULL
AND tx_hash IS NOT NULL
AND LENGTH(tx_hash) > 0
)
OR (
status = 'needs_review'
AND requested_at IS NOT NULL
AND error_message IS NOT NULL
AND LENGTH(error_message) > 0
)
)
);
CREATE INDEX IF NOT EXISTS idx_payment_refunds_status
ON payment_refunds(status, created_at);
@@ -0,0 +1,91 @@
-- Migration 032: immutable solo payout commitments and unconditional payout identity
--
-- A paid game's payout eligibility and exact terms are committed in the same transaction
-- that consumes its entry. Runtime kill switches may pause dispatch, but cannot erase or
-- retroactively create an already-committed liability.
ALTER TABLE games
ADD COLUMN IF NOT EXISTS payout_eligible BOOLEAN,
ADD COLUMN IF NOT EXISTS payout_terms JSONB,
ADD COLUMN IF NOT EXISTS payout_committed_at TIMESTAMPTZ;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'games_payout_commitment_complete'
) THEN
ALTER TABLE games
ADD CONSTRAINT games_payout_commitment_complete
CHECK (
payout_committed_at IS NULL
OR (payout_eligible IS NOT NULL AND payout_terms IS NOT NULL)
) NOT VALID;
END IF;
END $$;
CREATE OR REPLACE FUNCTION reject_game_payout_commitment_mutation()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.payout_committed_at IS NOT NULL AND (
NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.payout_eligible IS DISTINCT FROM OLD.payout_eligible
OR NEW.payout_terms IS DISTINCT FROM OLD.payout_terms
OR NEW.payout_committed_at IS DISTINCT FROM OLD.payout_committed_at
OR NEW.payout_escape_amount IS DISTINCT FROM OLD.payout_escape_amount
OR NEW.payout_treasure_amount IS DISTINCT FROM OLD.payout_treasure_amount
OR NEW.payout_escape_mult IS DISTINCT FROM OLD.payout_escape_mult
OR NEW.payout_treasure_mult IS DISTINCT FROM OLD.payout_treasure_mult
OR NEW.payout_address IS DISTINCT FROM OLD.payout_address
OR NEW.game_mode IS DISTINCT FROM OLD.game_mode
OR NEW.payment_id IS DISTINCT FROM OLD.payment_id
) THEN
RAISE EXCEPTION 'committed solo payout terms are immutable for game %', OLD.id
USING ERRCODE = '23514';
END IF;
IF OLD.payout_committed_at IS NULL
AND NEW.payout_committed_at IS NOT NULL
AND NEW.user_id IS DISTINCT FROM OLD.user_id
THEN
RAISE EXCEPTION 'solo payout owner cannot change while committing game %', OLD.id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_games_immutable_payout_commitment ON games;
CREATE TRIGGER trg_games_immutable_payout_commitment
BEFORE UPDATE ON games
FOR EACH ROW
EXECUTE FUNCTION reject_game_payout_commitment_mutation();
-- Refuse to weaken identity silently if historical duplicate obligations exist. Operators
-- must reconcile them explicitly before this migration can complete.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM payouts
WHERE game_id IS NOT NULL
GROUP BY game_id HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'Cannot enforce one payout per game: duplicate game_id obligations exist';
END IF;
IF EXISTS (
SELECT 1 FROM payouts
WHERE match_id IS NOT NULL
GROUP BY match_id HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'Cannot enforce one payout per match: duplicate match_id obligations exist';
END IF;
END $$;
-- Status changes (including failed/needs_review) never release the identity. A possibly
-- broadcast payout must not permit a replacement row for the same game or match.
DROP INDEX IF EXISTS idx_payouts_one_per_game;
CREATE UNIQUE INDEX idx_payouts_one_per_game
ON payouts(game_id)
WHERE game_id IS NOT NULL;
DROP INDEX IF EXISTS idx_payouts_one_per_match;
CREATE UNIQUE INDEX idx_payouts_one_per_match
ON payouts(match_id)
WHERE match_id IS NOT NULL;
@@ -0,0 +1,243 @@
-- Durable match payout terms.
--
-- A crypto match consumes escrow only after these fields are written in the same transaction.
-- They are an immutable acceptance snapshot: later configuration changes may stop new matches
-- or pause dispatch, but cannot erase or manufacture a liability for an already accepted match.
ALTER TABLE matches
ADD COLUMN IF NOT EXISTS payout_liability_amount_atomic BIGINT,
ADD COLUMN IF NOT EXISTS payout_liability_cap_atomic BIGINT,
ADD COLUMN IF NOT EXISTS payout_liability_terms JSONB,
ADD COLUMN IF NOT EXISTS payout_liability_accepted_at TIMESTAMPTZ;
-- Paid ticket lots preserve the accepted atomic backing for every future crypto entry. Fungible
-- user.race_entries remains the UI balance, but only a ticket with a funded lot can enter a crypto
-- payout match; legacy/admin-granted tickets remain usable only outside that liability path.
CREATE TABLE IF NOT EXISTS race_entry_lots (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
payment_id BIGINT NOT NULL REFERENCES payments(id) ON DELETE RESTRICT,
unit_value_atomic BIGINT NOT NULL CHECK (unit_value_atomic > 0),
original_entries INT NOT NULL CHECK (original_entries > 0),
remaining_entries INT NOT NULL CONSTRAINT race_entry_lots_remaining_within_original CHECK (
remaining_entries >= 0 AND remaining_entries <= original_entries
),
product_id VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (payment_id)
);
CREATE INDEX IF NOT EXISTS idx_race_entry_lots_user_available
ON race_entry_lots (user_id, created_at, id)
WHERE remaining_entries > 0;
-- The backing identity and accepted economics of a lot are append-only. Redemption/refund
-- workflows may only reduce remaining_entries (and later migrations may add terminal markers).
CREATE OR REPLACE FUNCTION reject_race_entry_lot_backing_mutation()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.payment_id IS DISTINCT FROM OLD.payment_id
OR NEW.unit_value_atomic IS DISTINCT FROM OLD.unit_value_atomic
OR NEW.original_entries IS DISTINCT FROM OLD.original_entries
OR NEW.product_id IS DISTINCT FROM OLD.product_id
OR NEW.created_at IS DISTINCT FROM OLD.created_at
THEN
RAISE EXCEPTION 'race entry lot backing is immutable for lot %', OLD.id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_race_entry_lot_immutable_backing ON race_entry_lots;
CREATE TRIGGER trg_race_entry_lot_immutable_backing
BEFORE UPDATE ON race_entry_lots
FOR EACH ROW
EXECUTE FUNCTION reject_race_entry_lot_backing_mutation();
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'race_entry_lots_remaining_within_original'
) THEN
ALTER TABLE race_entry_lots
ADD CONSTRAINT race_entry_lots_remaining_within_original
CHECK (remaining_entries >= 0 AND remaining_entries <= original_entries) NOT VALID;
END IF;
END $$;
-- One durable queue row is the idempotency anchor for one escrow hold/refund. `consumed` is a
-- terminal successful-start state; stale queue cleanup must never treat it as refundable merely
-- because time passed. The exact held amount survives config changes and concurrent re-queues.
ALTER TABLE match_queue_entries
ADD COLUMN IF NOT EXISTS escrow_amount BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS escrow_value_atomic BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS race_entry_lot_id BIGINT
REFERENCES race_entry_lots(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS consumed_at TIMESTAMPTZ;
ALTER TABLE match_queue_entries
DROP CONSTRAINT IF EXISTS match_queue_entries_status_check;
ALTER TABLE match_queue_entries
ADD CONSTRAINT match_queue_entries_status_check
CHECK (status IN ('queued', 'matched', 'consumed', 'cancelled')) NOT VALID;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'match_queue_entries_escrow_nonnegative'
) THEN
ALTER TABLE match_queue_entries
ADD CONSTRAINT match_queue_entries_escrow_nonnegative
CHECK (escrow_amount >= 0 AND escrow_value_atomic >= 0) NOT VALID;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'match_queue_committed_escrow_complete'
) THEN
ALTER TABLE match_queue_entries
ADD CONSTRAINT match_queue_committed_escrow_complete CHECK (
status NOT IN ('matched', 'consumed')
OR economy = 'free'
OR (economy = 'credits_prestige' AND escrow_amount > 0)
OR (
economy = 'crypto_race'
AND escrow_amount = 1
AND escrow_value_atomic > 0
AND race_entry_lot_id IS NOT NULL
)
) NOT VALID;
END IF;
END $$;
CREATE OR REPLACE FUNCTION reject_committed_match_queue_escrow_mutation()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IN ('matched', 'consumed', 'cancelled') AND (
NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.economy IS DISTINCT FROM OLD.economy
OR NEW.escrow_amount IS DISTINCT FROM OLD.escrow_amount
OR NEW.escrow_value_atomic IS DISTINCT FROM OLD.escrow_value_atomic
OR NEW.race_entry_lot_id IS DISTINCT FROM OLD.race_entry_lot_id
) THEN
RAISE EXCEPTION 'committed queue escrow is immutable for queue entry %', OLD.id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_match_queue_immutable_committed_escrow ON match_queue_entries;
CREATE TRIGGER trg_match_queue_immutable_committed_escrow
BEFORE UPDATE ON match_queue_entries
FOR EACH ROW
EXECUTE FUNCTION reject_committed_match_queue_escrow_mutation();
ALTER TABLE match_entrants
ADD COLUMN IF NOT EXISTS queue_entry_id BIGINT
REFERENCES match_queue_entries(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS entry_refunded_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS idx_match_entrants_queue_entry
ON match_entrants (queue_entry_id)
WHERE queue_entry_id IS NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'matches_payout_liability_amount_nonnegative'
) THEN
ALTER TABLE matches
ADD CONSTRAINT matches_payout_liability_amount_nonnegative
CHECK (payout_liability_amount_atomic IS NULL OR payout_liability_amount_atomic >= 0)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'matches_payout_liability_cap_positive'
) THEN
ALTER TABLE matches
ADD CONSTRAINT matches_payout_liability_cap_positive
CHECK (payout_liability_cap_atomic IS NULL OR payout_liability_cap_atomic > 0)
NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'matches_payout_liability_complete'
) THEN
ALTER TABLE matches
ADD CONSTRAINT matches_payout_liability_complete
CHECK (
payout_liability_accepted_at IS NULL
OR (
economy = 'crypto_race'
AND payout_liability_amount_atomic IS NOT NULL
AND payout_liability_amount_atomic > 0
AND payout_liability_cap_atomic IS NOT NULL
AND payout_liability_amount_atomic <= payout_liability_cap_atomic
AND payout_liability_terms IS NOT NULL
)
) NOT VALID;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'matches_payout_liability_economics_consistent'
) THEN
ALTER TABLE matches
ADD CONSTRAINT matches_payout_liability_economics_consistent
CHECK (
payout_liability_accepted_at IS NULL
OR (
entry_fee_atomic > 0
AND pot_atomic > 0
AND house_fee_atomic >= 0
AND house_fee_atomic < pot_atomic
AND payout_liability_amount_atomic = pot_atomic - house_fee_atomic
)
) NOT VALID;
END IF;
END $$;
CREATE OR REPLACE FUNCTION reject_match_payout_liability_mutation()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.payout_liability_accepted_at IS NOT NULL AND (
NEW.payout_liability_amount_atomic IS DISTINCT FROM OLD.payout_liability_amount_atomic
OR NEW.payout_liability_cap_atomic IS DISTINCT FROM OLD.payout_liability_cap_atomic
OR NEW.payout_liability_terms IS DISTINCT FROM OLD.payout_liability_terms
OR NEW.payout_liability_accepted_at IS DISTINCT FROM OLD.payout_liability_accepted_at
OR NEW.entry_fee_atomic IS DISTINCT FROM OLD.entry_fee_atomic
OR NEW.pot_atomic IS DISTINCT FROM OLD.pot_atomic
OR NEW.house_fee_atomic IS DISTINCT FROM OLD.house_fee_atomic
OR NEW.house_fee_percent IS DISTINCT FROM OLD.house_fee_percent
) THEN
RAISE EXCEPTION 'accepted match payout liability is immutable for match %', OLD.id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_matches_immutable_payout_liability ON matches;
CREATE TRIGGER trg_matches_immutable_payout_liability
BEFORE UPDATE ON matches
FOR EACH ROW
EXECUTE FUNCTION reject_match_payout_liability_mutation();
CREATE INDEX IF NOT EXISTS idx_matches_unsettled_payout_liability
ON matches (ended_at, id)
WHERE economy = 'crypto_race'
AND status = 'finished'
AND winner_user_id IS NOT NULL
AND payout_liability_accepted_at IS NOT NULL;
@@ -0,0 +1,19 @@
-- Migration 034: versioned, all-depth solo fairness manifest
--
-- `layout_fingerprint` remains the legacy level-one alias. New games persist one
-- generator/fingerprint-versioned entry for every depth advertised at game creation.
ALTER TABLE games
ADD COLUMN IF NOT EXISTS generator_version VARCHAR(64),
ADD COLUMN IF NOT EXISTS layout_fingerprints JSONB;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'games_layout_fingerprints_array_check'
) THEN
ALTER TABLE games
ADD CONSTRAINT games_layout_fingerprints_array_check
CHECK (layout_fingerprints IS NULL OR jsonb_typeof(layout_fingerprints) = 'array')
NOT VALID;
END IF;
END $$;
@@ -0,0 +1,262 @@
-- Durable payment entitlements, entry-consumption evidence, and payment-bound fairness.
--
-- A confirmed invoice must never become claimable again after it has been converted into
-- another entitlement. Product refunds need an exact, payment-scoped grant record so every
-- reversible benefit can be locked and revoked before any wallet transfer is attempted.
CREATE TABLE IF NOT EXISTS payment_entitlement_grants (
payment_id INTEGER PRIMARY KEY REFERENCES payments(id) ON DELETE RESTRICT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
source VARCHAR(40) NOT NULL,
credits_granted BIGINT NOT NULL DEFAULT 0 CHECK (credits_granted >= 0),
purchase_progress_granted BIGINT NOT NULL DEFAULT 0 CHECK (purchase_progress_granted >= 0),
race_entries_granted INTEGER NOT NULL DEFAULT 0 CHECK (race_entries_granted >= 0),
packs_granted JSONB NOT NULL DEFAULT '[]'::jsonb,
premium_level_granted VARCHAR(32),
premium_level_previous VARCHAR(32),
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'reversed', 'needs_review')),
credits_reversed BIGINT NOT NULL DEFAULT 0 CHECK (credits_reversed >= 0),
purchase_progress_reversed BIGINT NOT NULL DEFAULT 0
CHECK (purchase_progress_reversed >= 0),
race_entries_reversed INTEGER NOT NULL DEFAULT 0 CHECK (race_entries_reversed >= 0),
packs_reversed JSONB NOT NULL DEFAULT '[]'::jsonb,
reversal_reason TEXT,
reversed_at TIMESTAMPTZ,
needs_review_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT payment_entitlement_packs_array
CHECK (jsonb_typeof(packs_granted) = 'array' AND jsonb_typeof(packs_reversed) = 'array'),
CONSTRAINT payment_entitlement_reversal_bounds
CHECK (credits_reversed <= credits_granted
AND purchase_progress_reversed <= purchase_progress_granted
AND race_entries_reversed <= race_entries_granted),
CONSTRAINT payment_entitlement_state_shape CHECK (
(status = 'active' AND reversed_at IS NULL)
OR (status = 'reversed' AND reversed_at IS NOT NULL)
OR (status = 'needs_review' AND needs_review_at IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_payment_entitlement_user
ON payment_entitlement_grants(user_id, created_at DESC);
-- Tie fungible ledgers to their invoice. Partial indexes are final idempotency backstops in
-- addition to the payment/refund row locks used by the application.
ALTER TABLE credit_transactions
ADD COLUMN IF NOT EXISTS payment_id INTEGER REFERENCES payments(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_tx_payment_grant_once
ON credit_transactions(payment_id)
WHERE payment_id IS NOT NULL AND transaction_type IN ('purchase', 'recovery');
CREATE UNIQUE INDEX IF NOT EXISTS idx_credit_tx_payment_refund_once
ON credit_transactions(payment_id)
WHERE payment_id IS NOT NULL AND transaction_type = 'refund';
CREATE UNIQUE INDEX IF NOT EXISTS idx_race_entry_tx_payment_refund_once
ON race_entry_transactions(payment_id)
WHERE payment_id IS NOT NULL AND reason = 'refund';
ALTER TABLE race_entry_lots
ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'race_entry_lots_refund_shape'
) THEN
ALTER TABLE race_entry_lots
ADD CONSTRAINT race_entry_lots_refund_shape
CHECK (refunded_at IS NULL OR remaining_entries = 0) NOT VALID;
END IF;
END $$;
ALTER TABLE payment_refunds
ADD COLUMN IF NOT EXISTS purchase_progress_deducted BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS race_entries_deducted INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS packs_revoked JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS premium_level_restored VARCHAR(32),
ADD COLUMN IF NOT EXISTS entitlement_grant_payment_id INTEGER
REFERENCES payment_entitlement_grants(payment_id) ON DELETE RESTRICT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'payment_refunds_progress_nonnegative'
) THEN
ALTER TABLE payment_refunds
ADD CONSTRAINT payment_refunds_progress_nonnegative
CHECK (purchase_progress_deducted >= 0) NOT VALID;
END IF;
END $$;
-- A game mode label is not evidence that value was taken. Startup recovery may refund only the
-- exact entry snapshot committed in the same transaction that debited/linked the entry.
ALTER TABLE games
ADD COLUMN IF NOT EXISTS entry_consumed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS entry_credits_spent INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'games_entry_consumption_shape'
) THEN
ALTER TABLE games
ADD CONSTRAINT games_entry_consumption_shape CHECK (
entry_credits_spent IS NULL OR entry_credits_spent > 0
) NOT VALID;
END IF;
END $$;
-- Bind the secret half of a v2 fairness offer to the invoice before an address is shown. The
-- server seed remains private until the linked game ends; only its hash is sent to the client.
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS fairness_proof_version SMALLINT,
ADD COLUMN IF NOT EXISTS fairness_offer_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS fairness_offer_issued_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fairness_commitment CHAR(64),
ADD COLUMN IF NOT EXISTS fairness_server_seed CHAR(64),
ADD COLUMN IF NOT EXISTS fairness_client_seed VARCHAR(64),
ADD COLUMN IF NOT EXISTS fairness_bound_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS fairness_consumed_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_fairness_offer_once
ON payments(fairness_offer_id)
WHERE fairness_offer_id IS NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'payments_fairness_binding_complete'
) THEN
ALTER TABLE payments
ADD CONSTRAINT payments_fairness_binding_complete CHECK (
fairness_bound_at IS NULL OR (
payment_type = 'single_game'
AND fairness_proof_version = 2
AND fairness_offer_id IS NOT NULL
AND fairness_offer_issued_at IS NOT NULL
AND fairness_commitment ~ '^[0-9a-f]{64}$'
AND fairness_server_seed ~ '^[0-9a-f]{64}$'
AND fairness_client_seed IS NOT NULL
)
) NOT VALID;
END IF;
END $$;
CREATE OR REPLACE FUNCTION reject_payment_fairness_binding_mutation()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.fairness_bound_at IS NOT NULL AND (
NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.payment_type IS DISTINCT FROM OLD.payment_type
OR NEW.expected_amount IS DISTINCT FROM OLD.expected_amount
OR NEW.subaddress IS DISTINCT FROM OLD.subaddress
OR NEW.address_index IS DISTINCT FROM OLD.address_index
OR NEW.fairness_proof_version IS DISTINCT FROM OLD.fairness_proof_version
OR NEW.fairness_offer_id IS DISTINCT FROM OLD.fairness_offer_id
OR NEW.fairness_offer_issued_at IS DISTINCT FROM OLD.fairness_offer_issued_at
OR NEW.fairness_commitment IS DISTINCT FROM OLD.fairness_commitment
OR NEW.fairness_server_seed IS DISTINCT FROM OLD.fairness_server_seed
OR NEW.fairness_client_seed IS DISTINCT FROM OLD.fairness_client_seed
OR NEW.fairness_bound_at IS DISTINCT FROM OLD.fairness_bound_at
OR (OLD.fairness_consumed_at IS NOT NULL
AND NEW.fairness_consumed_at IS DISTINCT FROM OLD.fairness_consumed_at)
) THEN
RAISE EXCEPTION 'payment fairness binding is immutable for payment %', OLD.id
USING ERRCODE = '23514';
END IF;
IF OLD.fairness_bound_at IS NULL AND NEW.fairness_bound_at IS NOT NULL AND (
NEW.user_id IS DISTINCT FROM OLD.user_id
OR NEW.payment_type IS DISTINCT FROM OLD.payment_type
OR NEW.expected_amount IS DISTINCT FROM OLD.expected_amount
OR NEW.subaddress IS DISTINCT FROM OLD.subaddress
OR NEW.address_index IS DISTINCT FROM OLD.address_index
) THEN
RAISE EXCEPTION 'payment identity cannot change while binding fairness proof for payment %', OLD.id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_payments_immutable_fairness_binding ON payments;
CREATE TRIGGER trg_payments_immutable_fairness_binding
BEFORE UPDATE ON payments
FOR EACH ROW
EXECUTE FUNCTION reject_payment_fairness_binding_mutation();
-- Runtime fallback queries still recognize the legacy reason key, but backfill it into the
-- canonical marker and ledger FK so upgraded installations immediately get the same invariant.
WITH recovered AS (
SELECT DISTINCT ON ((substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer)
(substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer AS payment_id,
ct.user_id,
GREATEST(ct.amount, 0)::bigint AS credits_granted,
ct.id AS credit_transaction_id
FROM credit_transactions ct
WHERE ct.reason ~ '^single_game_recovered:[0-9]+$'
ORDER BY (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer, ct.id
)
INSERT INTO payment_entitlement_grants (
payment_id, user_id, source, credits_granted, metadata
)
SELECT r.payment_id, r.user_id, 'single_game_recovery', r.credits_granted,
jsonb_build_object('legacyBackfill', true, 'creditTransactionId', r.credit_transaction_id)
FROM recovered r
JOIN payments p ON p.id = r.payment_id AND p.user_id = r.user_id
ON CONFLICT (payment_id) DO NOTHING;
-- Some legacy deployments may already contain duplicate recovery ledger rows from a race
-- between instances. Attach only the earliest row to the invoice; the unique payment ledger
-- index then remains a backstop instead of making this upgrade itself fail.
WITH canonical_recovery_ledger AS (
SELECT DISTINCT ON ((substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer)
ct.id,
(substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer AS payment_id
FROM credit_transactions ct
JOIN payments p
ON p.id = (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer
AND p.user_id = ct.user_id
WHERE ct.payment_id IS NULL
AND ct.reason ~ '^single_game_recovered:[0-9]+$'
ORDER BY (substring(ct.reason FROM 'single_game_recovered:([0-9]+)'))::integer, ct.id
)
UPDATE credit_transactions ct
SET payment_id = canonical.payment_id
FROM canonical_recovery_ledger canonical
WHERE ct.id = canonical.id;
-- Historical confirmed products did not have an entitlement marker. Snapshot the durable grant
-- JSON conservatively; pack revocation still requires payment-specific entitlement provenance,
-- otherwise the refund service sends the row to manual review.
INSERT INTO payment_entitlement_grants (
payment_id, user_id, source, credits_granted, purchase_progress_granted,
race_entries_granted, packs_granted, premium_level_granted, metadata
)
SELECT
p.id,
p.user_id,
'legacy_product_backfill',
CASE
WHEN COALESCE(p.product_grants->>'credits', '') ~ '^[0-9]+$'
THEN (p.product_grants->>'credits')::bigint
ELSE GREATEST(COALESCE(p.credits_purchased, 0), 0)::bigint
END,
GREATEST(COALESCE(p.credits_purchased, 0), 0)::bigint,
CASE
WHEN COALESCE(p.product_grants->>'raceEntries', '') ~ '^[0-9]+$'
THEN (p.product_grants->>'raceEntries')::integer
ELSE 0
END,
CASE WHEN jsonb_typeof(p.product_grants->'packs') = 'array'
THEN p.product_grants->'packs' ELSE '[]'::jsonb END,
NULLIF(p.product_grants->>'premiumLevel', ''),
jsonb_build_object('legacyBackfill', true)
FROM payments p
WHERE p.status = 'confirmed'
AND p.payment_type IN ('credits_package', 'cosmetic_pack')
AND p.user_id IS NOT NULL
ON CONFLICT (payment_id) DO NOTHING;
@@ -0,0 +1,36 @@
-- Bind Smirk authentication nonces to a stable authenticated user, not merely to the
-- public Socket.IO id that Tavern/match state broadcasts to other players.
ALTER TABLE smirk_challenges
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
-- Challenges are five-minute ephemeral nonces. Invalidate any nonce issued by the old,
-- socket-only application before making the stable binding mandatory; clients simply retry.
DELETE FROM smirk_challenges WHERE user_id IS NULL;
ALTER TABLE smirk_challenges
ALTER COLUMN user_id SET NOT NULL;
-- Socket ownership is an authentication relation throughout the paid-play code. Refuse to
-- continue an upgrade with ambiguous historical rows, then make that invariant structural.
DO $$
BEGIN
IF EXISTS (
SELECT socket_id
FROM users
WHERE socket_id IS NOT NULL
GROUP BY socket_id
HAVING COUNT(*) > 1
) THEN
RAISE EXCEPTION 'cannot enforce unique socket ownership: duplicate users.socket_id rows exist'
USING ERRCODE = '23505';
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_socket_id_unique_nonnull
ON users (socket_id)
WHERE socket_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_smirk_challenges_user_active
ON smirk_challenges (user_id, expires_at)
WHERE used = FALSE;
@@ -0,0 +1,134 @@
-- Incoming-payment evidence must be unique per chain output, not per transaction or address.
-- One Monero transaction may contain several incoming outputs (including several for the same
-- subaddress), and one invoice may be completed by several top-up transactions.
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS provider_id VARCHAR(40) NOT NULL DEFAULT 'native-monero',
ADD COLUMN IF NOT EXISTS provider_invoice_id VARCHAR(160),
ADD COLUMN IF NOT EXISTS confirmation_evidence_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS late_receipt_checked_at TIMESTAMPTZ;
-- Migration 008 assumed one incoming transaction == one invoice. Keep tx_hash as display/search
-- evidence only. Legacy data also contains expired/retried rows that intentionally reused an
-- address, so neither transaction hashes nor invoice destinations are unique payment identities.
DROP INDEX IF EXISTS idx_payments_tx_hash_unique;
DROP INDEX IF EXISTS idx_payments_tx_hash_output_unique;
CREATE TABLE IF NOT EXISTS payment_receipts (
id BIGSERIAL PRIMARY KEY,
payment_id INTEGER NOT NULL REFERENCES payments(id) ON DELETE RESTRICT,
provider_id VARCHAR(40) NOT NULL,
evidence_type VARCHAR(24) NOT NULL
CHECK (evidence_type IN ('chain_output', 'provider_invoice')),
evidence_id VARCHAR(160) NOT NULL,
tx_hash VARCHAR(64),
output_id VARCHAR(80),
address_index INTEGER,
amount BIGINT NOT NULL CHECK (amount > 0),
confirmed BOOLEAN NOT NULL DEFAULT TRUE,
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT payment_receipt_shape CHECK (
(evidence_type = 'chain_output'
AND provider_id = 'native-monero'
AND tx_hash ~ '^[0-9a-f]{64}$'
AND (output_id ~ '^[0-9a-f]{64}$' OR output_id ~ '^global:(0|[1-9][0-9]*)$')
AND evidence_id = tx_hash || ':' || output_id
AND address_index IS NOT NULL AND address_index >= 0)
OR
(evidence_type = 'provider_invoice'
AND provider_id <> 'native-monero'
AND tx_hash IS NULL
AND output_id IS NULL
AND address_index IS NULL
AND LENGTH(evidence_id) > 0)
)
);
-- Output/invoice evidence is globally consumable once for its provider. In particular, tx_hash
-- and address_index are deliberately absent: sibling outputs can share both values.
DROP INDEX IF EXISTS idx_payment_receipts_chain_output_once;
DROP INDEX IF EXISTS idx_payment_receipts_provider_invoice_once;
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_receipts_evidence_once
ON payment_receipts(provider_id, evidence_id);
CREATE INDEX IF NOT EXISTS idx_payment_receipts_payment
ON payment_receipts(payment_id, observed_at);
-- Late receipts are intentionally not entitlements. The bounded reconciliation worker records
-- their durable output evidence here for an operator to refund or adjudicate manually, while the
-- underlying payment remains expired.
CREATE TABLE IF NOT EXISTS payment_late_reviews (
id BIGSERIAL PRIMARY KEY,
payment_id INTEGER NOT NULL UNIQUE REFERENCES payments(id) ON DELETE RESTRICT,
provider_id VARCHAR(40) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'needs_review'
CHECK (status IN ('needs_review', 'resolved', 'ignored')),
reason VARCHAR(40) NOT NULL DEFAULT 'late_confirmed_receipt'
CHECK (reason IN ('late_confirmed_receipt')),
observed_amount BIGINT NOT NULL CHECK (observed_amount > 0),
expected_amount BIGINT NOT NULL CHECK (expected_amount > 0),
receipt_count INTEGER NOT NULL CHECK (receipt_count > 0),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
first_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_payment_late_reviews_status
ON payment_late_reviews(status, last_observed_at);
CREATE INDEX IF NOT EXISTS idx_payments_late_receipt_scan
ON payments(late_receipt_checked_at, expires_at)
WHERE status IN ('pending', 'expired');
-- Forward-only guard. Existing confirmed legacy rows (including rows with NULL tx_hash or an
-- old received_amount) remain readable because this trigger runs only on a new transition into
-- confirmed. Every new confirmation must have exact durable evidence covering the invoice.
CREATE OR REPLACE FUNCTION enforce_payment_confirmation_evidence()
RETURNS TRIGGER AS $$
DECLARE
receipt_total BIGINT;
receipt_count BIGINT;
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.status = 'confirmed' THEN
RAISE EXCEPTION 'payment % must be inserted pending before receipt-backed confirmation', NEW.id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF NEW.status = 'confirmed' AND OLD.status IS DISTINCT FROM 'confirmed' THEN
SELECT COALESCE(SUM(r.amount), 0), COUNT(*)
INTO receipt_total, receipt_count
FROM payment_receipts r
WHERE r.payment_id = NEW.id
AND r.provider_id = NEW.provider_id
AND r.confirmed = TRUE
AND (
(NEW.provider_id = 'native-monero' AND r.evidence_type = 'chain_output')
OR (NEW.provider_id <> 'native-monero' AND r.evidence_type = 'provider_invoice')
);
IF receipt_count = 0 OR receipt_total < NEW.expected_amount THEN
RAISE EXCEPTION 'payment % lacks confirmed receipt coverage (% / %)',
NEW.id, receipt_total, NEW.expected_amount
USING ERRCODE = '23514';
END IF;
IF NEW.received_amount IS NULL OR NEW.received_amount < NEW.expected_amount
OR NEW.received_amount > receipt_total THEN
RAISE EXCEPTION 'payment % received_amount is not supported by receipts', NEW.id
USING ERRCODE = '23514';
END IF;
NEW.confirmation_evidence_at = COALESCE(NEW.confirmation_evidence_at, NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_payments_require_confirmation_evidence ON payments;
CREATE TRIGGER trg_payments_require_confirmation_evidence
BEFORE INSERT OR UPDATE OF status ON payments
FOR EACH ROW
EXECUTE FUNCTION enforce_payment_confirmation_evidence();

Some files were not shown because too many files have changed in this diff Show More