mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
feat: productionize paid modes and PvP capture
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
Vendored
-2
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
+333
-85
@@ -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); });
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ▸</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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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; });
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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.
|
||||
Executable
+275
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"}}]'
|
||||
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user