mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
This commit is contained in:
@@ -0,0 +1,622 @@
|
||||
# =============================================================================
|
||||
# Wownerogue Server Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and customize for your environment.
|
||||
# The server validates these settings at startup. Production fails closed on missing secrets,
|
||||
# ambiguous payout flags, simulated blocks, or missing wallet/daemon RPC endpoints.
|
||||
#
|
||||
# This is a generic MIT self-hosting template. The named operator is solely responsible for the
|
||||
# deployment and its legal/financial obligations; Such Software does not operate or endorse it.
|
||||
# Do not set OPERATED_PRODUCT_PROFILE. That opt-in identifier is reserved for the two exact
|
||||
# Such Software operated templates in .env.mainnet.example and .env.stagenet.example.
|
||||
#
|
||||
# QUICK START CONFIGURATIONS:
|
||||
# ---------------------------
|
||||
# 1. FREE MODE (no payments, just play):
|
||||
# PAYMENTS_ENABLED=false
|
||||
#
|
||||
# 2. PAID MODE (1 WOW per game, 2x payout on win):
|
||||
# PAYMENTS_ENABLED=true
|
||||
# PAYMENT_MODES=direct
|
||||
# + Configure wallet RPC and database
|
||||
#
|
||||
# 3. CREDITS MODE (buy bundles, play multiple games):
|
||||
# PAYMENTS_ENABLED=true
|
||||
# PAYMENT_MODES=credits
|
||||
# + Configure wallet RPC and database
|
||||
#
|
||||
# 4. BOTH MODES (let users choose):
|
||||
# PAYMENTS_ENABLED=true
|
||||
# PAYMENT_MODES=direct,credits
|
||||
|
||||
# =============================================================================
|
||||
# UNDERSTANDING ATOMIC UNITS
|
||||
# =============================================================================
|
||||
# Wownero (like Monero) uses "atomic units" - the smallest indivisible unit.
|
||||
#
|
||||
# 1 WOW = 100,000,000,000 atomic units (10^11, or 100 billion)
|
||||
#
|
||||
# Examples:
|
||||
# 1 WOW = 100000000000
|
||||
# 0.5 WOW = 50000000000
|
||||
# 0.1 WOW = 10000000000
|
||||
# 5 WOW = 500000000000
|
||||
# 10 WOW = 1000000000000
|
||||
#
|
||||
# Why? Floating point math is imprecise. Atomic units ensure exact calculations.
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 1: BASIC SERVER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Server port (default: 3000)
|
||||
PORT=3000
|
||||
|
||||
# Environment: production | development | debug
|
||||
# - production: minimal logging, real blockchain
|
||||
# - 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
|
||||
|
||||
# Optional: Display "Hosted by" link in footer (leave empty to hide)
|
||||
# Example: https://yoursite.com or https://github.com/yourname
|
||||
# HOSTED_BY=https://example.com
|
||||
# HOSTED_BY_NAME=Example Host
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 2: DATABASE (PostgreSQL) - REQUIRED
|
||||
# =============================================================================
|
||||
# The game stores user sessions, payment history, and credits in PostgreSQL.
|
||||
# You must create the database and user first (see README).
|
||||
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=wownerogue
|
||||
DB_USER=wownerogue
|
||||
DB_PASSWORD=your_secure_password_here
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 3: GAME DIFFICULTY
|
||||
# =============================================================================
|
||||
# Controls dungeon size, monster behavior, and effective house edge.
|
||||
#
|
||||
# Preset | Dungeon | Monster Speed | Player Win Rate | Best For
|
||||
# ------------|----------|---------------|-----------------|------------------
|
||||
# easy | 30x15 | Slow | ~70% | Free play, demos
|
||||
# normal | 45x22 | Medium | ~45% | Balanced gameplay
|
||||
# hard | 55x28 | Fast | ~35% | Challenge mode
|
||||
# casino | 70x35 | Fast | ~30% | Paid modes (default)
|
||||
#
|
||||
# If unset, defaults to 'casino' for paid modes, 'normal' for free mode.
|
||||
# DIFFICULTY_PRESET=casino
|
||||
|
||||
# Fine-tune individual difficulty parameters (override preset defaults).
|
||||
# Only set these if you want to deviate from the chosen preset.
|
||||
#
|
||||
# Dungeon layout:
|
||||
# DUNGEON_WIDTH=70 # Map width in tiles
|
||||
# DUNGEON_HEIGHT=35 # Map height in tiles
|
||||
# DUNGEON_DUG_PERCENTAGE=0.10 # % of map that's open space (lower = tighter)
|
||||
# DUNGEON_ROOM_WIDTH_MIN=3 # Room width range (min)
|
||||
# DUNGEON_ROOM_WIDTH_MAX=4 # Room width range (max)
|
||||
# DUNGEON_ROOM_HEIGHT_MIN=3 # Room height range (min)
|
||||
# DUNGEON_ROOM_HEIGHT_MAX=4 # Room height range (max)
|
||||
# DUNGEON_CORRIDOR_MIN=6 # Corridor length range (min)
|
||||
# DUNGEON_CORRIDOR_MAX=15 # Corridor length range (max)
|
||||
#
|
||||
# Monster behavior:
|
||||
# MONSTER_SPEED=1.0 # Moves per player move (1.0 = same speed, 0.5 = half)
|
||||
# MONSTER_CHASE=0.97 # Chase probability 0.0-1.0 (rest is random wander)
|
||||
# MONSTER_VISION=22 # Vision range in tiles
|
||||
# MONSTER_DISTANCE=0.30 # Starting distance ratio 0.0-1.0 (lower = closer)
|
||||
#
|
||||
# Treasure placement:
|
||||
# TREASURE_ROOM_POSITION=0.20 # Room position ratio 0.0-1.0 (lower = earlier room)
|
||||
# TREASURE_EXIT_DISTANCE=0.85 # Distance from exit ratio 0.0-1.0 (higher = farther)
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 4: PAYMENT SYSTEM OVERVIEW
|
||||
# =============================================================================
|
||||
#
|
||||
# PAYMENTS_ENABLED is the master switch:
|
||||
# false = FREE mode, anyone can play, no crypto involved
|
||||
# true = Paid mode, requires wallet RPC configuration
|
||||
#
|
||||
# PAYMENT_MODES determines HOW users pay (when payments enabled):
|
||||
#
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ MODE: direct │
|
||||
# ├─────────────────────────────────────────────────────────────────────────┤
|
||||
# │ • User pays X WOW per game │
|
||||
# │ • If they WIN (escape dungeon): get PAYOUT_ESCAPE × X back (e.g., 2x) │
|
||||
# │ • If they WIN with treasure: get PAYOUT_TREASURE × X back (e.g., 3x) │
|
||||
# │ • If they LOSE (die): house keeps the payment │
|
||||
# │ │
|
||||
# │ Example: Pay 1 WOW, win = get 2 WOW. Die = lose 1 WOW. │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ MODE: credits │
|
||||
# ├─────────────────────────────────────────────────────────────────────────┤
|
||||
# │ • User buys a PACKAGE of credits upfront (e.g., 10 credits for 5 WOW) │
|
||||
# │ • Each game costs 1 credit (or CREDITS_PER_GAME) │
|
||||
# │ • Packages can include bonus credits (bulk discount) │
|
||||
# │ • Payouts are optional - credits are usually just for playing │
|
||||
# │ │
|
||||
# │ Example: Buy 10 credits for 5 WOW = 0.5 WOW/game (50% discount!) │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
#
|
||||
# ┌─────────────────────────────────────────────────────────────────────────┐
|
||||
# │ MODE: direct,credits (both enabled) │
|
||||
# ├─────────────────────────────────────────────────────────────────────────┤
|
||||
# │ • Users can choose which mode to use │
|
||||
# │ • If PREFER_CREDITS_FIRST=true, credits are auto-used when available │
|
||||
# │ • Good for giving users flexibility │
|
||||
# └─────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
# Master switch: Set to false for completely free play (no wallet needed)
|
||||
PAYMENTS_ENABLED=true
|
||||
|
||||
# FREE_PLAY_ENABLED: offer FREE play as a CHOICE even when payments are on. Free games
|
||||
# are recorded as game_mode=FREE and appear on the Pleb leaderboard (NOT the Hall of
|
||||
# Champions, which is for paid games). Always effectively on when PAYMENTS_ENABLED=false.
|
||||
#
|
||||
# DEPLOYMENT PATTERNS
|
||||
# • Mainnet prestige: free-to-play + sell service credits/entry, with NO PAYOUTS.
|
||||
# No-payout design is not a legal conclusion; an independent operator needs its own review.
|
||||
# PAYMENTS_ENABLED=true FREE_PLAY_ENABLED=true PAYOUTS_ENABLED=false
|
||||
# DIRECT_PAYOUTS_ENABLED=false CREDITS_PAYOUTS_ENABLED=false
|
||||
# DIRECT_REQUIRES_ADDRESS=false CREDITS_REQUIRES_ADDRESS=false (no payout = no address)
|
||||
# • Test/stagenet: test mechanics can exercise payouts only with conspicuous no-real-value copy.
|
||||
# Never accept or represent mainnet funds under a test-only profile.
|
||||
# PAYMENTS_ENABLED=true PAYOUTS_ENABLED=true *_PAYOUTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
|
||||
# Which payment modes to enable (comma-separated)
|
||||
PAYMENT_MODES=direct,credits
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 5: DIRECT MODE CONFIGURATION
|
||||
# =============================================================================
|
||||
# Only relevant if PAYMENT_MODES includes "direct"
|
||||
|
||||
# Enable/disable direct payments
|
||||
DIRECT_PAYMENT_ENABLED=true
|
||||
|
||||
# Price per game in atomic units (100000000000 = 1 WOW)
|
||||
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=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=false
|
||||
DIRECT_PAYOUT_ESCAPE=2.0 # Escape dungeon = 2x payment
|
||||
DIRECT_PAYOUT_TREASURE=3.0 # Escape WITH treasure = 3x payment
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 6: CREDITS MODE CONFIGURATION
|
||||
# =============================================================================
|
||||
# Only relevant if PAYMENT_MODES includes "credits"
|
||||
|
||||
# Enable/disable credits system
|
||||
CREDITS_ENABLED=true
|
||||
|
||||
# How many credits does one game cost?
|
||||
CREDITS_PER_GAME=1
|
||||
|
||||
# Must user provide address for credits mode?
|
||||
CREDITS_REQUIRES_ADDRESS=false
|
||||
|
||||
# When both modes enabled:
|
||||
ALLOW_MIXED_MODE=true # Show both options to user
|
||||
PREFER_CREDITS_FIRST=true # Auto-deduct credits if user has them
|
||||
|
||||
# Credits payouts (usually DISABLED so purchases grant service play, not prize eligibility)
|
||||
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:
|
||||
# id - Unique identifier (shown in UI)
|
||||
# credits - Number of game credits included
|
||||
# price - Cost in atomic units (as STRING to avoid JS number precision issues)
|
||||
# bonus - Extra free credits (0 for no bonus)
|
||||
#
|
||||
# Default packages (small discounts to encourage bulk purchase):
|
||||
# small: 10 credits for 9 WOW (~10% discount vs 1 WOW/game direct)
|
||||
# medium: 20 credits for 17 WOW (~15% discount)
|
||||
# large: 50 credits for 40 WOW (~20% discount)
|
||||
#
|
||||
# Tip: Format JSON with newlines for readability, then minify for .env:
|
||||
# [
|
||||
# {"id":"small", "credits":10, "price":"900000000000", "bonus":0},
|
||||
# {"id":"medium", "credits":20, "price":"1700000000000", "bonus":0},
|
||||
# {"id":"large", "credits":50, "price":"4000000000000", "bonus":0}
|
||||
# ]
|
||||
#
|
||||
CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"900000000000","bonus":0},{"id":"medium","credits":20,"price":"1700000000000","bonus":0},{"id":"large","credits":50,"price":"4000000000000","bonus":0}]'
|
||||
|
||||
# Credit packages can bundle cosmetic/render pack grants and a premium tier via
|
||||
# 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 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
|
||||
# =============================================================================
|
||||
# Early entry lets players start a game immediately without waiting for the
|
||||
# next block. Risk: if a new block arrives before they escape, the game ends.
|
||||
#
|
||||
# Disabled by default for direct (paid-per-game) mode since players paid for
|
||||
# a full block window. Useful for free and credits modes.
|
||||
|
||||
# Master toggle
|
||||
EARLY_ENTRY_ENABLED=true
|
||||
|
||||
# Allow early entry in free mode?
|
||||
EARLY_ENTRY_FREE_MODE=true
|
||||
|
||||
# Allow early entry in credits mode?
|
||||
EARLY_ENTRY_CREDITS_MODE=true
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 7: PAYOUT SETTINGS
|
||||
# =============================================================================
|
||||
# Controls how winnings are sent back to players.
|
||||
# Payouts are batched for efficiency and to reduce transaction fees.
|
||||
|
||||
# Master payout switch. The Such-operated mainnet product sells credits for leaderboard prestige
|
||||
# without crypto winnings. Its separate XMR stagenet test product uses 2x/3x test-coin 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
|
||||
PAYOUT_MIN_AMOUNT=10000000000
|
||||
|
||||
# Maximum payout per single game (safety limit)
|
||||
# 10000000000000 = 100 WOW
|
||||
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';
|
||||
# To retry a permanently failed payout, reset it with:
|
||||
# UPDATE payouts SET status = 'pending', retry_count = 0 WHERE id = <id>;
|
||||
PAYOUT_MAX_RETRIES=3
|
||||
|
||||
# Max payouts to send in one batch (wallet RPC can timeout on large batches)
|
||||
MAX_PAYOUT_BATCH_SIZE=50
|
||||
|
||||
# How often to retry failed/stuck payouts (milliseconds). Default: 300000 (5 min)
|
||||
# PAYOUT_RETRY_INTERVAL_MS=300000
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 8: ANTI-ABUSE LIMITS
|
||||
# =============================================================================
|
||||
# Protect against bots and abuse
|
||||
|
||||
MAX_GAMES_PER_HOUR=60 # Max games one user can play per hour
|
||||
MAX_PAYOUTS_PER_DAY=100 # Max payout transactions per user per day
|
||||
MAX_CREDIT_PURCHASE_PER_DAY=100000000000000 # Max credit purchase (1000 WOW)
|
||||
GAME_COOLDOWN_SECONDS=5 # Minimum seconds between games
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 9: WALLET RPC - REQUIRED FOR PAID MODES
|
||||
# =============================================================================
|
||||
# The wallet RPC handles:
|
||||
# 1. Generating unique payment addresses for each user
|
||||
# 2. Checking for incoming payments
|
||||
# 3. Sending payouts to winners
|
||||
#
|
||||
# You must run wownero-wallet-rpc (or monero-wallet-rpc) separately:
|
||||
#
|
||||
# wownero-wallet-rpc \
|
||||
# --wallet-file /path/to/wallet \
|
||||
# --password-file /path/to/root-readable-wallet-password \
|
||||
# --rpc-bind-ip 127.0.0.1 \
|
||||
# --rpc-bind-port 34570 \
|
||||
# --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
|
||||
|
||||
# Required together in production:
|
||||
# WALLET_RPC_USER=wownerogue
|
||||
# WALLET_RPC_PASSWORD=replace-with-a-distinct-strong-rpc-password
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 10: BLOCKCHAIN DAEMON RPC
|
||||
# =============================================================================
|
||||
# Used to get current block height for game synchronization.
|
||||
# This is the daemon (wownerod/monerod), NOT the wallet.
|
||||
#
|
||||
# The game syncs to block timing - each block triggers game state checks.
|
||||
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
|
||||
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated:
|
||||
# note that the two defaults are the SAME host, so out of the box there is exactly one node
|
||||
# and no redundancy at all.
|
||||
#
|
||||
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
|
||||
# same office) shares the outage that takes the first one down, so it buys nothing. What actually
|
||||
# adds availability is a node reachable over a separate path: a home node across the mesh, or a
|
||||
# public/community node.
|
||||
#
|
||||
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
|
||||
# remote node cannot silently serve a different chain's data. For FREE / CREDITS instances the
|
||||
# daemon is only supplying block height, so a remote node is a reasonable fallback. For instances
|
||||
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
|
||||
# an untrusted node is an untrusted input to those.
|
||||
#
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://192.0.2.40:34568
|
||||
#
|
||||
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
|
||||
# RPC_PREFERRED_RETRY_MS=60000
|
||||
#
|
||||
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
|
||||
# and nothing is charged. There is intentionally no wallet failover: two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs. So a wallet outage declines paid intake while
|
||||
# credits and free play keep working.
|
||||
|
||||
# How often to poll for new blocks (milliseconds)
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 11: ADMIN API
|
||||
# =============================================================================
|
||||
# Secure endpoints for refunds, credit adjustments, and user management.
|
||||
# Generate a strong random key and keep it secret!
|
||||
|
||||
# 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.
|
||||
# Independent deployments must identify their own operator. Such Software operates only
|
||||
# play.wowne.ro and monerogue.app; it does not endorse or accept responsibility for self-hosts.
|
||||
# OPERATOR_NAME=Responsible operator name
|
||||
# OPERATOR_CONTACT_URL=mailto:support@example.com
|
||||
# OPERATOR_CONTACT_LABEL=Support
|
||||
# LEGAL_POLICY_VERSION=2026-07-22-v1
|
||||
# TERMS_EFFECTIVE_DATE=2026-07-22
|
||||
# MINIMUM_AGE=18
|
||||
# PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
# Leave unset for third-party/self-hosted deployments. Official templates set one reviewed value:
|
||||
# OPERATED_PRODUCT_PROFILE=such-play-wow-prestige | such-monerogue-stagenet
|
||||
# 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/...
|
||||
|
||||
# Allowed origins for admin API CORS (comma-separated, only needed for cross-origin)
|
||||
# Same-origin requests are always allowed. Leave unset if admin panel is on same host.
|
||||
# ALLOWED_ORIGINS=https://admin.example.com
|
||||
|
||||
# Set to 'true' ONLY when running behind a trusted reverse proxy (Nginx/Caddy). When
|
||||
# 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
|
||||
# degenerate case where a block lands the instant a game starts (before the dungeon
|
||||
# renders / before the first move is possible). Default 2000 (2s); set 0 to disable.
|
||||
# GAME_START_GRACE_MS=2000
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 13: ADMIN ALERTS (Resend)
|
||||
# =============================================================================
|
||||
# Email alerts for low wallet balance and critical issues.
|
||||
# Sign up at https://resend.com to get an API key.
|
||||
|
||||
# Resend API key
|
||||
# RESEND_API_KEY=re_xxxxxxxxxxxxx
|
||||
|
||||
# Admin email to receive alerts
|
||||
# ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# From address for alert emails (must be verified in Resend)
|
||||
# Default: alerts@monerogue.app
|
||||
# ALERT_FROM_EMAIL=noreply@yourdomain.com
|
||||
|
||||
# -------------------------
|
||||
# BALANCE THRESHOLDS
|
||||
# -------------------------
|
||||
# Two-tier alert system for wallet balance monitoring:
|
||||
#
|
||||
# BALANCE_WARN: Sends email alert when balance drops below this level
|
||||
# You should top up the wallet soon.
|
||||
#
|
||||
# BALANCE_CRITICAL: Sends email AND HALTS NEW GAMES when balance drops below
|
||||
# Users will see a modal: "Sorry, the house balance is too low..."
|
||||
# Games resume automatically when balance is restored above this threshold.
|
||||
#
|
||||
# All values in atomic units:
|
||||
# WOW: 1 WOW = 100000000000 (10^11)
|
||||
# XMR: 1 XMR = 1000000000000 (10^12)
|
||||
|
||||
# Warning threshold - sends email alert (default: 0.1 WOW/XMR)
|
||||
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
|
||||
|
||||
# -------------------------
|
||||
# OTHER ALERT SETTINGS
|
||||
# -------------------------
|
||||
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 13b: OPTIONAL MAINNET FINANCIAL-EVENT EXPORT
|
||||
# =============================================================================
|
||||
# Confirmed payments and final refunds are snapshotted to a durable outbox. Configure both values
|
||||
# only for a reviewed MAINNET accounting sink that honors Idempotency-Key. Test-network rows are
|
||||
# suppressed locally and must never be exported. Keep the bearer token in the protected runtime
|
||||
# environment; never commit it.
|
||||
# FINANCIAL_EVENT_SINK_URL=https://ledger.operator.example/v1/events
|
||||
# FINANCIAL_EVENT_SINK_TOKEN=CHANGE_ME_STRONG_ACCOUNTING_SINK_TOKEN
|
||||
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:receipts
|
||||
# FINANCIAL_EVENT_POLL_MS=60000
|
||||
# FINANCIAL_EVENT_BATCH_SIZE=20
|
||||
# FINANCIAL_EVENT_MAX_ATTEMPTS=8
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 14: SMIRK WALLET INTEGRATION
|
||||
# =============================================================================
|
||||
# Smirk is a browser extension wallet for Wownero/Monero.
|
||||
# https://smirk.network
|
||||
#
|
||||
# Enable this for Wownero mainnet. Disable for Monero stagenet
|
||||
# (Smirk doesn't support stagenet addresses).
|
||||
|
||||
# Enable Smirk wallet connection button (default: true)
|
||||
# Set to false for Monero stagenet deployments
|
||||
SMIRK_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 14b: BLOCK EXPLORER
|
||||
# =============================================================================
|
||||
# URL prefix for linking transaction hashes to a block explorer.
|
||||
# Include the trailing slash. TX hash will be appended directly.
|
||||
# Leave unset to disable clickable TX links in transaction history.
|
||||
#
|
||||
# Wownero: https://explore.wowne.ro/tx/
|
||||
# Monero: https://xmrchain.net/tx/
|
||||
EXPLORER_TX_URL=https://explore.wowne.ro/tx/
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 15: DEVELOPMENT/DEBUG OPTIONS
|
||||
# =============================================================================
|
||||
# These are for testing only. Don't use in production with real money!
|
||||
|
||||
# Block source: daemon (real blockchain) | simulated (fake blocks for testing)
|
||||
# Paid modes always use daemon unless FORCE_SIMULATED_BLOCKS=true
|
||||
BLOCK_SOURCE=daemon
|
||||
|
||||
# Force simulated blocks even in paid mode (DANGEROUS - for testing only!)
|
||||
# FORCE_SIMULATED_BLOCKS=false
|
||||
|
||||
# =============================================================================
|
||||
# LEGACY OPTIONS (deprecated - use PAYMENT_MODES instead)
|
||||
# =============================================================================
|
||||
# GAME_MODE: FREE | PAID_SINGLE | PAID_CREDITS
|
||||
# Kept for backward compatibility. New setups should use PAYMENT_MODES.
|
||||
# GAME_MODE=
|
||||
|
||||
CRYPTO_TYPE=WOW
|
||||
|
||||
# =============================================================================
|
||||
# Tavern & Multiplayer modes (optional)
|
||||
# =============================================================================
|
||||
# Tavern: a walkable social room (chat, avatars, spectator camera).
|
||||
TAVERN_ENABLED=true
|
||||
# Server-tick / snapshot broadcast cadence in ms (default 100 = 10 Hz)
|
||||
# TAVERN_TICK_MS=100
|
||||
|
||||
# =============================================================================
|
||||
# Match mode (multiplayer races)
|
||||
# =============================================================================
|
||||
# Enable real-time multiplayer races. Solo/Tavern are unaffected.
|
||||
MATCH_ENABLED=true
|
||||
|
||||
# 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
|
||||
|
||||
# Hard ceiling in ms after which a race is force-ended (default 4m).
|
||||
# MATCH_HARD_CEILING_MS=240000
|
||||
|
||||
# Credits cost for prestige-only races (default 1).
|
||||
# MATCH_CREDITS_COST=1
|
||||
|
||||
# Crypto race house fee percent. Production crypto matches require an explicit value from
|
||||
# 0 (inclusive) through 100 (exclusive).
|
||||
MATCH_HOUSE_FEE_PERCENT=5
|
||||
|
||||
# Required crypto-race per-player entry fee in atomic units when payout matches are enabled.
|
||||
MATCH_ENTRY_FEE_ATOMIC=5000000000
|
||||
|
||||
# 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,127 @@
|
||||
# Such Software operated product: play.wowne.ro.
|
||||
# Wownero mainnet free play + pay-for-credits leaderboard/prestige only.
|
||||
# No payouts, prizes, cash-out, or crypto racing; not offered/marketed as gambling.
|
||||
# Legal classification still depends on applicable law.
|
||||
# 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
|
||||
OPERATED_PRODUCT_PROFILE=such-play-wow-prestige
|
||||
|
||||
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
|
||||
|
||||
# Exact public operator/disclosure metadata for the Such Software operated service.
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
LEGAL_POLICY_VERSION=2026-07-22-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-22
|
||||
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; purchased credits rank on the paid prestige board.
|
||||
PAYMENTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
GAME_MODE=PAID_CREDITS
|
||||
PAYMENT_MODES=credits
|
||||
ALLOW_MIXED_MODE=false
|
||||
PREFER_CREDITS_FIRST=true
|
||||
|
||||
# Direct per-run payment is outside the operated product scope and must remain disabled.
|
||||
DIRECT_PAYMENT_ENABLED=false
|
||||
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
|
||||
|
||||
# Optional mainnet-only accounting export. Configure URL and token together in the protected live
|
||||
# environment only after the sink's Idempotency-Key contract and dead-letter monitoring are ready.
|
||||
# FINANCIAL_EVENT_SINK_URL=https://ledger.such.software/v1/events
|
||||
# FINANCIAL_EVENT_SINK_TOKEN=CHANGE_ME_STRONG_ACCOUNTING_SINK_TOKEN
|
||||
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:wow-receipts
|
||||
FINANCIAL_EVENT_POLL_MS=60000
|
||||
FINANCIAL_EVENT_BATCH_SIZE=20
|
||||
FINANCIAL_EVENT_MAX_ATTEMPTS=8
|
||||
|
||||
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
|
||||
|
||||
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated:
|
||||
# note that the two defaults are the SAME host, so out of the box there is exactly one node
|
||||
# and no redundancy at all.
|
||||
#
|
||||
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
|
||||
# same office) shares the outage that takes the first one down, so it buys nothing; a node
|
||||
# reachable over a separate path, a home node across the mesh, or a public/community node, is
|
||||
# what actually adds availability.
|
||||
#
|
||||
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
|
||||
# remote node cannot silently serve a different chain's data. For FREE / CREDITS instances the
|
||||
# daemon is only supplying block height, so a remote node is a reasonable fallback. For instances
|
||||
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
|
||||
# an untrusted node is an untrusted input to those.
|
||||
#
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://192.0.2.40:34568
|
||||
#
|
||||
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
|
||||
# RPC_PREFERRED_RETRY_MS=60000
|
||||
#
|
||||
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
|
||||
# and nothing is charged. There is intentionally no wallet failover: two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs. A wallet outage therefore declines paid intake
|
||||
# while credits and free play keep working.
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
DIFFICULTY_PRESET=normal
|
||||
EARLY_ENTRY_ENABLED=true
|
||||
EARLY_ENTRY_FREE_MODE=true
|
||||
EARLY_ENTRY_CREDITS_MODE=true
|
||||
|
||||
SOLO_ENABLED=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
|
||||
@@ -0,0 +1,46 @@
|
||||
# Match (multiplayer race) mode configuration
|
||||
# Add these to src/.env and src/.env.example as needed.
|
||||
|
||||
# Enable match mode (free race queues and, if configured, paid race economies)
|
||||
MATCH_ENABLED=false
|
||||
|
||||
# Max players per race (2-32)
|
||||
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
|
||||
|
||||
# Hard ceiling in ms after which a race is force-ended (default 4 minutes)
|
||||
MATCH_HARD_CEILING_MS=240000
|
||||
|
||||
# Cost in credits to enter a prestige-credit race (default 1)
|
||||
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,113 @@
|
||||
# Such Software operated test product: monerogue.app.
|
||||
# NO REAL VALUE: Monero stagenet test coins only. Never send mainnet XMR.
|
||||
# Single-player test gambling mechanics pay 2x/3x test coins; crypto-match payouts stay off.
|
||||
# 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
|
||||
OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet
|
||||
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
|
||||
|
||||
# Exact public operator/disclosure metadata for the Such Software operated service.
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
LEGAL_POLICY_VERSION=2026-07-23-v2
|
||||
TERMS_EFFECTIVE_DATE=2026-07-23
|
||||
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 direct test-coin entry.
|
||||
# Purchased-credit entry is outside this operated profile and stays fail-closed.
|
||||
PAYMENTS_ENABLED=true
|
||||
FREE_PLAY_ENABLED=true
|
||||
GAME_MODE=PAID_SINGLE
|
||||
PAYMENT_MODES=direct
|
||||
ALLOW_MIXED_MODE=false
|
||||
PREFER_CREDITS_FIRST=false
|
||||
|
||||
# NO REAL VALUE: 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=false
|
||||
CREDITS_PER_GAME=1
|
||||
CREDITS_REQUIRES_ADDRESS=false
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
# Inert compatibility values: the operated profile rejects credit purchase and credit payout paths.
|
||||
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}]'
|
||||
|
||||
# Stagenet-test-coin-only payout switch and 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
|
||||
|
||||
# Deliberately blank: NO REAL VALUE stagenet events are suppressed locally and must not reach a
|
||||
# real accounting sink. Startup rejects a configured financial-event sink on this network.
|
||||
FINANCIAL_EVENT_SINK_URL=
|
||||
FINANCIAL_EVENT_SINK_TOKEN=
|
||||
FINANCIAL_EVENT_ACCOUNT_REF=wowngeon:xmr-stagenet-test
|
||||
|
||||
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
|
||||
|
||||
SOLO_ENABLED=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
|
||||
# Operated PvP may use free/prestige economies, but never a crypto-pot/race payout economy.
|
||||
MATCH_CRYPTO_RACE_ENABLED=false
|
||||
MATCH_PAYOUTS_ENABLED=false
|
||||
@@ -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,76 @@
|
||||
/**
|
||||
* ChainProfile registry: the 10× foundation stone for multi-chain (Pillar 3).
|
||||
*
|
||||
* A single, data-driven source for everything chain-specific that ISN'T an RPC call: money units
|
||||
* (decimals / atomic divisor), the block-timing model (mean block time, which difficulty must
|
||||
* scale to), the adapter family (which wallet/daemon adapter implements it), and the payment-URI
|
||||
* scheme. The adapters (daemon/wallet clients, address validators) plug in per family and read
|
||||
* their params from here.
|
||||
*
|
||||
* The set is exactly what the Smirk wallet supports: btc, ltc, xmr, wow, grin (see
|
||||
* smirk-monorepo packages/core/src/types.ts AssetType). All are proof-of-work / high-variance, so
|
||||
* the "a random block ends your run" mechanic is fair on every one. Adding a chain = one entry here
|
||||
* plus a family adapter, never a fork.
|
||||
*/
|
||||
|
||||
const DEFAULT_DECIMALS = 12;
|
||||
|
||||
// family: 'monero' (subaddress + transfer_split), 'utxo' (BTC/LTC-style), 'mimblewimble' (Grin).
|
||||
const PROFILES = Object.freeze({
|
||||
WOW: Object.freeze({ id: 'WOW', symbol: 'WOW', decimals: 11, meanBlockTimeMs: 300000, family: 'monero', uriScheme: 'wownero' }),
|
||||
XMR: Object.freeze({ id: 'XMR', symbol: 'XMR', decimals: 12, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'monero' }),
|
||||
BTC: Object.freeze({ id: 'BTC', symbol: 'BTC', decimals: 8, meanBlockTimeMs: 600000, family: 'utxo', uriScheme: 'bitcoin' }),
|
||||
LTC: Object.freeze({ id: 'LTC', symbol: 'LTC', decimals: 8, meanBlockTimeMs: 150000, family: 'utxo', uriScheme: 'litecoin' }),
|
||||
GRIN: Object.freeze({ id: 'GRIN', symbol: 'GRIN', decimals: 9, meanBlockTimeMs: 60000, family: 'mimblewimble', uriScheme: 'grin' })
|
||||
});
|
||||
|
||||
function normalizeId(cryptoType) {
|
||||
return String(cryptoType || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a chain profile. Unknown/unset falls back to a WOW-shaped default (11 decimals, ~2 min)
|
||||
* so callers never crash on a mis-set CRYPTO_TYPE. Returns a frozen profile object.
|
||||
*/
|
||||
function getProfile(cryptoType) {
|
||||
const id = normalizeId(cryptoType);
|
||||
if (PROFILES[id]) return PROFILES[id];
|
||||
return Object.freeze({ id: id || 'WOW', symbol: id || 'WOW', decimals: DEFAULT_DECIMALS, meanBlockTimeMs: 120000, family: 'monero', uriScheme: 'monero' });
|
||||
}
|
||||
|
||||
function decimalsFor(cryptoType) {
|
||||
return getProfile(cryptoType).decimals;
|
||||
}
|
||||
|
||||
function meanBlockTimeMsFor(cryptoType) {
|
||||
return getProfile(cryptoType).meanBlockTimeMs;
|
||||
}
|
||||
|
||||
function familyFor(cryptoType) {
|
||||
return getProfile(cryptoType).family;
|
||||
}
|
||||
|
||||
function uriSchemeFor(cryptoType) {
|
||||
return getProfile(cryptoType).uriScheme;
|
||||
}
|
||||
|
||||
/** Atomic divisor as a BigInt (10 ** decimals): safe for ETH-18-scale amounts (none here, but correct). */
|
||||
function atomicDivisor(cryptoType) {
|
||||
return 10n ** BigInt(decimalsFor(cryptoType));
|
||||
}
|
||||
|
||||
function isSupported(cryptoType) {
|
||||
return !!PROFILES[normalizeId(cryptoType)];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_DECIMALS,
|
||||
PROFILES,
|
||||
getProfile,
|
||||
decimalsFor,
|
||||
meanBlockTimeMsFor,
|
||||
familyFor,
|
||||
uriSchemeFor,
|
||||
atomicDivisor,
|
||||
isSupported
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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 {
|
||||
SOURCE_SOFTWARE,
|
||||
getOperatedProductProfile
|
||||
} = require('./operatedProductProfiles');
|
||||
|
||||
const DEFAULT_POLICY_VERSION = '2026-07-22';
|
||||
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 operatedProduct = getOperatedProductProfile(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(
|
||||
operatedProduct?.operatorContactUrl || env.OPERATOR_CONTACT_URL
|
||||
);
|
||||
const operatorName = operatedProduct?.operatorName
|
||||
|| String(env.OPERATOR_NAME || '').trim()
|
||||
|| 'Site operator (identity not configured)';
|
||||
const operatorContactLabel = operatedProduct?.operatorContactLabel
|
||||
|| String(env.OPERATOR_CONTACT_LABEL || '').trim()
|
||||
|| (operatorContactUrl
|
||||
? operatorContactUrl.replace(/^mailto:/, '')
|
||||
: 'Operator contact is not configured');
|
||||
|
||||
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: operatorName,
|
||||
contactUrl: operatorContactUrl,
|
||||
contactLabel: operatorContactLabel
|
||||
}),
|
||||
operatedProduct,
|
||||
software: SOURCE_SOFTWARE,
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* 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 {
|
||||
getOperatedProductProfile,
|
||||
validateOperatedProductProfile
|
||||
} = require('./operatedProductProfiles');
|
||||
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';
|
||||
|
||||
// NODE_ENV must be explicit and recognized.
|
||||
//
|
||||
// Every fail-closed gate in the app (chain/wallet identity verification, secret-strength
|
||||
// checks, the simulated-block prohibition, HSTS, the readiness identity requirement) tests
|
||||
// for the literal string 'production'. DebugManager instead treats an unset NODE_ENV as
|
||||
// production, so it suppresses console logging and reports IS_PRODUCTION while every safety
|
||||
// gate reads false. Rejecting a missing or unrecognized value at startup makes that
|
||||
// ambiguous state unbootable rather than requiring agreement across all those call sites.
|
||||
const RECOGNIZED_ENVS = ['production', 'development', 'debug', 'test'];
|
||||
const declaredEnv = typeof env.NODE_ENV === 'string' ? env.NODE_ENV.trim() : '';
|
||||
if (!declaredEnv) {
|
||||
errors.push('NODE_ENV is not set. Set it explicitly to one of: '
|
||||
+ `${RECOGNIZED_ENVS.join(', ')}. An unset NODE_ENV disables every production safety `
|
||||
+ 'gate while the runtime still behaves as production.');
|
||||
} else if (!RECOGNIZED_ENVS.includes(declaredEnv.toLowerCase())) {
|
||||
errors.push(`NODE_ENV='${declaredEnv}' is not a recognized environment. Use one of: `
|
||||
+ `${RECOGNIZED_ENVS.join(', ')}. An unrecognized value disables every production `
|
||||
+ 'safety gate.');
|
||||
}
|
||||
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();
|
||||
const financialSinkUrl = String(env.FINANCIAL_EVENT_SINK_URL || '').trim();
|
||||
const financialSinkToken = String(env.FINANCIAL_EVENT_SINK_TOKEN || '').trim();
|
||||
|
||||
const operatedProfile = getOperatedProductProfile(env);
|
||||
const exactOperatedLegacyMode = operatedProfile
|
||||
&& String(env.GAME_MODE || '').trim() === operatedProfile.gameMode;
|
||||
if (env.GAME_MODE && env.PAYMENT_MODES && !exactOperatedLegacyMode) {
|
||||
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
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.');
|
||||
}
|
||||
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 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);
|
||||
|
||||
// Accounting export is optional, but a partial or misleading configuration is not. The
|
||||
// stagenet product intentionally suppresses its no-value test events instead of sending
|
||||
// them to a real accounting sink.
|
||||
if (Boolean(financialSinkUrl) !== Boolean(financialSinkToken)) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL and FINANCIAL_EVENT_SINK_TOKEN must be configured together.');
|
||||
}
|
||||
if (financialSinkUrl && financialSinkToken) {
|
||||
try {
|
||||
const endpoint = new URL(financialSinkUrl);
|
||||
if (!['http:', 'https:'].includes(endpoint.protocol)) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must use http:// or https://.');
|
||||
}
|
||||
if (endpoint.username || endpoint.password) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must not embed credentials.');
|
||||
}
|
||||
if (production && endpoint.protocol !== 'https:') {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must use https:// in production.');
|
||||
}
|
||||
} catch (_) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_URL must be a valid URL.');
|
||||
}
|
||||
if (financialSinkToken.length < 32
|
||||
|| PLACEHOLDER_SECRET.test(financialSinkToken)
|
||||
|| new Set(financialSinkToken).size < 8) {
|
||||
errors.push('FINANCIAL_EVENT_SINK_TOKEN must be a strong non-placeholder secret of at least 32 characters.');
|
||||
}
|
||||
if (network !== 'mainnet') {
|
||||
errors.push('Financial event export must be unset on non-mainnet networks; test-network events are suppressed locally.');
|
||||
}
|
||||
const accountRef = String(env.FINANCIAL_EVENT_ACCOUNT_REF || 'wowngeon:receipts').trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(accountRef)) {
|
||||
errors.push('FINANCIAL_EVENT_ACCOUNT_REF must be a non-PII identifier using only letters, digits, dot, underscore, colon, slash, or hyphen.');
|
||||
}
|
||||
}
|
||||
|
||||
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 (packages.length === 0) {
|
||||
errors.push('CREDITS_PACKAGES must not be empty when explicitly configured.');
|
||||
} else {
|
||||
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 (error) {
|
||||
errors.push(`CREDITS_PACKAGES is not valid JSON: ${error.message}`);
|
||||
}
|
||||
} else if (creditsEnabled) {
|
||||
warnings.push('Credits mode is using built-in package defaults; set CREDITS_PACKAGES explicitly for production pricing.');
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
// A Such Software operated-product identifier is an exact public claim in every
|
||||
// environment. Never let a development or preview process impersonate that identity
|
||||
// while bypassing the host/network/economic contract.
|
||||
errors.push(...validateOperatedProductProfile(env, config));
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
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}`));
|
||||
|
||||
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;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EnvironmentValidator;
|
||||
@@ -0,0 +1,210 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Opt-in contracts for the two public products operated by Such Software.
|
||||
*
|
||||
* Independent MIT deployments do not set OPERATED_PRODUCT_PROFILE and are deliberately not
|
||||
* constrained by these product decisions. Official production environments do set it, causing
|
||||
* preflight and normal startup to reject any network, identity, or economic-scope drift.
|
||||
*/
|
||||
|
||||
const OPERATED_PRODUCT_PROFILE_IDS = Object.freeze({
|
||||
WOW_PRESTIGE: 'such-play-wow-prestige',
|
||||
XMR_STAGENET: 'such-monerogue-stagenet'
|
||||
});
|
||||
|
||||
const SOURCE_SOFTWARE = Object.freeze({
|
||||
publisherName: 'Such Software',
|
||||
contactUrl: 'mailto:apps@such.software',
|
||||
contactLabel: 'apps@such.software',
|
||||
license: 'MIT',
|
||||
rightsNotice: 'Permission to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies is granted under the MIT License only if the copyright and permission notice is included in all copies or substantial portions.',
|
||||
warrantyNotice: 'The software is provided “AS IS”, without warranty of any kind, express or implied, to the fullest extent stated in the MIT License.',
|
||||
legalAdviceNotice: 'Documentation, examples, and product disclosures are informational only; they are not legal advice or a determination that any deployment complies with applicable law.',
|
||||
thirdPartyNotice: 'A third-party or self-hosted operator is solely responsible for its deployment, product design, legal compliance, funds, players, claims, and support. The MIT License does not make that deployment a Such Software service or authorize it to claim Such Software sponsorship or endorsement. Such Software does not operate, supervise, endorse, or accept responsibility for third-party deployments.',
|
||||
operatedBoundaryNotice: 'Such Software operates only play.wowne.ro and monerogue.app, and only within the product scopes stated for those services. It does not operate or endorse deployments made from this source by anyone else.'
|
||||
});
|
||||
|
||||
const PROFILES = Object.freeze({
|
||||
[OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE]: Object.freeze({
|
||||
id: OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE,
|
||||
gameMode: 'PAID_CREDITS',
|
||||
hostname: 'play.wowne.ro',
|
||||
publicUrl: 'https://play.wowne.ro',
|
||||
operatorName: 'Such Software',
|
||||
operatorContactUrl: SOURCE_SOFTWARE.contactUrl,
|
||||
operatorContactLabel: SOURCE_SOFTWARE.contactLabel,
|
||||
legalPolicyVersion: '2026-07-22-v1',
|
||||
termsEffectiveDate: '2026-07-22',
|
||||
scopeNotice: 'Such Software operates play.wowne.ro only as Wownero mainnet free play and pay-for-credits leaderboard/prestige play. Credits are non-redeemable service entitlements. The service offers no prizes, winnings, cash-out, or payouts and is not offered or marketed as gambling; legal classification depends on applicable law.',
|
||||
commerceSummary: 'This purchase grants non-redeemable service credits for leaderboard/prestige play only. play.wowne.ro offers no prize, payout, or cash-out and is not marketed as gambling.',
|
||||
noRealValueNotice: null
|
||||
}),
|
||||
[OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET]: Object.freeze({
|
||||
id: OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET,
|
||||
gameMode: 'PAID_SINGLE',
|
||||
hostname: 'monerogue.app',
|
||||
publicUrl: 'https://monerogue.app',
|
||||
operatorName: 'Such Software',
|
||||
operatorContactUrl: SOURCE_SOFTWARE.contactUrl,
|
||||
operatorContactLabel: SOURCE_SOFTWARE.contactLabel,
|
||||
legalPolicyVersion: '2026-07-23-v2',
|
||||
termsEffectiveDate: '2026-07-23',
|
||||
scopeNotice: 'Such Software operates monerogue.app only with direct-entry Monero stagenet test coins. Its single-player 2×/3× outcomes are test gambling mechanics; purchased-credit entry is disabled, and no real-money or mainnet gambling is offered.',
|
||||
commerceSummary: 'NO REAL VALUE: monerogue.app uses direct-entry Monero stagenet test coins only. Single-player qualifying outcomes pay 2× or 3× in test coins. Purchased-credit entry is off. Never send mainnet XMR.',
|
||||
noRealValueNotice: 'NO REAL VALUE: Monero stagenet coins are test data, not money, deposits, redeemable prizes, or a promise of value. Never send mainnet XMR to this service.'
|
||||
})
|
||||
});
|
||||
|
||||
const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
|
||||
const FALSE_VALUES = new Set(['false', '0', 'no', 'off']);
|
||||
|
||||
function isExplicitTrue(value) {
|
||||
return TRUE_VALUES.has(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isExplicitFalse(value) {
|
||||
return FALSE_VALUES.has(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function selectedProfileId(env = process.env) {
|
||||
return String(env.OPERATED_PRODUCT_PROFILE || '').trim();
|
||||
}
|
||||
|
||||
function getOperatedProductProfile(env = process.env) {
|
||||
return PROFILES[selectedProfileId(env)] || null;
|
||||
}
|
||||
|
||||
function selectPublicPaidModeDescriptors(env = process.env, descriptors = {}) {
|
||||
const profile = getOperatedProductProfile(env);
|
||||
if (!profile) {
|
||||
return { ...descriptors };
|
||||
}
|
||||
|
||||
const descriptor = descriptors[profile.gameMode];
|
||||
return descriptor === undefined ? {} : { [profile.gameMode]: descriptor };
|
||||
}
|
||||
|
||||
function isExactPublicRoot(value, expected) {
|
||||
try {
|
||||
const actual = new URL(String(value || ''));
|
||||
const target = new URL(expected);
|
||||
return actual.protocol === 'https:'
|
||||
&& actual.username === ''
|
||||
&& actual.password === ''
|
||||
&& actual.origin === target.origin
|
||||
&& (actual.pathname === '' || actual.pathname === '/')
|
||||
&& actual.search === ''
|
||||
&& actual.hash === '';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function exactMultiplier(value, expected) {
|
||||
return String(value ?? '').trim() !== '' && Number(value) === expected;
|
||||
}
|
||||
|
||||
function hasOutOfScopeProduct(config) {
|
||||
const standaloneProducts = config?.products?.cosmetic || [];
|
||||
const creditPackages = config?.modes?.credits?.packages || [];
|
||||
|
||||
// These two operated products sell only the top-level credits/bonus declared by a credit
|
||||
// package. Reject the entire extensible grants object, including unknown future keys, so a
|
||||
// new entitlement cannot silently widen the public product contract.
|
||||
return standaloneProducts.length > 0
|
||||
|| creditPackages.some(product => product
|
||||
&& Object.prototype.hasOwnProperty.call(product, 'grants'));
|
||||
}
|
||||
|
||||
function validateOperatedProductProfile(env = process.env, config = {}) {
|
||||
const id = selectedProfileId(env);
|
||||
if (!id) return [];
|
||||
|
||||
const profile = PROFILES[id];
|
||||
if (!profile) {
|
||||
return [`OPERATED_PRODUCT_PROFILE=${id} is unknown. Remove it for an independent MIT deployment or use a reviewed Such Software profile.`];
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const requireContract = (condition, requirement) => {
|
||||
if (!condition) errors.push(`OPERATED_PRODUCT_PROFILE=${id} requires ${requirement}.`);
|
||||
};
|
||||
const cryptoType = String(env.CRYPTO_TYPE || config?.currency?.symbol || '').trim().toUpperCase();
|
||||
const network = String(env.MONERO_NETWORK || '').trim().toLowerCase();
|
||||
|
||||
requireContract(isExactPublicRoot(env.HOSTED_BY, profile.publicUrl), `HOSTED_BY=${profile.publicUrl}`);
|
||||
requireContract(String(env.OPERATOR_NAME || '').trim() === profile.operatorName,
|
||||
`OPERATOR_NAME=${profile.operatorName}`);
|
||||
requireContract(String(env.OPERATOR_CONTACT_URL || '').trim() === profile.operatorContactUrl,
|
||||
`OPERATOR_CONTACT_URL=${profile.operatorContactUrl}`);
|
||||
requireContract(String(env.OPERATOR_CONTACT_LABEL || '').trim() === profile.operatorContactLabel,
|
||||
`OPERATOR_CONTACT_LABEL=${profile.operatorContactLabel}`);
|
||||
requireContract(String(env.LEGAL_POLICY_VERSION || '').trim() === profile.legalPolicyVersion,
|
||||
`LEGAL_POLICY_VERSION=${profile.legalPolicyVersion}`);
|
||||
requireContract(String(env.TERMS_EFFECTIVE_DATE || '').trim() === profile.termsEffectiveDate,
|
||||
`TERMS_EFFECTIVE_DATE=${profile.termsEffectiveDate}`);
|
||||
requireContract(isExplicitTrue(env.PAYMENTS_ENABLED) && config?.paymentsEnabled === true,
|
||||
'PAYMENTS_ENABLED=true');
|
||||
requireContract(String(env.GAME_MODE || '').trim() === profile.gameMode,
|
||||
`GAME_MODE=${profile.gameMode}`);
|
||||
requireContract(isExplicitTrue(env.PAID_ACKNOWLEDGEMENT_REQUIRED),
|
||||
'PAID_ACKNOWLEDGEMENT_REQUIRED=true');
|
||||
requireContract(isExplicitTrue(env.FREE_PLAY_ENABLED), 'FREE_PLAY_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.SOLO_ENABLED), 'SOLO_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.TAVERN_ENABLED), 'TAVERN_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.MATCH_ENABLED), 'MATCH_ENABLED=true');
|
||||
requireContract(isExplicitFalse(env.MATCH_CRYPTO_RACE_ENABLED), 'MATCH_CRYPTO_RACE_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.MATCH_PAYOUTS_ENABLED), 'MATCH_PAYOUTS_ENABLED=false');
|
||||
requireContract(!hasOutOfScopeProduct(config),
|
||||
'no standalone products or bundled entitlement grants; credit packages may grant only their top-level credits and bonus');
|
||||
|
||||
if (id === OPERATED_PRODUCT_PROFILE_IDS.WOW_PRESTIGE) {
|
||||
requireContract(cryptoType === 'WOW', 'CRYPTO_TYPE=WOW');
|
||||
requireContract(network === 'mainnet', 'MONERO_NETWORK=mainnet');
|
||||
requireContract(String(env.PAYMENT_MODES || '').trim().toLowerCase() === 'credits',
|
||||
'PAYMENT_MODES=credits');
|
||||
requireContract(isExplicitFalse(env.DIRECT_PAYMENT_ENABLED)
|
||||
&& config?.modes?.direct?.enabled === false, 'DIRECT_PAYMENT_ENABLED=false');
|
||||
requireContract(isExplicitTrue(env.CREDITS_ENABLED)
|
||||
&& config?.modes?.credits?.enabled === true, 'CREDITS_ENABLED=true');
|
||||
requireContract(isExplicitFalse(env.PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.enabled === false, 'PAYOUTS_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.DIRECT_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.direct?.enabled === false, 'DIRECT_PAYOUTS_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.CREDITS_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.credits?.enabled === false, 'CREDITS_PAYOUTS_ENABLED=false');
|
||||
requireContract(isExplicitFalse(env.ALLOW_MAINNET_PAYOUTS), 'ALLOW_MAINNET_PAYOUTS=false');
|
||||
}
|
||||
|
||||
if (id === OPERATED_PRODUCT_PROFILE_IDS.XMR_STAGENET) {
|
||||
requireContract(cryptoType === 'XMR', 'CRYPTO_TYPE=XMR');
|
||||
requireContract(network === 'stagenet', 'MONERO_NETWORK=stagenet');
|
||||
requireContract(String(env.PAYMENT_MODES || '').trim().toLowerCase() === 'direct',
|
||||
'PAYMENT_MODES=direct');
|
||||
requireContract(isExplicitTrue(env.DIRECT_PAYMENT_ENABLED)
|
||||
&& config?.modes?.direct?.enabled === true, 'DIRECT_PAYMENT_ENABLED=true');
|
||||
requireContract(isExplicitFalse(env.CREDITS_ENABLED)
|
||||
&& config?.modes?.credits?.enabled === false, 'CREDITS_ENABLED=false');
|
||||
requireContract(isExplicitTrue(env.PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.enabled === true, 'PAYOUTS_ENABLED=true');
|
||||
requireContract(isExplicitTrue(env.DIRECT_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.direct?.enabled === true, 'DIRECT_PAYOUTS_ENABLED=true');
|
||||
requireContract(isExplicitFalse(env.CREDITS_PAYOUTS_ENABLED)
|
||||
&& config?.payouts?.rules?.credits?.enabled === false, 'CREDITS_PAYOUTS_ENABLED=false');
|
||||
requireContract(exactMultiplier(env.DIRECT_PAYOUT_ESCAPE, 2), 'DIRECT_PAYOUT_ESCAPE=2');
|
||||
requireContract(exactMultiplier(env.DIRECT_PAYOUT_TREASURE, 3), 'DIRECT_PAYOUT_TREASURE=3');
|
||||
requireContract(isExplicitFalse(env.ALLOW_MAINNET_PAYOUTS), 'ALLOW_MAINNET_PAYOUTS=false');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OPERATED_PRODUCT_PROFILE_IDS,
|
||||
SOURCE_SOFTWARE,
|
||||
getOperatedProductProfile,
|
||||
selectPublicPaidModeDescriptors,
|
||||
selectedProfileId,
|
||||
validateOperatedProductProfile
|
||||
};
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* Payment Configuration Manager
|
||||
* Loads unified payment settings from defaults, environment variables, and (future) database overrides.
|
||||
*/
|
||||
|
||||
const { EventEmitter } = require('events');
|
||||
const { inferCurrencyDecimals } = require('../game/helpers/gameModeUtils');
|
||||
|
||||
const DEFAULT_CONFIG = Object.freeze({
|
||||
paymentsEnabled: true,
|
||||
currency: {
|
||||
symbol: 'WOW',
|
||||
decimals: 11,
|
||||
minPayment: 1000000000n, // 0.01 WOW in atomic units
|
||||
maxPayment: 100000000000000n // 1,000 WOW in atomic units
|
||||
},
|
||||
// Early entry allows free/credits mode players to start immediately without waiting for next block
|
||||
// Risk: If next block is found before they escape, they lose (timeout)
|
||||
earlyEntry: {
|
||||
enabled: true, // Master toggle for early entry feature
|
||||
allowInFreeMode: true, // Allow early entry in free mode
|
||||
allowInCreditsMode: true // Allow early entry when using credits
|
||||
},
|
||||
modes: {
|
||||
direct: {
|
||||
enabled: true,
|
||||
price: 100000000000n, // 1 WOW per game
|
||||
requiresAddress: true,
|
||||
allowGuestPlay: false
|
||||
},
|
||||
credits: {
|
||||
enabled: false,
|
||||
creditsPerGame: 1,
|
||||
requiresAddress: true,
|
||||
allowMixedMode: true,
|
||||
packages: [
|
||||
{ id: 'small', credits: 10, price: 500000000000n, bonus: 0 },
|
||||
{ id: 'medium', credits: 25, price: 1000000000000n, bonus: 2 },
|
||||
{ id: 'large', credits: 100, price: 3500000000000n, bonus: 15 }
|
||||
]
|
||||
}
|
||||
},
|
||||
payouts: {
|
||||
enabled: true,
|
||||
requiresKYC: false,
|
||||
rules: {
|
||||
direct: {
|
||||
enabled: true,
|
||||
multipliers: {
|
||||
escape: 2.0,
|
||||
escapeWithTreasure: 3.0
|
||||
},
|
||||
minPayout: 1000000000n,
|
||||
maxPayout: 10000000000000n
|
||||
},
|
||||
credits: {
|
||||
enabled: false,
|
||||
multipliers: {
|
||||
escape: 2.0,
|
||||
escapeWithTreasure: 3.0
|
||||
},
|
||||
baseValue: 100000000000n,
|
||||
minPayout: 1000000000n,
|
||||
maxPayout: 5000000000000n
|
||||
}
|
||||
},
|
||||
processing: {
|
||||
automatic: true,
|
||||
batchingEnabled: true,
|
||||
batchInterval: 300,
|
||||
confirmations: 1,
|
||||
maxRetries: 3
|
||||
}
|
||||
},
|
||||
promotions: {
|
||||
enabled: false,
|
||||
freeCredits: {
|
||||
onSignup: 0,
|
||||
onFirstPurchase: 0,
|
||||
referralBonus: 0
|
||||
},
|
||||
discounts: {
|
||||
enabled: false,
|
||||
bulkDiscount: 0,
|
||||
happyHour: null
|
||||
}
|
||||
},
|
||||
products: {
|
||||
// Standalone products that do not grant a game entry. Example:
|
||||
// { id: 'pack_3d', label: '3D Character Pack', price: 500000000000n,
|
||||
// grants: { packs: ['kenney-3d-characters'] } }
|
||||
cosmetic: []
|
||||
},
|
||||
limits: {
|
||||
maxGamesPerHour: 60,
|
||||
maxPayoutsPerDay: 100,
|
||||
maxCreditPurchasePerDay: 100000000000000n,
|
||||
cooldownBetweenGames: 5
|
||||
},
|
||||
preferences: {
|
||||
allowMixedMode: true,
|
||||
preferCreditsFirst: true
|
||||
}
|
||||
});
|
||||
|
||||
function cloneConfig(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(cloneConfig);
|
||||
}
|
||||
if (value && typeof value === 'object' && !(value instanceof EventEmitter)) {
|
||||
return Object.entries(value).reduce((acc, [key, val]) => {
|
||||
acc[key] = cloneConfig(val);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', '0', 'no', 'n', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (Number.isInteger(value)) {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
|
||||
if (normalized === '') {
|
||||
return fallback;
|
||||
}
|
||||
const num = Number(normalized);
|
||||
if (!Number.isFinite(num)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(num);
|
||||
}
|
||||
|
||||
function parseAtomicValue(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
// 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(value);
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().toLowerCase().replace(/_/g, '');
|
||||
if (normalized === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
const num = Number(normalized);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
function safeParseJson(value, fallback) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneGrantPayload(grants) {
|
||||
if (!grants || typeof grants !== 'object') return undefined;
|
||||
return cloneConfig(grants);
|
||||
}
|
||||
|
||||
function normalizeConfiguredProduct(product, fallback = {}) {
|
||||
if (!product || typeof product !== 'object') return null;
|
||||
const id = product.id || fallback.id;
|
||||
if (!id) return null;
|
||||
const out = {
|
||||
id,
|
||||
label: product.label || fallback.label || id,
|
||||
price: parseAtomicValue(product.price, fallback.price || 0n)
|
||||
};
|
||||
if (product.credits != null || fallback.credits != null) {
|
||||
out.credits = parseInteger(product.credits, fallback.credits || 0);
|
||||
}
|
||||
if (product.bonus != null || fallback.bonus != null) {
|
||||
out.bonus = parseInteger(product.bonus || 0, fallback.bonus || 0);
|
||||
}
|
||||
const grants = cloneGrantPayload(product.grants || fallback.grants);
|
||||
if (grants) out.grants = grants;
|
||||
return out;
|
||||
}
|
||||
|
||||
class PaymentConfigManager {
|
||||
constructor(options = {}) {
|
||||
this.logger = options.logger || console;
|
||||
this.eventBus = options.eventBus || new EventEmitter();
|
||||
this.persistenceAdapter = options.persistenceAdapter || null;
|
||||
this.config = this.loadConfig();
|
||||
this.validateConfig();
|
||||
this.legacyOverride = process.env.GAME_MODE || null;
|
||||
}
|
||||
|
||||
loadConfig() {
|
||||
const config = cloneConfig(DEFAULT_CONFIG);
|
||||
|
||||
// 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') {
|
||||
config.paymentsEnabled = false;
|
||||
config.modes.direct.enabled = false;
|
||||
config.modes.credits.enabled = false;
|
||||
} else if (gameModeOverride === 'PAID_SINGLE') {
|
||||
config.paymentsEnabled = true;
|
||||
config.modes.direct.enabled = true;
|
||||
config.modes.credits.enabled = false;
|
||||
} else if (gameModeOverride === 'PAID_CREDITS') {
|
||||
config.paymentsEnabled = true;
|
||||
config.modes.direct.enabled = false;
|
||||
config.modes.credits.enabled = true;
|
||||
}
|
||||
|
||||
if (process.env.PAYMENT_MODES) {
|
||||
const modes = process.env.PAYMENT_MODES.split(',').map(m => m.trim().toLowerCase()).filter(Boolean);
|
||||
config.modes.direct.enabled = modes.includes('direct');
|
||||
config.modes.credits.enabled = modes.includes('credits');
|
||||
config.paymentsEnabled = config.modes.direct.enabled || config.modes.credits.enabled;
|
||||
}
|
||||
|
||||
config.currency.symbol = process.env.CRYPTO_TYPE || config.currency.symbol;
|
||||
// Derive decimals from the currency (XMR=12, WOW=11) so display divisors match the
|
||||
// chain. Previously decimals stayed at the WOW default (11) even for XMR, making all
|
||||
// XMR amounts display 10x too large. An explicit CURRENCY_DECIMALS env still wins.
|
||||
config.currency.decimals = process.env.CURRENCY_DECIMALS
|
||||
? parseInt(process.env.CURRENCY_DECIMALS, 10)
|
||||
: inferCurrencyDecimals(config.currency.symbol);
|
||||
config.currency.minPayment = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.currency.minPayment);
|
||||
config.currency.maxPayment = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.currency.maxPayment);
|
||||
|
||||
const directPriceEnv = process.env.DIRECT_GAME_PRICE ?? process.env.SINGLE_GAME_PRICE;
|
||||
config.modes.direct.price = parseAtomicValue(directPriceEnv, config.modes.direct.price);
|
||||
config.modes.direct.enabled = parseBoolean(process.env.DIRECT_PAYMENT_ENABLED, config.modes.direct.enabled);
|
||||
config.modes.direct.requiresAddress = parseBoolean(process.env.DIRECT_REQUIRES_ADDRESS, config.modes.direct.requiresAddress);
|
||||
config.modes.direct.allowGuestPlay = parseBoolean(process.env.DIRECT_ALLOW_GUEST_PLAY, config.modes.direct.allowGuestPlay);
|
||||
|
||||
const creditsEnabled = parseBoolean(process.env.CREDITS_ENABLED, undefined);
|
||||
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);
|
||||
|
||||
const packageJson = safeParseJson(process.env.CREDITS_PACKAGES, null);
|
||||
if (Array.isArray(packageJson) && packageJson.length > 0) {
|
||||
config.modes.credits.packages = packageJson.map(pkg => normalizeConfiguredProduct(pkg, {
|
||||
credits: 0,
|
||||
bonus: 0,
|
||||
price: 0n
|
||||
})).filter(pkg => pkg && pkg.id);
|
||||
} else if (process.env.CREDITS_PACKAGE_PRICE) {
|
||||
const overridePrice = parseAtomicValue(process.env.CREDITS_PACKAGE_PRICE, null);
|
||||
if (overridePrice !== null) {
|
||||
config.modes.credits.packages = config.modes.credits.packages.map((pkg, index) =>
|
||||
index === 0 ? { ...pkg, price: overridePrice } : pkg
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cosmeticProducts = safeParseJson(process.env.COSMETIC_PRODUCTS, null);
|
||||
if (Array.isArray(cosmeticProducts)) {
|
||||
config.products.cosmetic = cosmeticProducts
|
||||
.map(product => normalizeConfiguredProduct(product, { price: 0n }))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
config.payouts.enabled = parseBoolean(process.env.PAYOUTS_ENABLED, config.payouts.enabled);
|
||||
config.payouts.rules.direct.enabled = parseBoolean(process.env.DIRECT_PAYOUTS_ENABLED, config.payouts.rules.direct.enabled);
|
||||
config.payouts.rules.direct.multipliers.escape = parseFloatValue(process.env.DIRECT_PAYOUT_ESCAPE, config.payouts.rules.direct.multipliers.escape);
|
||||
config.payouts.rules.direct.multipliers.escapeWithTreasure = parseFloatValue(process.env.DIRECT_PAYOUT_TREASURE, config.payouts.rules.direct.multipliers.escapeWithTreasure);
|
||||
config.payouts.rules.direct.minPayout = parseAtomicValue(process.env.PAYOUT_MIN_AMOUNT, config.payouts.rules.direct.minPayout);
|
||||
config.payouts.rules.direct.maxPayout = parseAtomicValue(process.env.PAYOUT_MAX_PER_GAME, config.payouts.rules.direct.maxPayout);
|
||||
|
||||
const creditsPayoutEnabled = parseBoolean(process.env.CREDITS_PAYOUTS_ENABLED ?? process.env.CREDITS_PAYOUT_ENABLED, undefined);
|
||||
if (creditsPayoutEnabled !== undefined) {
|
||||
config.payouts.rules.credits.enabled = creditsPayoutEnabled;
|
||||
}
|
||||
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);
|
||||
config.limits.maxPayoutsPerDay = parseInteger(process.env.MAX_PAYOUTS_PER_DAY, config.limits.maxPayoutsPerDay);
|
||||
config.limits.maxCreditPurchasePerDay = parseAtomicValue(process.env.MAX_CREDIT_PURCHASE_PER_DAY, config.limits.maxCreditPurchasePerDay);
|
||||
config.limits.cooldownBetweenGames = parseInteger(process.env.GAME_COOLDOWN_SECONDS, config.limits.cooldownBetweenGames);
|
||||
|
||||
config.preferences.allowMixedMode = config.modes.credits.allowMixedMode;
|
||||
config.preferences.preferCreditsFirst = parseBoolean(process.env.PREFER_CREDITS_FIRST, config.preferences.preferCreditsFirst);
|
||||
|
||||
config.earlyEntry.enabled = parseBoolean(process.env.EARLY_ENTRY_ENABLED, config.earlyEntry.enabled);
|
||||
config.earlyEntry.allowInFreeMode = parseBoolean(process.env.EARLY_ENTRY_FREE_MODE, config.earlyEntry.allowInFreeMode);
|
||||
config.earlyEntry.allowInCreditsMode = parseBoolean(process.env.EARLY_ENTRY_CREDITS_MODE, config.earlyEntry.allowInCreditsMode);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return cloneConfig(this.config);
|
||||
}
|
||||
|
||||
getLegacyGameMode() {
|
||||
if (!this.config.paymentsEnabled) {
|
||||
return 'FREE';
|
||||
}
|
||||
const directEnabled = !!this.config.modes.direct.enabled;
|
||||
const creditsEnabled = !!this.config.modes.credits.enabled;
|
||||
const legacyOverride = String(this.legacyOverride || '').trim().toUpperCase();
|
||||
// GAME_MODE is legacy compatibility input, not authority over the parsed payment
|
||||
// policy. Honor it only when the selected mode is actually enabled; contradictory
|
||||
// PAYMENT_MODES/per-mode switches must never be misreported to sockets or health.
|
||||
if (legacyOverride === 'PAID_SINGLE' && directEnabled) {
|
||||
return 'PAID_SINGLE';
|
||||
}
|
||||
if (legacyOverride === 'PAID_CREDITS' && creditsEnabled) {
|
||||
return 'PAID_CREDITS';
|
||||
}
|
||||
if (directEnabled && !creditsEnabled) {
|
||||
return 'PAID_SINGLE';
|
||||
}
|
||||
if (!directEnabled && creditsEnabled) {
|
||||
return 'PAID_CREDITS';
|
||||
}
|
||||
if (!directEnabled && !creditsEnabled) {
|
||||
return 'FREE';
|
||||
}
|
||||
return this.config.preferences.preferCreditsFirst ? 'PAID_CREDITS' : 'PAID_SINGLE';
|
||||
}
|
||||
|
||||
isModeEnabled(mode) {
|
||||
return Boolean(this.config.modes[mode]?.enabled);
|
||||
}
|
||||
|
||||
validateConfig() {
|
||||
const directEnabled = this.config.modes.direct.enabled;
|
||||
const creditsEnabled = this.config.modes.credits.enabled;
|
||||
|
||||
if (!this.config.paymentsEnabled && (directEnabled || creditsEnabled)) {
|
||||
this.logger.warn?.('⚠️ paymentsEnabled is false but some payment modes are enabled. Payments will remain disabled.');
|
||||
}
|
||||
|
||||
if (!directEnabled && !creditsEnabled && this.config.paymentsEnabled) {
|
||||
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;
|
||||
}
|
||||
|
||||
validatePrices() {
|
||||
const { direct, credits } = this.config.modes;
|
||||
if (direct.enabled && direct.price <= 0n) {
|
||||
throw new Error('Direct game price must be a positive value');
|
||||
}
|
||||
if (credits.enabled) {
|
||||
for (const pkg of credits.packages) {
|
||||
if (!pkg.id) {
|
||||
throw new Error('Each credit package requires an id');
|
||||
}
|
||||
if (pkg.price <= 0n) {
|
||||
throw new Error(`Credit package ${pkg.id} has invalid price`);
|
||||
}
|
||||
if (pkg.credits <= 0) {
|
||||
throw new Error(`Credit package ${pkg.id} has invalid credit count`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const product of this.config.products.cosmetic || []) {
|
||||
if (!product.id) {
|
||||
throw new Error('Each cosmetic product requires an id');
|
||||
}
|
||||
if (product.price <= 0n) {
|
||||
throw new Error(`Cosmetic product ${product.id} has invalid price`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateConfig(updates) {
|
||||
const newConfig = cloneConfig(this.config);
|
||||
Object.keys(updates || {}).forEach(key => {
|
||||
newConfig[key] = updates[key];
|
||||
});
|
||||
|
||||
const testManager = new PaymentConfigManager({
|
||||
logger: this.logger,
|
||||
eventBus: this.eventBus,
|
||||
persistenceAdapter: this.persistenceAdapter
|
||||
});
|
||||
testManager.config = cloneConfig(newConfig);
|
||||
testManager.validateConfig();
|
||||
|
||||
this.config = newConfig;
|
||||
if (this.persistenceAdapter && typeof this.persistenceAdapter.save === 'function') {
|
||||
await this.persistenceAdapter.save(newConfig);
|
||||
}
|
||||
this.broadcastConfigUpdate();
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.config = this.loadConfig();
|
||||
this.validateConfig();
|
||||
this.broadcastConfigUpdate();
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
broadcastConfigUpdate() {
|
||||
if (this.eventBus && typeof this.eventBus.emit === 'function') {
|
||||
this.eventBus.emit('paymentConfig:update', this.getConfig());
|
||||
}
|
||||
}
|
||||
|
||||
setPersistenceAdapter(adapter) {
|
||||
this.persistenceAdapter = adapter;
|
||||
}
|
||||
|
||||
setEventBus(eventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
summarize() {
|
||||
return {
|
||||
paymentsEnabled: this.config.paymentsEnabled,
|
||||
legacyMode: this.getLegacyGameMode(),
|
||||
directEnabled: this.config.modes.direct.enabled,
|
||||
creditsEnabled: this.config.modes.credits.enabled,
|
||||
directPrice: this.config.modes.direct.price,
|
||||
creditPackages: this.config.modes.credits.packages.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
PaymentConfigManager.DEFAULT_CONFIG = DEFAULT_CONFIG;
|
||||
|
||||
module.exports = PaymentConfigManager;
|
||||
@@ -0,0 +1,71 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const RELEASE_ID_PATTERN = /^git-([0-9a-f]{12})$/;
|
||||
const COMMIT_PATTERN = /^[0-9a-f]{40}$/;
|
||||
const MAX_FILE_BYTES = 128;
|
||||
|
||||
function readBoundedRegularFile(filePath) {
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW
|
||||
);
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile()
|
||||
|| before.nlink !== 1
|
||||
|| (before.mode & 0o222) !== 0
|
||||
|| before.size < 1
|
||||
|| before.size > MAX_FILE_BYTES
|
||||
) {
|
||||
throw new Error('release identity file has unsafe shape');
|
||||
}
|
||||
const value = fs.readFileSync(descriptor, { encoding: 'utf8' }).trim();
|
||||
const after = fs.fstatSync(descriptor);
|
||||
if (
|
||||
before.dev !== after.dev
|
||||
|| before.ino !== after.ino
|
||||
|| before.size !== after.size
|
||||
|| before.mtimeMs !== after.mtimeMs
|
||||
) {
|
||||
throw new Error('release identity file changed while it was read');
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function unverifiedIdentity() {
|
||||
return Object.freeze({ verified: false, id: null, commit: null });
|
||||
}
|
||||
|
||||
function loadReleaseIdentity(options = {}) {
|
||||
const production = options.production === undefined
|
||||
? process.env.NODE_ENV === 'production'
|
||||
: Boolean(options.production);
|
||||
const releaseRoot = options.releaseRoot || path.resolve(__dirname, '..', '..');
|
||||
|
||||
try {
|
||||
const id = readBoundedRegularFile(path.join(releaseRoot, '.release-id'));
|
||||
const commit = readBoundedRegularFile(path.join(releaseRoot, '.release-commit'));
|
||||
const idMatch = id.match(RELEASE_ID_PATTERN);
|
||||
if (!idMatch || !COMMIT_PATTERN.test(commit) || idMatch[1] !== commit.slice(0, 12)) {
|
||||
throw new Error('release identity files disagree');
|
||||
}
|
||||
return Object.freeze({ verified: true, id, commit });
|
||||
} catch (error) {
|
||||
if (production) {
|
||||
throw new Error('production release identity is absent or invalid', { cause: error });
|
||||
}
|
||||
return unverifiedIdentity();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMMIT_PATTERN,
|
||||
RELEASE_ID_PATTERN,
|
||||
loadReleaseIdentity,
|
||||
readBoundedRegularFile
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Database Connection Manager
|
||||
* Handles PostgreSQL connection pool and migration management
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Query validator to detect potential SQL injection
|
||||
*/
|
||||
class QueryValidator {
|
||||
isSafe(text, params) {
|
||||
// Check if using parameterized query properly
|
||||
// Count unique placeholders ($1, $2, etc.): PostgreSQL allows reusing $1 multiple times
|
||||
const placeholders = text.match(/\$\d+/g) || [];
|
||||
const uniquePlaceholders = new Set(placeholders);
|
||||
const placeholderCount = uniquePlaceholders.size;
|
||||
if (placeholderCount !== params.length) {
|
||||
console.warn('⚠️ Parameter count mismatch in query');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
validateQuery(query) {
|
||||
if (typeof query === 'string') {
|
||||
console.warn('⚠️ Raw string query detected - should use parameterized queries');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseManager {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
this.connected = false;
|
||||
this.lastSuccessfulQueryAt = 0;
|
||||
this.lastFailedQueryAt = 0;
|
||||
this.queryValidator = new QueryValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection and run migrations
|
||||
*/
|
||||
async initialize() {
|
||||
const connected = await this.init();
|
||||
if (connected) {
|
||||
await this.runMigrations();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database connection
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
this.pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'wownerogue',
|
||||
user: process.env.DB_USER || 'wownerogue',
|
||||
password: process.env.DB_PASSWORD,
|
||||
max: 40, // Maximum number of clients in the pool
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
// Add query monitoring for security
|
||||
this.pool.on('query', (query) => {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
this.queryValidator.validateQuery(query);
|
||||
}
|
||||
});
|
||||
|
||||
// Test connection
|
||||
const client = await this.pool.connect();
|
||||
await client.query('SELECT NOW()');
|
||||
client.release();
|
||||
|
||||
this.connected = true;
|
||||
this.lastSuccessfulQueryAt = Date.now();
|
||||
console.log('✅ Database connected successfully');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Database connection failed:', error.message);
|
||||
this.connected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run database migrations
|
||||
*/
|
||||
async runMigrations() {
|
||||
try {
|
||||
// Simple migration ledger
|
||||
await this.pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (filename TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT NOW())`);
|
||||
|
||||
const migrationsPath = path.join(__dirname, '../migrations');
|
||||
const files = await fs.readdir(migrationsPath);
|
||||
const sqlFiles = files.filter(file => file.endsWith('.sql')).sort();
|
||||
|
||||
console.log(`📁 Found ${sqlFiles.length} migration files`);
|
||||
|
||||
for (const file of sqlFiles) {
|
||||
// Skip if already applied
|
||||
const existing = await this.pool.query(`SELECT 1 FROM schema_migrations WHERE filename = $1`, [file]);
|
||||
if (existing.rowCount > 0) {
|
||||
console.log(`⏩ Skipping already applied migration: ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(migrationsPath, file);
|
||||
const sql = await fs.readFile(filePath, 'utf8');
|
||||
console.log(`🔄 Running migration: ${file}`);
|
||||
|
||||
// Apply the migration AND record it in the ledger atomically in one
|
||||
// transaction, so a migration can never be applied-but-unrecorded (which
|
||||
// would re-run a possibly-non-idempotent migration on the next boot).
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(sql);
|
||||
await client.query(`INSERT INTO schema_migrations (filename) VALUES ($1)`, [file]);
|
||||
await client.query('COMMIT');
|
||||
} catch (migErr) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
throw migErr;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
console.log(`✅ Migration completed: ${file}`);
|
||||
}
|
||||
|
||||
console.log('✅ Migration phase complete');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query with security validation
|
||||
*/
|
||||
async query(text, params = []) {
|
||||
if (!this.connected) {
|
||||
throw new Error('Database not connected');
|
||||
}
|
||||
|
||||
// Validate query structure for security
|
||||
if (!this.queryValidator.isSafe(text, params)) {
|
||||
throw new Error('Potentially unsafe query detected');
|
||||
}
|
||||
|
||||
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') {
|
||||
console.log('📊 Query executed:', { text: text.substring(0, 100), duration, rows: result.rowCount });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.lastFailedQueryAt = Date.now();
|
||||
console.error('❌ Database query error:', error.message);
|
||||
console.error('Query:', text);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to ensure parameterized queries
|
||||
*/
|
||||
async safeQuery(template, values = {}) {
|
||||
const { text, params } = this.buildParameterizedQuery(template, values);
|
||||
return this.query(text, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build parameterized query from template
|
||||
*/
|
||||
buildParameterizedQuery(template, values) {
|
||||
let paramIndex = 1;
|
||||
const params = [];
|
||||
|
||||
const text = template.replace(/:(\w+)/g, (match, key) => {
|
||||
if (key in values) {
|
||||
params.push(values[key]);
|
||||
return `$${paramIndex++}`;
|
||||
}
|
||||
throw new Error(`Missing parameter: ${key}`);
|
||||
});
|
||||
|
||||
return { text, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a client from the pool for transactions
|
||||
*/
|
||||
async getClient() {
|
||||
if (!this.connected) {
|
||||
throw new Error('Database not connected');
|
||||
}
|
||||
return await this.pool.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a callback within a database transaction
|
||||
* Automatically handles BEGIN, COMMIT, and ROLLBACK
|
||||
* @param {Function} callback - Async function that receives the client
|
||||
* @returns {Promise<any>} - Result from the callback
|
||||
*/
|
||||
async withTransaction(callback) {
|
||||
const client = await this.getClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
async close() {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.connected = false;
|
||||
console.log('📴 Database connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
*/
|
||||
isConnected() {
|
||||
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
|
||||
*/
|
||||
async healthCheck() {
|
||||
try {
|
||||
const result = await this.query('SELECT 1 as status, NOW() as timestamp');
|
||||
return {
|
||||
status: 'healthy',
|
||||
timestamp: result.rows[0].timestamp,
|
||||
connected: this.connected
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
error: error.message,
|
||||
connected: false
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DatabaseManager;
|
||||
@@ -0,0 +1,99 @@
|
||||
const Game = require('../game/game');
|
||||
|
||||
// Verbose logging is limited to debug and development environments.
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
class User {
|
||||
constructor(id, address) {
|
||||
this.id = id; // socket.id
|
||||
this.address = address;
|
||||
this.currentGame = null;
|
||||
this.blockRec = 0; // Block height used for game entry timing
|
||||
this.clientId = null; // socket.client.id, a distinct value from socket.id
|
||||
|
||||
this.stats = {
|
||||
gamesPlayed: 0,
|
||||
gamesWon: 0,
|
||||
gamesLost: 0,
|
||||
totalPlayTime: 0,
|
||||
highestScore: 0,
|
||||
treasuresFound: 0,
|
||||
monstersDefeated: 0
|
||||
};
|
||||
|
||||
// Registration happens in the constructor so every User is reachable by socket id.
|
||||
userRegistry.set(id, this);
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`User created and registered: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking a new game for this user.
|
||||
* @param {Game} gameInstance - The game instance this user is playing
|
||||
*/
|
||||
joinGame(gameInstance) {
|
||||
this.currentGame = gameInstance;
|
||||
this.stats.gamesPlayed++;
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[User.joinGame] User ${this.id} joined game ${gameInstance.id}. Total games played: ${this.stats.gamesPlayed}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the end of a game and fold its results into the user statistics.
|
||||
* Does nothing when the user has no current game.
|
||||
* @param {string} result - 'won', 'lost', or 'abandoned'
|
||||
* @param {number} score - Final score
|
||||
* @param {object} gameStats - Additional game statistics
|
||||
*/
|
||||
endGame(result, score = 0, gameStats = {}) {
|
||||
if (this.currentGame) {
|
||||
this.currentGame = null;
|
||||
|
||||
if (result === 'won') {
|
||||
this.stats.gamesWon++;
|
||||
} else if (result === 'lost') {
|
||||
this.stats.gamesLost++;
|
||||
}
|
||||
|
||||
if (score > this.stats.highestScore) {
|
||||
this.stats.highestScore = score;
|
||||
}
|
||||
|
||||
if (gameStats.treasuresFound) {
|
||||
this.stats.treasuresFound += gameStats.treasuresFound;
|
||||
}
|
||||
|
||||
if (gameStats.monstersDefeated) {
|
||||
this.stats.monstersDefeated += gameStats.monstersDefeated;
|
||||
}
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[User.endGame] User ${this.id} game ended: ${result}. Score: ${score}. Stats: ${JSON.stringify(this.stats)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {object} A copy of the stats, so callers cannot mutate the user's own counters.
|
||||
*/
|
||||
getStats() {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
isInGame() {
|
||||
return this.currentGame !== null;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps socket.id to User. Declared after the class body; the constructor only reads it at call time,
|
||||
// which is always after this binding is initialised.
|
||||
const userRegistry = new Map();
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
getUserBySocketId: (socketId) => userRegistry.get(socketId),
|
||||
removeUser: (socketId) => userRegistry.delete(socketId),
|
||||
getAllUsers: () => Array.from(userRegistry.values())
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Debug Manager Module
|
||||
* Handles debug functionality and simulated block height logic for development
|
||||
* Keeps debug/development code separate from production logic
|
||||
*/
|
||||
|
||||
const packageConfig = require('../package.json');
|
||||
|
||||
class DebugManager {
|
||||
constructor(broadcastManager) {
|
||||
this.broadcastManager = broadcastManager;
|
||||
|
||||
// Environment-based configuration
|
||||
const nodeEnv = process.env.NODE_ENV || 'production';
|
||||
this.IS_PRODUCTION = nodeEnv === 'production';
|
||||
this.IS_DEBUG = nodeEnv === 'debug';
|
||||
this.IS_DEVELOPMENT = nodeEnv === 'development';
|
||||
|
||||
// Debug mode is enabled in development or debug environments
|
||||
this.DEBUG_MODE = this.IS_DEBUG || this.IS_DEVELOPMENT;
|
||||
|
||||
// Configuration precedence:
|
||||
// 1. Explicit env overrides
|
||||
// 2. Package.json config
|
||||
const config = this.IS_PRODUCTION ? packageConfig.config.production : packageConfig.config.debug;
|
||||
this.CONSOLE_LOGGING = process.env.CONSOLE_LOGGING ? process.env.CONSOLE_LOGGING === 'true' : (config.console_logging || this.DEBUG_MODE);
|
||||
this.DEBUG_HOTKEYS = process.env.DEBUG_HOTKEYS ? process.env.DEBUG_HOTKEYS === 'true' : (config.debug_hotkeys || this.DEBUG_MODE);
|
||||
// Simulation can be force-disabled by BLOCK_SOURCE=daemon or GAME_MODE paid modes
|
||||
const forceSimFlag = process.env.FORCE_SIMULATED_BLOCKS === 'true';
|
||||
const blockSource = (process.env.BLOCK_SOURCE || '').toLowerCase(); // 'daemon' | 'simulated'
|
||||
let simulatedDefault = config.simulated_blocks || this.DEBUG_MODE;
|
||||
if (blockSource === 'daemon') simulatedDefault = false;
|
||||
// In paid modes always use real daemon unless explicitly forced (for test)
|
||||
const paidMode = ['PAID_SINGLE','PAID_CREDITS'].includes(process.env.GAME_MODE);
|
||||
if (paidMode && !forceSimFlag) simulatedDefault = false;
|
||||
this.SIMULATED_BLOCKS = process.env.SIMULATED_BLOCKS ? process.env.SIMULATED_BLOCKS === 'true' : simulatedDefault;
|
||||
|
||||
this.debugBlockHeight = 1;
|
||||
this.debugInterval = null;
|
||||
this.statusInterval = null;
|
||||
this.lastProductionBlockHeight = 0;
|
||||
this.lastSuccessfulRpcAt = 0;
|
||||
|
||||
// Log configuration on startup
|
||||
this.logConfig();
|
||||
}
|
||||
|
||||
// ====== CONFIGURATION LOGGING ======
|
||||
|
||||
/**
|
||||
* Log current configuration settings
|
||||
*/
|
||||
logConfig() {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log("🔧 DEBUG MANAGER CONFIGURATION:");
|
||||
console.log(` NODE_ENV: ${process.env.NODE_ENV || 'production'}`);
|
||||
console.log(` DEBUG_MODE: ${this.DEBUG_MODE}`);
|
||||
console.log(` CONSOLE_LOGGING: ${this.CONSOLE_LOGGING}`);
|
||||
console.log(` DEBUG_HOTKEYS: ${this.DEBUG_HOTKEYS}`);
|
||||
console.log(` SIMULATED_BLOCKS: ${this.SIMULATED_BLOCKS}`);
|
||||
if (process.env.GAME_MODE && ['PAID_SINGLE','PAID_CREDITS'].includes(process.env.GAME_MODE)) {
|
||||
console.log(` GAME_MODE: ${process.env.GAME_MODE} (simulation ${this.SIMULATED_BLOCKS ? 'ENABLED' : 'DISABLED'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== INITIALIZATION ======
|
||||
|
||||
/**
|
||||
* Initialize debug mode or production mode
|
||||
*/
|
||||
initialize() {
|
||||
if (this.SIMULATED_BLOCKS) {
|
||||
this.initializeDebugMode();
|
||||
} else {
|
||||
this.initializeProductionMode();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize debug mode with simulated blocks
|
||||
*/
|
||||
initializeDebugMode() {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log("🐛 DEBUG MODE ENABLED - Simulating blocks every 30 seconds");
|
||||
}
|
||||
|
||||
// Initial debug block broadcast
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
|
||||
// Debug block height simulator - advances every 30 seconds
|
||||
this.debugInterval = setInterval(() => {
|
||||
this.debugBlockHeight++;
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`🐛 DEBUG: New simulated block: ${this.debugBlockHeight}`);
|
||||
}
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
|
||||
// Notify listeners about new block
|
||||
this.onNewBlock(this.debugBlockHeight);
|
||||
|
||||
}, 30000); // Every 30 seconds
|
||||
|
||||
// Regular status broadcasting - sends current block height every 5 seconds
|
||||
// This ensures all clients stay up-to-date even if they miss a block change
|
||||
this.statusInterval = setInterval(() => {
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
}, 5000); // Every 5 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize production mode with real blockchain calls
|
||||
*/
|
||||
initializeProductionMode() {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log("🚀 PRODUCTION MODE ENABLED - Using real blockchain RPC calls");
|
||||
}
|
||||
|
||||
// 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 || !this.rpcService.healthy) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log("❌ Failed to get block count from daemon");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Always broadcast current height to keep clients updated
|
||||
this.broadcastManager.broadcastBlockHeight(currentHeight, this.chainTip);
|
||||
|
||||
// If new block found
|
||||
if (currentHeight > this.lastProductionBlockHeight) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`⛏️ New block found: ${currentHeight}`);
|
||||
}
|
||||
|
||||
// Notify listeners about new block
|
||||
this.onNewBlock(currentHeight);
|
||||
|
||||
this.lastProductionBlockHeight = currentHeight;
|
||||
// Refresh the cosmetic tip (hash/difficulty) only on an actual new block: this
|
||||
// is decoration for the Tavern, not something worth an extra RPC every 2s.
|
||||
this._refreshChainTip(currentHeight);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.error("❌ RPC Error:", error.message);
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
// immediate first poll so UI shows real height quickly
|
||||
poll();
|
||||
this.debugInterval = setInterval(poll, 2000); // Every 2 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort refresh of the cosmetic chain tip. Never awaited by a caller and never allowed to
|
||||
* reject: a failed decoration lookup must not disturb block-driven game logic.
|
||||
*/
|
||||
_refreshChainTip(height) {
|
||||
if (!this.rpcService?.getChainTipInfo) return;
|
||||
this.rpcService.getChainTipInfo()
|
||||
.then((tip) => {
|
||||
if (!tip) return;
|
||||
this.chainTip = { ...tip, height };
|
||||
})
|
||||
.catch(() => { /* decoration only */ });
|
||||
}
|
||||
|
||||
// ====== BLOCK HEIGHT MANAGEMENT ======
|
||||
|
||||
/**
|
||||
* Cosmetic chain tip for ambient UI. Null until the first successful post-block lookup, and
|
||||
* always null under simulated blocks (there is no chain to describe).
|
||||
*/
|
||||
getChainTip() {
|
||||
return this.SIMULATED_BLOCKS ? null : (this.chainTip || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current block height (debug or production)
|
||||
* @returns {number} Current block height
|
||||
*/
|
||||
getCurrentBlockHeight() {
|
||||
if (this.SIMULATED_BLOCKS) return this.debugBlockHeight;
|
||||
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
|
||||
*/
|
||||
setDebugBlockHeight(height) {
|
||||
if (this.DEBUG_MODE) {
|
||||
this.debugBlockHeight = height;
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`🐛 DEBUG: Block height set to: ${height}`);
|
||||
}
|
||||
this.broadcastManager.broadcastBlockHeight(height);
|
||||
} else if (this.CONSOLE_LOGGING) {
|
||||
console.warn("Cannot set debug block height in production mode");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance debug block height by one (debug mode only)
|
||||
*/
|
||||
advanceDebugBlock() {
|
||||
if (this.DEBUG_MODE) {
|
||||
this.debugBlockHeight++;
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`🐛 DEBUG: Manually advanced block to: ${this.debugBlockHeight}`);
|
||||
}
|
||||
this.broadcastManager.broadcastBlockHeight(this.debugBlockHeight);
|
||||
this.onNewBlock(this.debugBlockHeight);
|
||||
} else if (this.CONSOLE_LOGGING) {
|
||||
console.warn("Cannot advance debug block in production mode");
|
||||
}
|
||||
}
|
||||
|
||||
// ====== EVENT HANDLING ======
|
||||
|
||||
/**
|
||||
* Called when a new block is detected (debug or production)
|
||||
* Override this method or set callbacks to handle new blocks
|
||||
* @param {number} blockHeight - The new block height
|
||||
*/
|
||||
onNewBlock(blockHeight) {
|
||||
// This method can be overridden or have callbacks registered
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`📦 New block detected: ${blockHeight}`);
|
||||
}
|
||||
|
||||
// Call registered callbacks
|
||||
if (this.newBlockCallbacks) {
|
||||
this.newBlockCallbacks.forEach(callback => {
|
||||
try {
|
||||
callback(blockHeight);
|
||||
} catch (error) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.error("Error in new block callback:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for when new blocks are detected
|
||||
* @param {function} callback - Function to call with new block height
|
||||
*/
|
||||
onNewBlockCallback(callback) {
|
||||
if (!this.newBlockCallbacks) {
|
||||
this.newBlockCallbacks = [];
|
||||
}
|
||||
this.newBlockCallbacks.push(callback);
|
||||
}
|
||||
|
||||
// ====== DEBUG UTILITIES ======
|
||||
|
||||
/**
|
||||
* Get debug status information
|
||||
* @returns {object} Debug status object
|
||||
*/
|
||||
getDebugStatus() {
|
||||
return {
|
||||
debugMode: this.DEBUG_MODE,
|
||||
currentBlockHeight: this.getCurrentBlockHeight(),
|
||||
isRunning: !!this.debugInterval,
|
||||
lastProductionBlock: this.lastProductionBlockHeight,
|
||||
chainHealthy: this.isChainHealthy(),
|
||||
lastSuccessfulRpcAt: this.lastSuccessfulRpcAt,
|
||||
debugBlock: this.debugBlockHeight
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug socket event
|
||||
* @param {string} socketId - Socket ID
|
||||
* @param {string} eventName - Event name
|
||||
* @param {object} data - Event data
|
||||
*/
|
||||
debugSocket(socketId, eventName, data) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
const shortSocketId = socketId.substring(0, 8);
|
||||
console.log(`🔌 SOCKET DEBUG: Sending ${eventName} to ${shortSocketId}...`);
|
||||
|
||||
// Limit payload logging to prevent console spam
|
||||
const dataStr = JSON.stringify(data);
|
||||
const shortDataStr = dataStr.length > 300 ? dataStr.substring(0, 300) + "..." : dataStr;
|
||||
console.log(`📦 PAYLOAD: ${shortDataStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ====== CLEANUP ======
|
||||
|
||||
/**
|
||||
* Stop all debug intervals and clean up
|
||||
*/
|
||||
cleanup() {
|
||||
if (this.debugInterval) {
|
||||
clearInterval(this.debugInterval);
|
||||
this.debugInterval = null;
|
||||
}
|
||||
|
||||
if (this.statusInterval) {
|
||||
clearInterval(this.statusInterval);
|
||||
this.statusInterval = null;
|
||||
}
|
||||
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log("🧹 Debug manager cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle debug mode (restart required)
|
||||
* @param {boolean} enabled - Whether to enable debug mode
|
||||
*/
|
||||
setDebugMode(enabled) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`🔄 Switching debug mode to: ${enabled ? 'ENABLED' : 'DISABLED'}`);
|
||||
}
|
||||
this.cleanup();
|
||||
this.DEBUG_MODE = enabled;
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DebugManager;
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Difficulty Configuration
|
||||
*
|
||||
* Configures dungeon difficulty based on crypto block timing.
|
||||
* The goal is to make the game challenging enough that the house wins
|
||||
* the majority of games (target: 60-70% house win rate).
|
||||
*
|
||||
* Key factors affecting difficulty:
|
||||
* 1. Dungeon size (larger = more moves needed = harder)
|
||||
* 2. Monster starting position (closer to player = harder)
|
||||
* 3. Monster move frequency (how often monster moves per player move)
|
||||
* 4. Monster AI aggressiveness
|
||||
* 5. Treasure placement (farther from exit = riskier)
|
||||
* 6. Number of monsters (future enhancement)
|
||||
*
|
||||
* Block times (measured against live daemons; see chainProfile.js meanBlockTimeMs):
|
||||
* - Grin (GRIN): ~1 minute
|
||||
* - Monero (XMR): ~2 minutes
|
||||
* - Litecoin (LTC): ~2.5 minutes
|
||||
* - Wownero (WOW): ~5 minutes (5.01 min/block over 1000 blocks; for calibration WOW is a slow
|
||||
* chain, closer to BTC than to XMR)
|
||||
* - Bitcoin (BTC): ~10 minutes
|
||||
*
|
||||
* Slow chains (WOW/BTC) cannot get a timer-driven house edge from a single dungeon without it
|
||||
* becoming an unplayable slog, so they use multi-level depth instead. See NETWORK_TUNING below.
|
||||
*/
|
||||
|
||||
const DIFFICULTY_PRESETS = {
|
||||
// Easy - for testing, high player win rate
|
||||
easy: {
|
||||
dungeon: {
|
||||
width: 30,
|
||||
height: 15,
|
||||
roomWidthRange: [4, 8],
|
||||
roomHeightRange: [3, 6],
|
||||
corridorLengthRange: [2, 5],
|
||||
dugPercentage: 0.25 // More open space = easier navigation
|
||||
},
|
||||
monster: {
|
||||
startDistanceFromPlayer: 0.9, // 90% of max distance (far from player)
|
||||
movesPerPlayerMove: 0.5, // Monster moves every other player move
|
||||
chaseAggressiveness: 0.6, // 60% chance to chase, 40% random
|
||||
visionRange: 6, // Short vision - easy to evade
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
roomPositionRatio: 0.5, // Middle room
|
||||
distanceFromExitRatio: 0.3 // Close to exit
|
||||
},
|
||||
targetHouseWinRate: 0.3 // 30% house wins (player-friendly)
|
||||
},
|
||||
|
||||
// Normal - balanced baseline, calibrated for ~2 min blocks (XMR)
|
||||
normal: {
|
||||
dungeon: {
|
||||
width: 45,
|
||||
height: 22,
|
||||
roomWidthRange: [3, 7],
|
||||
roomHeightRange: [3, 6],
|
||||
corridorLengthRange: [3, 7],
|
||||
dugPercentage: 0.2
|
||||
},
|
||||
monster: {
|
||||
startDistanceFromPlayer: 0.6, // 60% of max distance
|
||||
movesPerPlayerMove: 1.0, // Monster moves every player move
|
||||
chaseAggressiveness: 0.8, // 80% chase, 20% random
|
||||
visionRange: 10, // Medium vision
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
roomPositionRatio: 0.4, // Early-middle room
|
||||
distanceFromExitRatio: 0.5 // Moderate distance from exit
|
||||
},
|
||||
targetHouseWinRate: 0.55 // 55% house wins
|
||||
},
|
||||
|
||||
// Hard - challenging, house advantage
|
||||
hard: {
|
||||
dungeon: {
|
||||
width: 55,
|
||||
height: 28,
|
||||
roomWidthRange: [3, 6],
|
||||
roomHeightRange: [3, 5],
|
||||
corridorLengthRange: [4, 10],
|
||||
dugPercentage: 0.15 // Tighter corridors
|
||||
},
|
||||
monster: {
|
||||
startDistanceFromPlayer: 0.4, // Closer to player
|
||||
movesPerPlayerMove: 1.0,
|
||||
chaseAggressiveness: 0.9, // Very aggressive
|
||||
visionRange: 14, // Good vision
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
roomPositionRatio: 0.3, // Earlier room (farther from exit)
|
||||
distanceFromExitRatio: 0.7 // Farther from exit
|
||||
},
|
||||
targetHouseWinRate: 0.65 // 65% house wins
|
||||
},
|
||||
|
||||
// Casino - high house edge for real money
|
||||
casino: {
|
||||
dungeon: {
|
||||
width: 70, // Large dungeon
|
||||
height: 35,
|
||||
roomWidthRange: [3, 4], // Small rooms
|
||||
roomHeightRange: [3, 4],
|
||||
corridorLengthRange: [6, 15], // Long corridors
|
||||
dugPercentage: 0.10 // Very tight
|
||||
},
|
||||
monster: {
|
||||
startDistanceFromPlayer: 0.30, // Close to player
|
||||
movesPerPlayerMove: 1.0, // Monster matches player speed
|
||||
chaseAggressiveness: 0.97, // Nearly always chases
|
||||
visionRange: 22, // Excellent vision
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
roomPositionRatio: 0.20, // Early room
|
||||
distanceFromExitRatio: 0.85 // Very far from exit
|
||||
},
|
||||
targetHouseWinRate: 0.70 // 70% house wins
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get difficulty preset based on crypto type and environment
|
||||
*/
|
||||
function getDifficultyPreset(cryptoType = 'WOW', overridePreset = null) {
|
||||
// Allow explicit override via environment
|
||||
const envPreset = process.env.DIFFICULTY_PRESET || overridePreset;
|
||||
|
||||
if (envPreset && DIFFICULTY_PRESETS[envPreset]) {
|
||||
return { ...DIFFICULTY_PRESETS[envPreset], presetName: envPreset };
|
||||
}
|
||||
|
||||
// Default based on payment mode and crypto
|
||||
const gameMode = process.env.GAME_MODE || 'FREE';
|
||||
const paymentsEnabled = process.env.PAYMENTS_ENABLED === 'true';
|
||||
|
||||
if (gameMode === 'FREE' && !paymentsEnabled) {
|
||||
// Free mode = easier for fun
|
||||
return { ...DIFFICULTY_PRESETS.normal, presetName: 'normal' };
|
||||
}
|
||||
|
||||
// Paid modes = casino difficulty
|
||||
return { ...DIFFICULTY_PRESETS.casino, presetName: 'casino' };
|
||||
}
|
||||
|
||||
// Per-network tuning: cryptoType shapes difficulty through the level count, which scales with block
|
||||
// time. A run descends `levels` normal-sized dungeons, so cumulative run length is what makes the
|
||||
// block timer bite on slow chains. Dungeon size and monster speed are deliberately NOT the lever:
|
||||
// size self-cancels (a bigger map moves the exit and the monster equally far away), and even a 2.2x
|
||||
// monster caps WOW near a 64% house win rate. Each level is base-sized with a fair monster; the edge
|
||||
// comes from racing the block across the whole descent.
|
||||
// Counts stay modest (well under blockTime / ~75s per level) because 4-8 levels inside one block
|
||||
// deadline is rarely clearable, and reach-the-exit-to-win reads as intuitive only at low depth.
|
||||
// Operator env overrides (DUNGEON_LEVELS, DUNGEON_*, MONSTER_*) take precedence.
|
||||
// Set NETWORK_TUNING_DISABLED=true to bypass this table entirely.
|
||||
const NETWORK_TUNING = {
|
||||
GRIN: { levels: 1 }, // ~1 min blocks
|
||||
XMR: { levels: 1 }, // ~2 min
|
||||
LTC: { levels: 2 }, // ~2.5 min
|
||||
WOW: { levels: 2 }, // ~5 min
|
||||
BTC: { levels: 3 } // ~10 min
|
||||
};
|
||||
|
||||
// Fold the per-network level count onto the resolved preset (size + monster stay at the preset).
|
||||
function applyNetworkTuning(preset, cryptoType) {
|
||||
if (process.env.NETWORK_TUNING_DISABLED === 'true') return preset;
|
||||
const t = NETWORK_TUNING[String(cryptoType || '').trim().toUpperCase()];
|
||||
if (!t) return preset;
|
||||
return { ...preset, levels: t.levels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge difficulty config with custom overrides.
|
||||
* Precedence (lowest to highest): preset, per-network tuning, env vars, explicit customOverrides.
|
||||
*/
|
||||
function getDifficultyConfig(cryptoType = 'WOW', customOverrides = {}) {
|
||||
const preset = applyNetworkTuning(getDifficultyPreset(cryptoType, customOverrides.preset), cryptoType);
|
||||
|
||||
// Apply env var overrides (only when explicitly set)
|
||||
const envOverrides = {
|
||||
dungeon: {},
|
||||
monster: {},
|
||||
treasure: {}
|
||||
};
|
||||
|
||||
const env = process.env;
|
||||
if (env.DUNGEON_WIDTH) envOverrides.dungeon.width = parseInt(env.DUNGEON_WIDTH);
|
||||
if (env.DUNGEON_HEIGHT) envOverrides.dungeon.height = parseInt(env.DUNGEON_HEIGHT);
|
||||
if (env.DUNGEON_DUG_PERCENTAGE) envOverrides.dungeon.dugPercentage = parseFloat(env.DUNGEON_DUG_PERCENTAGE);
|
||||
if (env.DUNGEON_ROOM_WIDTH_MIN) envOverrides.dungeon.roomWidthRange = [parseInt(env.DUNGEON_ROOM_WIDTH_MIN), parseInt(env.DUNGEON_ROOM_WIDTH_MAX || env.DUNGEON_ROOM_WIDTH_MIN)];
|
||||
if (env.DUNGEON_ROOM_HEIGHT_MIN) envOverrides.dungeon.roomHeightRange = [parseInt(env.DUNGEON_ROOM_HEIGHT_MIN), parseInt(env.DUNGEON_ROOM_HEIGHT_MAX || env.DUNGEON_ROOM_HEIGHT_MIN)];
|
||||
if (env.DUNGEON_CORRIDOR_MIN) envOverrides.dungeon.corridorLengthRange = [parseInt(env.DUNGEON_CORRIDOR_MIN), parseInt(env.DUNGEON_CORRIDOR_MAX || env.DUNGEON_CORRIDOR_MIN)];
|
||||
|
||||
if (env.MONSTER_SPEED) envOverrides.monster.movesPerPlayerMove = parseFloat(env.MONSTER_SPEED);
|
||||
if (env.MONSTER_CHASE) envOverrides.monster.chaseAggressiveness = parseFloat(env.MONSTER_CHASE);
|
||||
if (env.MONSTER_VISION) envOverrides.monster.visionRange = parseInt(env.MONSTER_VISION);
|
||||
if (env.MONSTER_DISTANCE) envOverrides.monster.startDistanceFromPlayer = parseFloat(env.MONSTER_DISTANCE);
|
||||
|
||||
if (env.TREASURE_ROOM_POSITION) envOverrides.treasure.roomPositionRatio = parseFloat(env.TREASURE_ROOM_POSITION);
|
||||
if (env.TREASURE_EXIT_DISTANCE) envOverrides.treasure.distanceFromExitRatio = parseFloat(env.TREASURE_EXIT_DISTANCE);
|
||||
|
||||
// Level count: operator env DUNGEON_LEVELS wins, else custom, else the network tuning, else 1.
|
||||
const levels = env.DUNGEON_LEVELS
|
||||
? Math.max(1, parseInt(env.DUNGEON_LEVELS, 10) || 1)
|
||||
: (customOverrides.levels || preset.levels || 1);
|
||||
|
||||
return {
|
||||
...preset,
|
||||
levels,
|
||||
dungeon: { ...preset.dungeon, ...envOverrides.dungeon, ...customOverrides.dungeon },
|
||||
monster: { ...preset.monster, ...envOverrides.monster, ...customOverrides.monster },
|
||||
treasure: { ...preset.treasure, ...envOverrides.treasure, ...customOverrides.treasure }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal monster starting room based on difficulty config
|
||||
* @param {Array} rooms - Array of dungeon rooms
|
||||
* @param {number} startDistanceRatio - 0.0 (same room as player) to 1.0 (farthest room)
|
||||
* @returns {number} Room index for monster spawn
|
||||
*/
|
||||
function getMonsterSpawnRoomIndex(rooms, startDistanceRatio = 0.6) {
|
||||
if (!rooms || rooms.length < 2) return 0;
|
||||
|
||||
// Player spawns in room 0 (entrance)
|
||||
// startDistanceRatio of 1.0 = last room, 0.0 = first room
|
||||
const targetIndex = Math.floor((rooms.length - 1) * startDistanceRatio);
|
||||
|
||||
// Don't spawn in first room (player's room) or last room (exit)
|
||||
return Math.max(1, Math.min(targetIndex, rooms.length - 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate treasure room based on difficulty config
|
||||
* @param {Array} rooms - Array of dungeon rooms
|
||||
* @param {number} positionRatio - 0.0 (first room) to 1.0 (last room)
|
||||
* @returns {number} Room index for treasure
|
||||
*/
|
||||
function getTreasureRoomIndex(rooms, positionRatio = 0.5) {
|
||||
if (!rooms || rooms.length < 3) return Math.floor(rooms.length / 2);
|
||||
|
||||
// Avoid first room (entrance) and last room (exit)
|
||||
const usableRooms = rooms.length - 2;
|
||||
const targetIndex = 1 + Math.floor(usableRooms * positionRatio);
|
||||
|
||||
return Math.max(1, Math.min(targetIndex, rooms.length - 2));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DIFFICULTY_PRESETS,
|
||||
NETWORK_TUNING,
|
||||
applyNetworkTuning,
|
||||
getDifficultyPreset,
|
||||
getDifficultyConfig,
|
||||
getMonsterSpawnRoomIndex,
|
||||
getTreasureRoomIndex
|
||||
};
|
||||
@@ -0,0 +1,423 @@
|
||||
const crypto = require('crypto');
|
||||
const ROT = require('./rot.js');
|
||||
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig');
|
||||
const { createSeededRNG, seedToInt } = require('./provablyFair');
|
||||
|
||||
// Environment-based console logging control
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
// ========================================
|
||||
// DUNGEON CONFIGURATION SECTION
|
||||
// ========================================
|
||||
// These values are now overridden by difficultyConfig.js based on game mode
|
||||
// Edit difficultyConfig.js presets to customize dungeon generation
|
||||
const DUNGEON_CONFIGS = {
|
||||
// Default dungeon dimensions (overridden by difficulty preset)
|
||||
DEFAULT_WIDTH: 45,
|
||||
DEFAULT_HEIGHT: 22,
|
||||
|
||||
// Legacy smaller dungeon size (for compatibility)
|
||||
LEGACY_WIDTH: 25,
|
||||
LEGACY_HEIGHT: 19,
|
||||
|
||||
// Dungeon generation settings (overridden by difficulty preset)
|
||||
ROOM_WIDTH_RANGE: [3, 7],
|
||||
ROOM_HEIGHT_RANGE: [3, 6],
|
||||
CORRIDOR_LENGTH_RANGE: [3, 7],
|
||||
DUG_PERCENTAGE: 0.2,
|
||||
|
||||
// Lighting and appearance
|
||||
TORCH_DENSITY: 0.1, // 20% chance for torches on wall tiles adjacent to floors
|
||||
TORCH_ENABLED: true, // Set to false to disable torches completely
|
||||
FLOOR_VARIATION: 0.01, // Chance for secondary floor tiles (visual variety)
|
||||
|
||||
// Tile symbols
|
||||
PRIMARY_FLOOR: "'1",
|
||||
SECONDARY_FLOOR: "'2",
|
||||
WALL_TILE: "#",
|
||||
TORCH_TILE: "torch"
|
||||
};
|
||||
|
||||
/**
|
||||
* Dungeon generation utilities
|
||||
*/
|
||||
class DungeonGenerator {
|
||||
static get GENERATOR_VERSION() { return 'dungeon-generator-v2'; }
|
||||
static get SUPPORTED_GENERATOR_VERSIONS() {
|
||||
return Object.freeze(['dungeon-generator-v1', 'dungeon-generator-v2']);
|
||||
}
|
||||
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');
|
||||
|
||||
return {
|
||||
...DUNGEON_CONFIGS,
|
||||
// Override with difficulty settings
|
||||
DEFAULT_WIDTH: difficulty.dungeon.width,
|
||||
DEFAULT_HEIGHT: difficulty.dungeon.height,
|
||||
ROOM_WIDTH_RANGE: difficulty.dungeon.roomWidthRange,
|
||||
ROOM_HEIGHT_RANGE: difficulty.dungeon.roomHeightRange,
|
||||
CORRIDOR_LENGTH_RANGE: difficulty.dungeon.corridorLengthRange,
|
||||
DUG_PERCENTAGE: difficulty.dungeon.dugPercentage,
|
||||
// Include difficulty info for game.js
|
||||
difficulty: difficulty
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PROVABLY FAIR (Phase 0.2): Reproduce a dungeon purely from its committed seed.
|
||||
* Mirrors the standard generation path used by Game so a verifier can independently
|
||||
* regenerate the exact layout a player saw. Assumes standard generation options
|
||||
* (the difficulty preset for `cryptoType`); games started with custom dungeon
|
||||
* overrides would need those overrides passed in `gameOptions` to match.
|
||||
* @param {string} seed - The revealed game seed
|
||||
* @param {string|null} cryptoType - Currency, selects the difficulty preset
|
||||
* @param {object} gameOptions - Optional overrides matching the original game
|
||||
* @returns {object} The regenerated dungeon ({ map, rooms, entrance, exit, treasure, torches })
|
||||
*/
|
||||
static regenerateFromSeed(seed, cryptoType = null, gameOptions = {}) {
|
||||
const dungeonConfig = this.getConfig(cryptoType);
|
||||
const width = gameOptions.width || dungeonConfig.DEFAULT_WIDTH;
|
||||
const height = gameOptions.height || dungeonConfig.DEFAULT_HEIGHT;
|
||||
const gameConfig = {
|
||||
width,
|
||||
height,
|
||||
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,
|
||||
...gameOptions
|
||||
};
|
||||
return this.generate(width, height, {
|
||||
...gameConfig,
|
||||
rng: createSeededRNG(seed),
|
||||
seedInt: seedToInt(seed)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic fingerprint of a dungeon's layout, used to compare a regenerated
|
||||
* dungeon against the one that was played.
|
||||
* @param {object} dungeon - A dungeon object from generate()/regenerateFromSeed()
|
||||
* @returns {string} SHA-256 hex of the layout-defining fields
|
||||
*/
|
||||
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,
|
||||
exit: dungeon.exit,
|
||||
treasure: dungeon.treasure
|
||||
});
|
||||
return crypto.createHash('sha256').update(payload).digest('hex');
|
||||
}
|
||||
|
||||
static generate(width, height, options = {}) {
|
||||
// G4: resolve the difficulty-preset overrides so the digger and treasure
|
||||
// placement honour them (getConfig applies preset + env-var overrides on top
|
||||
// of DUNGEON_CONFIGS). These become defaults that explicit `options` can override.
|
||||
const presetConfig = this.getConfig(options.cryptoType || null);
|
||||
const defaultOptions = {
|
||||
floorVariation: DUNGEON_CONFIGS.FLOOR_VARIATION,
|
||||
torchEnabled: DUNGEON_CONFIGS.TORCH_ENABLED,
|
||||
torchDensity: DUNGEON_CONFIGS.TORCH_DENSITY,
|
||||
primaryFloor: DUNGEON_CONFIGS.PRIMARY_FLOOR,
|
||||
secondaryFloor: DUNGEON_CONFIGS.SECONDARY_FLOOR,
|
||||
torchTile: DUNGEON_CONFIGS.TORCH_TILE,
|
||||
// Preset-derived digger parameters (difficulty overrides)
|
||||
roomWidthRange: presetConfig.ROOM_WIDTH_RANGE,
|
||||
roomHeightRange: presetConfig.ROOM_HEIGHT_RANGE,
|
||||
corridorLengthRange: presetConfig.CORRIDOR_LENGTH_RANGE,
|
||||
dugPercentage: presetConfig.DUG_PERCENTAGE,
|
||||
// Preset-derived treasure room placement ratio
|
||||
roomPositionRatio: presetConfig.difficulty.treasure.roomPositionRatio
|
||||
};
|
||||
|
||||
const config = { ...defaultOptions, ...options };
|
||||
const generatorVersion = config.generatorVersion || this.GENERATOR_VERSION;
|
||||
if (!this.SUPPORTED_GENERATOR_VERSIONS.includes(generatorVersion)) {
|
||||
throw new Error(`Unsupported dungeon generator version: ${generatorVersion}`);
|
||||
}
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[DungeonGenerator] Generating dungeon with effective torchDensity: ${config.torchDensity}`);
|
||||
}
|
||||
|
||||
// PROVABLY FAIR (Phase 0.2): when a per-game seeded RNG is supplied, all randomness
|
||||
// in generation is derived from the committed seed so the dungeon is reproducible.
|
||||
const rng = (typeof config.rng === 'function') ? config.rng : Math.random;
|
||||
const baseSeedInt = config.seedInt;
|
||||
|
||||
// REACHABILITY (Phase 3.2): a dungeon whose exit is unreachable from the entrance
|
||||
// is paid-but-unwinnable. The digger normally connects all rooms, so this is rare,
|
||||
// but guard it. Retries are DETERMINISTIC (per-attempt ROT seed = baseSeedInt+attempt
|
||||
// and the seeded rng stream keeps advancing), so verify regeneration replays the same
|
||||
// attempts and reproduces the exact same final dungeon.
|
||||
const MAX_ATTEMPTS = 12;
|
||||
let last = null;
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
||||
const attemptSeed = (baseSeedInt != null) ? baseSeedInt + attempt : null;
|
||||
const dungeon = this._generateOnce(width, height, config, rng, attemptSeed);
|
||||
last = dungeon;
|
||||
const reachable = this.isReachable(dungeon.map, dungeon.entrance, dungeon.exit, config);
|
||||
const objectivesValid = generatorVersion === 'dungeon-generator-v1'
|
||||
|| this.hasDistinctReachableObjectives(dungeon, config);
|
||||
if (reachable && objectivesValid) {
|
||||
return dungeon;
|
||||
}
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.warn(`[DungeonGenerator] attempt ${attempt}: invalid reachability/objectives, regenerating`);
|
||||
}
|
||||
}
|
||||
if (generatorVersion === 'dungeon-generator-v1') {
|
||||
// Exact historical behavior for already-recorded v1 proofs.
|
||||
console.error(`[DungeonGenerator] Could not generate a reachable dungeon after ${MAX_ATTEMPTS} attempts; returning last candidate.`);
|
||||
return last;
|
||||
}
|
||||
throw new Error(`Could not generate a playable dungeon after ${MAX_ATTEMPTS} deterministic attempts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass dungeon generation (one attempt). Seeds ROT.RNG deterministically when
|
||||
* `seedInt` is provided. Returns { map, rooms, entrance, exit, treasure, torches }.
|
||||
*/
|
||||
static _generateOnce(width, height, config, rng, seedInt) {
|
||||
if (seedInt != null) {
|
||||
ROT.RNG.setSeed(seedInt);
|
||||
}
|
||||
|
||||
// Create dungeon using ROT.js Map.Digger.
|
||||
// G4: honour the difficulty-preset overrides carried on config (falling back to
|
||||
// the module constants) instead of always using DUNGEON_CONFIGS.
|
||||
const digger = new ROT.Map.Digger(width, height, {
|
||||
roomWidth: config.roomWidthRange || DUNGEON_CONFIGS.ROOM_WIDTH_RANGE,
|
||||
roomHeight: config.roomHeightRange || DUNGEON_CONFIGS.ROOM_HEIGHT_RANGE,
|
||||
corridorLength: config.corridorLengthRange || DUNGEON_CONFIGS.CORRIDOR_LENGTH_RANGE,
|
||||
dugPercentage: (config.dugPercentage !== undefined) ? config.dugPercentage : DUNGEON_CONFIGS.DUG_PERCENTAGE
|
||||
});
|
||||
|
||||
// Initialize the empty map with walls (1)
|
||||
const map = Array(height).fill().map(() => Array(width).fill(1));
|
||||
|
||||
// Fill in floors (0) using digger's callback
|
||||
digger.create((x, y, value) => {
|
||||
// value: 0 = floor, 1 = wall
|
||||
map[y][x] = value;
|
||||
});
|
||||
|
||||
// Create enhanced map with floor variations and torches
|
||||
const enhancedMap = this.enhanceMapWithVariations(map, config);
|
||||
|
||||
// Get rooms that were created
|
||||
const rooms = digger.getRooms();
|
||||
|
||||
// Place entrance in the first room
|
||||
const entranceRoom = rooms[0];
|
||||
const entranceCenter = entranceRoom.getCenter();
|
||||
const entrance = [entranceCenter[0], entranceCenter[1]];
|
||||
|
||||
// Place exit in the last room
|
||||
const exitRoom = rooms[rooms.length - 1];
|
||||
const exitCenter = exitRoom.getCenter();
|
||||
const exit = [exitCenter[0], exitCenter[1]];
|
||||
|
||||
// Place treasure in a middle room (not first or last).
|
||||
// G4: use the difficulty-aware treasure room index instead of a hardcoded midpoint.
|
||||
let treasureRoom;
|
||||
if (rooms.length > 2) {
|
||||
const treasureIndex = getTreasureRoomIndex(rooms, config.roomPositionRatio);
|
||||
treasureRoom = rooms[treasureIndex];
|
||||
} else {
|
||||
// Fallback if we have fewer than 3 rooms
|
||||
treasureRoom = rooms[0];
|
||||
}
|
||||
const treasureCenter = treasureRoom.getCenter();
|
||||
// Place treasure slightly off center to make it more interesting
|
||||
const treasure = [
|
||||
treasureCenter[0] + Math.floor(rng() * 3) - 1,
|
||||
treasureCenter[1] + Math.floor(rng() * 3) - 1
|
||||
];
|
||||
|
||||
// Ensure the treasure is on a floor tile (check for both floor types)
|
||||
const treasureTile = enhancedMap[treasure[1]][treasure[0]];
|
||||
if (treasureTile !== config.primaryFloor && treasureTile !== config.secondaryFloor) {
|
||||
treasure[0] = treasureCenter[0];
|
||||
treasure[1] = treasureCenter[1];
|
||||
}
|
||||
|
||||
return {
|
||||
map: enhancedMap, // Return the enhanced map with variations
|
||||
rooms: rooms,
|
||||
entrance: entrance,
|
||||
exit: exit,
|
||||
treasure: treasure,
|
||||
torches: this.getTorchPositions(enhancedMap, config) // Include torch positions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS flood-fill: is `to` reachable from `from` over walkable tiles?
|
||||
*/
|
||||
static isReachable(map, from, to, config) {
|
||||
const primaryFloor = config.primaryFloor;
|
||||
const secondaryFloor = config.secondaryFloor;
|
||||
const isWalk = (t) => t === primaryFloor || t === secondaryFloor || t === 0 || t === '>' || t === '$M';
|
||||
const h = map.length;
|
||||
const w = map[0].length;
|
||||
const [sx, sy] = from;
|
||||
const [tx, ty] = to;
|
||||
if (!isWalk(map[sy] && map[sy][sx]) || !isWalk(map[ty] && map[ty][tx])) return false;
|
||||
|
||||
const seen = Array.from({ length: h }, () => new Array(w).fill(false));
|
||||
let head = 0;
|
||||
const queue = [[sx, sy]];
|
||||
seen[sy][sx] = true;
|
||||
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||||
while (head < queue.length) {
|
||||
const [x, y] = queue[head++];
|
||||
if (x === tx && y === ty) return true;
|
||||
for (const [dx, dy] of DIRS) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx >= 0 && nx < w && ny >= 0 && ny < h && !seen[ny][nx] && isWalk(map[ny][nx])) {
|
||||
seen[ny][nx] = true;
|
||||
queue.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** V2 requires three distinct, reachable objectives so a run cannot start already won/looted. */
|
||||
static hasDistinctReachableObjectives(dungeon, config) {
|
||||
const points = [dungeon?.entrance, dungeon?.exit, dungeon?.treasure];
|
||||
if (points.some(point => !Array.isArray(point) || point.length !== 2
|
||||
|| !Number.isInteger(point[0]) || !Number.isInteger(point[1]))) return false;
|
||||
const keys = points.map(([x, y]) => `${x},${y}`);
|
||||
if (new Set(keys).size !== points.length) return false;
|
||||
return this.isReachable(dungeon.map, dungeon.entrance, dungeon.exit, config)
|
||||
&& this.isReachable(dungeon.map, dungeon.entrance, dungeon.treasure, config);
|
||||
}
|
||||
|
||||
// Enhanced map creation with floor variations and torch placement
|
||||
static enhanceMapWithVariations(basicMap, config) {
|
||||
const enhancedMap = basicMap.map(row => row.slice()); // Create a deep copy
|
||||
const { primaryFloor, secondaryFloor, floorVariation, torchEnabled, torchDensity, torchTile } = config;
|
||||
|
||||
if (typeof primaryFloor === 'undefined' || typeof secondaryFloor === 'undefined' || typeof floorVariation === 'undefined' || typeof torchEnabled === 'undefined' || typeof torchDensity === 'undefined' || typeof torchTile === 'undefined') {
|
||||
console.error("[DungeonGenerator.enhanceMapWithVariations] Critical config missing:", config);
|
||||
// Fallback or throw error if essential configs are still missing despite defaults
|
||||
// This indicates a problem upstream if defaults didn't propagate or were explicitly undefined.
|
||||
}
|
||||
|
||||
let placedTorchesCount = 0; // Counter for torches placed in this function
|
||||
const rng = (typeof config.rng === 'function') ? config.rng : Math.random;
|
||||
|
||||
// Log the torchDensity being used for this map generation pass
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[DungeonGenerator.enhanceMapWithVariations] Starting enhancement with torchDensity: ${config.torchDensity}`);
|
||||
}
|
||||
|
||||
const height = basicMap.length; // Use basicMap's dimensions
|
||||
const width = basicMap[0].length; // Use basicMap's dimensions
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const cell = basicMap[y][x];
|
||||
|
||||
if (cell === 0) {
|
||||
// Floor tile - randomly choose primary or secondary
|
||||
if (rng() < config.floorVariation) {
|
||||
enhancedMap[y][x] = config.secondaryFloor;
|
||||
} else {
|
||||
enhancedMap[y][x] = config.primaryFloor;
|
||||
}
|
||||
} else if (cell === 1) {
|
||||
// Wall tile - maybe add a torch
|
||||
if (config.torchEnabled && this.shouldPlaceTorch(basicMap, x, y, config.torchDensity, rng)) {
|
||||
enhancedMap[y][x] = config.torchTile;
|
||||
placedTorchesCount++; // Increment if a torch is placed
|
||||
} else {
|
||||
enhancedMap[y][x] = '#'; // Regular wall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[DungeonGenerator.enhanceMapWithVariations] Placed ${placedTorchesCount} torches during map enhancement.`);
|
||||
}
|
||||
return enhancedMap;
|
||||
}
|
||||
|
||||
// Determine if a torch should be placed on this wall tile
|
||||
static shouldPlaceTorch(map, x, y, torchDensity, rng = Math.random) {
|
||||
// Only place torches on walls that are adjacent to floors (for lighting logic)
|
||||
const height = map.length;
|
||||
const width = map[0].length;
|
||||
|
||||
// Check if this wall tile is adjacent to at least one floor tile
|
||||
const adjacentToFloor = [
|
||||
[-1, 0], [1, 0], [0, -1], [0, 1] // Adjacent cells
|
||||
].some(([dx, dy]) => {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
return nx >= 0 && nx < width && ny >= 0 && ny < height && map[ny][nx] === 0;
|
||||
});
|
||||
|
||||
const randomValue = rng();
|
||||
const shouldPlace = adjacentToFloor && randomValue < torchDensity;
|
||||
|
||||
return shouldPlace;
|
||||
}
|
||||
|
||||
// Get positions of all torches for client-side rendering
|
||||
static getTorchPositions(enhancedMap, config) {
|
||||
const torches = [];
|
||||
const { torchTile } = config; // Ensure config is used here
|
||||
|
||||
if (typeof torchTile === 'undefined') {
|
||||
console.error("[DungeonGenerator.getTorchPositions] torchTile config missing:", config);
|
||||
return torches; // Or handle error appropriately
|
||||
}
|
||||
|
||||
const height = enhancedMap.length;
|
||||
const width = enhancedMap[0].length;
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[DungeonGenerator.getTorchPositions] Searching for torch tile '${config.torchTile}' in a ${width}x${height} map.`);
|
||||
}
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (enhancedMap[y][x] === config.torchTile) {
|
||||
torches.push([x, y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[DungeonGenerator.getTorchPositions] Found ${torches.length} torches. Positions: ${JSON.stringify(torches)}`);
|
||||
}
|
||||
return torches;
|
||||
}
|
||||
|
||||
static getRandomRoomCenter(rooms, excludeFirst = false, excludeLast = false) {
|
||||
if (!rooms || rooms.length === 0) return null;
|
||||
|
||||
let availableRooms = [...rooms];
|
||||
if (excludeFirst && availableRooms.length > 1) {
|
||||
availableRooms = availableRooms.slice(1);
|
||||
}
|
||||
if (excludeLast && availableRooms.length > 1) {
|
||||
availableRooms = availableRooms.slice(0, -1);
|
||||
}
|
||||
|
||||
const randomRoom = availableRooms[Math.floor(Math.random() * availableRooms.length)];
|
||||
return randomRoom.getCenter();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DungeonGenerator;
|
||||
@@ -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 (!DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(recordedGenerator)) {
|
||||
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 (!DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(entryGenerator)) {
|
||||
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, generatorVersion: entryGenerator }
|
||||
);
|
||||
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 };
|
||||
@@ -0,0 +1,548 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const ROT = require('./rot.js');
|
||||
const Player = require('./player.js');
|
||||
const Monster = require('./monster.js');
|
||||
const DungeonGenerator = require('./dungeon.js');
|
||||
const LightingAndFov = require('./lightingAndFov.js');
|
||||
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig');
|
||||
const { createGameProof, getPreGameCommitment, getPostGameReveal, createSeededRNG, seedToInt, levelSeed, seededShuffle } = require('./provablyFair');
|
||||
|
||||
// Environment-based console logging control
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
// Only this module can mint the option that selects a historical generator. Public/client game
|
||||
// options may contain a same-named string, but the constructor deliberately discards it. Durable
|
||||
// restart restoration supplies the database-validated version through the symbol-backed factory.
|
||||
const TRUSTED_GENERATOR_VERSION = Symbol('trustedGeneratorVersion');
|
||||
|
||||
class Game {
|
||||
/**
|
||||
* Create a new game instance
|
||||
* @param {string} socketId - Socket ID of the player
|
||||
* @param {User} user - User object for statistics tracking
|
||||
* @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,
|
||||
generatorVersion: _untrustedGeneratorVersion,
|
||||
[TRUSTED_GENERATOR_VERSION]: trustedGeneratorVersion,
|
||||
...dungeonOptions
|
||||
} = suppliedOptions;
|
||||
this.generatorVersion = trustedGeneratorVersion || DungeonGenerator.GENERATOR_VERSION;
|
||||
if (!DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(this.generatorVersion)) {
|
||||
throw new Error(`Unsupported trusted dungeon generator version: ${this.generatorVersion}`);
|
||||
}
|
||||
this.user = user;
|
||||
this.id = uuidv4();
|
||||
this.socketId = socketId;
|
||||
this.players = {};
|
||||
this.monsters = {};
|
||||
this.dungeon = null;
|
||||
this.moveCount = 0;
|
||||
this.startedAt = Date.now();
|
||||
|
||||
// The fairness proof is built first; the seed it commits to drives all later generation.
|
||||
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.
|
||||
this.seededRNG = createSeededRNG(this.gameProof.seed);
|
||||
this.seedInt = seedToInt(this.gameProof.seed);
|
||||
|
||||
const dungeonConfig = DungeonGenerator.getConfig();
|
||||
|
||||
const width = dungeonOptions.width || dungeonConfig.DEFAULT_WIDTH;
|
||||
const height = dungeonOptions.height || dungeonConfig.DEFAULT_HEIGHT;
|
||||
|
||||
this.gameConfig = {
|
||||
width: width,
|
||||
height: height,
|
||||
// Dungeon generation options (can be overridden by gameOptions)
|
||||
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
|
||||
...dungeonOptions
|
||||
};
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[Game Constructor] Creating game for user ${user.id} (socket: ${socketId})`);
|
||||
console.log(`[Game Constructor] Dimensions: ${width}x${height}`);
|
||||
console.log(`[Game Constructor] Game config:`, JSON.stringify(this.gameConfig, null, 2));
|
||||
}
|
||||
|
||||
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 = this.generatorVersion;
|
||||
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,
|
||||
generatorVersion: this.generatorVersion
|
||||
}
|
||||
);
|
||||
if (depth < this.maxDepth) dungeon.treasure = null;
|
||||
}
|
||||
manifest.push({
|
||||
depth,
|
||||
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
|
||||
generatorVersion: this.generatorVersion,
|
||||
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: this.generatorVersion,
|
||||
fingerprintVersion: DungeonGenerator.FINGERPRINT_VERSION,
|
||||
generationOptions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory method to create a game with standard dimensions
|
||||
*/
|
||||
static createStandardGame(socketId, user, options = {}) {
|
||||
return new Game(socketId, user, options);
|
||||
}
|
||||
|
||||
/** @internal Rebuild a run using the generator version already trusted from PostgreSQL. */
|
||||
static createRestoredStandardGame(socketId, user, options = {}, generatorVersion) {
|
||||
return new Game(socketId, user, {
|
||||
...options,
|
||||
[TRUSTED_GENERATOR_VERSION]: generatorVersion
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
initializeGame() {
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[Game.initializeGame] Initializing game with config:`, JSON.stringify(this.gameConfig, null, 2));
|
||||
}
|
||||
if (typeof this.gameConfig.width === 'undefined' || typeof this.gameConfig.height === 'undefined') {
|
||||
console.error("[Game.initializeGame] CRITICAL: Width or Height is undefined in gameConfig!", this.gameConfig);
|
||||
throw new Error(`Invalid game dimensions: width=${this.gameConfig.width}, height=${this.gameConfig.height}`);
|
||||
}
|
||||
|
||||
this.width = this.gameConfig.width;
|
||||
this.height = this.gameConfig.height;
|
||||
|
||||
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 scaled to block time by
|
||||
// the per-network tuning. Reaching a non-final exit descends to a fresh level; only the final
|
||||
// level's exit escapes. Defaults to 1 (single level) when no tuning is present.
|
||||
this.depth = 1;
|
||||
this.maxDepth = Math.max(1, parseInt(difficultyConfig.levels, 10) || 1);
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[Game.initializeGame] Using difficulty preset: ${difficultyConfig.presetName}`);
|
||||
console.log(`[Game.initializeGame] Target house win rate: ${difficultyConfig.targetHouseWinRate * 100}%`);
|
||||
console.log(`[Game.initializeGame] Levels this run: ${this.maxDepth}`);
|
||||
}
|
||||
|
||||
this.gameState = 'waiting';
|
||||
this.startBlock = null;
|
||||
this.player = new Player();
|
||||
this.monsterMoveAccumulator = 0; // Carries fractional monster moves between turns.
|
||||
this.fee = 0;
|
||||
|
||||
// Also creates the monster and FOV for the level.
|
||||
this._generateLevel(1);
|
||||
|
||||
this.gameState = 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the dungeon for a given level and place the player + a fresh monster on it. The layout
|
||||
* is deterministic from the committed seed via a per-level seed (level 1 uses the master seed;
|
||||
* deeper levels salt it), keeping the whole descent verifiable. Treasure lives only in the vault
|
||||
* (the final level); intermediate levels are a race to the stairs.
|
||||
* @param {number} depth - 1-based level index
|
||||
*/
|
||||
_generateLevel(depth) {
|
||||
const isFirst = depth === 1;
|
||||
const seed = levelSeed(this.gameProof.seed, depth);
|
||||
const dungeon = DungeonGenerator.generate(this.gameConfig.width, this.gameConfig.height, {
|
||||
...this.gameConfig,
|
||||
generatorVersion: this.generatorVersion,
|
||||
rng: isFirst ? this.seededRNG : createSeededRNG(seed),
|
||||
seedInt: isFirst ? this.seedInt : seedToInt(seed)
|
||||
});
|
||||
|
||||
// Only the final level holds the treasure, so the reward requires a full descent.
|
||||
if (depth < this.maxDepth) {
|
||||
dungeon.treasure = null;
|
||||
}
|
||||
this.dungeon = dungeon;
|
||||
|
||||
// Every played descent must match the all-level manifest the constructor committed. Generator
|
||||
// drift within a live process fails hard rather than 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`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dungeon.entrance) {
|
||||
this.player.moveTo(dungeon.entrance[0], dungeon.entrance[1]);
|
||||
}
|
||||
|
||||
// Fresh monster per level, placed by difficulty config (closer = harder).
|
||||
this.monster = new Monster(0, 0, { visionRange: this.difficultyConfig.monster.visionRange || 12 });
|
||||
this.monsterMoveAccumulator = 0;
|
||||
if (dungeon.rooms && dungeon.rooms.length > 2) {
|
||||
const idx = getMonsterSpawnRoomIndex(dungeon.rooms, this.difficultyConfig.monster.startDistanceFromPlayer);
|
||||
const center = dungeon.rooms[idx].getCenter();
|
||||
this.monster.moveTo(center[0], center[1]);
|
||||
}
|
||||
// ROT.Digger can legitimately produce <=2 rooms on the 30x15 easy profile, and a room center
|
||||
// can be malformed; either case leaves the monster at its constructor default (0,0), normally
|
||||
// a wall, giving an effectively monsterless run. The fallback picks the farthest passable
|
||||
// non-objective tile deterministically. It consumes no RNG, so dungeon generation and layout
|
||||
// fingerprints are unaffected; monster placement sits outside the published layout proof.
|
||||
const primaryFloor = this.gameConfig.primaryFloor || "'1";
|
||||
const secondaryFloor = this.gameConfig.secondaryFloor || "'2";
|
||||
const monsterTilePassable = (x, y) => {
|
||||
const tile = dungeon.map?.[y]?.[x];
|
||||
return tile === primaryFloor || tile === secondaryFloor || tile === 0 || tile === '>' || tile === '$M';
|
||||
};
|
||||
const monsterSpawnValid = monsterTilePassable(this.monster.x, this.monster.y)
|
||||
&& (this.monster.x !== this.player.x || this.monster.y !== this.player.y)
|
||||
&& (!dungeon.exit || this.monster.x !== dungeon.exit[0] || this.monster.y !== dungeon.exit[1])
|
||||
&& (!dungeon.treasure || this.monster.x !== dungeon.treasure[0] || this.monster.y !== dungeon.treasure[1]);
|
||||
if (!monsterSpawnValid) {
|
||||
let fallback = null;
|
||||
let fallbackDistance = -1;
|
||||
for (let y = 0; y < dungeon.map.length; y += 1) {
|
||||
for (let x = 0; x < dungeon.map[y].length; x += 1) {
|
||||
if (!monsterTilePassable(x, y)) continue;
|
||||
if (x === this.player.x && y === this.player.y) continue;
|
||||
if (dungeon.exit && x === dungeon.exit[0] && y === dungeon.exit[1]) continue;
|
||||
if (dungeon.treasure && x === dungeon.treasure[0] && y === dungeon.treasure[1]) continue;
|
||||
const distance = Math.abs(x - this.player.x) + Math.abs(y - this.player.y);
|
||||
if (distance > fallbackDistance) {
|
||||
fallback = [x, y];
|
||||
fallbackDistance = distance;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fallback) throw new Error('Dungeon has no valid monster spawn tile');
|
||||
this.monster.moveTo(fallback[0], fallback[1]);
|
||||
}
|
||||
|
||||
// A fresh descent is unexplored, so lighting and FOV reset with the level.
|
||||
this.lightingAndFov = new LightingAndFov(dungeon.map, dungeon.torches);
|
||||
this._fovInstance = LightingAndFov.initializeFOV(dungeon.map, this.gameConfig);
|
||||
const fovRadius = this.gameConfig.fovRadius || 10;
|
||||
this.visibleTiles = LightingAndFov.updateFOV(this._fovInstance, this.player, dungeon.map, fovRadius);
|
||||
}
|
||||
|
||||
/** Take the stairs down to the next level (regenerates the dungeon, keeps player identity/treasure). */
|
||||
_descend() {
|
||||
this.depth += 1;
|
||||
this._generateLevel(this.depth);
|
||||
}
|
||||
|
||||
updateFOV() {
|
||||
this.visibleTiles = LightingAndFov.updateFOV(this._fovInstance, this.player, this.dungeon.map, 10);
|
||||
}
|
||||
|
||||
movePlayer(dx, dy) {
|
||||
const newX = this.player.x + dx;
|
||||
const newY = this.player.y + dy;
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`Move: (${this.player.x},${this.player.y}) -> (${newX},${newY})`);
|
||||
}
|
||||
|
||||
// A move is valid only onto a floor tile inside the map bounds.
|
||||
const primaryFloor = this.gameConfig.primaryFloor || "'1";
|
||||
const secondaryFloor = this.gameConfig.secondaryFloor || "'2";
|
||||
|
||||
if (this.dungeon &&
|
||||
this.dungeon.map[newY] &&
|
||||
this.dungeon.map[newY][newX] !== undefined &&
|
||||
(this.dungeon.map[newY][newX] === primaryFloor || this.dungeon.map[newY][newX] === secondaryFloor)) {
|
||||
|
||||
this.player.moveTo(newX, newY);
|
||||
this.moveCount += 1;
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`Player moved to ${newX},${newY} in game for socket ${this.socketId}`);
|
||||
}
|
||||
|
||||
this.updateFOV();
|
||||
|
||||
// Walking into the monster is a collision death.
|
||||
if (this.monster && this.monster.x === newX && this.monster.y === newY) {
|
||||
this.gameState = 'lost';
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`💀 Player walked into monster at (${newX},${newY}) - GAME OVER`);
|
||||
}
|
||||
return {
|
||||
status: 'moved',
|
||||
event: 'monster_caught',
|
||||
player: this.player.getState(),
|
||||
monster: this.monster.getState(),
|
||||
visibleTiles: this.visibleTiles,
|
||||
moves: this.moveCount
|
||||
};
|
||||
}
|
||||
|
||||
if (this.dungeon.exit && this.player.isAt(this.dungeon.exit[0], this.dungeon.exit[1])) {
|
||||
// Above the bottom level the exit is stairs down, not a win. The new level is generated
|
||||
// here, so the returned state and the next getState already reflect it.
|
||||
if (this.depth < this.maxDepth) {
|
||||
this._descend();
|
||||
return {
|
||||
status: 'moved', event: 'descend', depth: this.depth, maxDepth: this.maxDepth,
|
||||
player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount
|
||||
};
|
||||
}
|
||||
this.gameState = 'won';
|
||||
return { status: 'moved', event: 'escaped', player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount };
|
||||
}
|
||||
if (this.dungeon.treasure && this.player.isAt(this.dungeon.treasure[0], this.dungeon.treasure[1]) && !this.player.hasTreasure) {
|
||||
this.player.hasTreasure = true;
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`🏆 TREASURE PICKUP: Player ${this.socketId} collected treasure at (${this.dungeon.treasure[0]}, ${this.dungeon.treasure[1]})`);
|
||||
}
|
||||
this.dungeon.treasure = null;
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`🗑️ TREASURE REMOVED: dungeon.treasure is now null for player ${this.socketId}`);
|
||||
console.log(`Player ${this.socketId} collected treasure! hasTreasure: ${this.player.hasTreasure}`);
|
||||
}
|
||||
return { status: 'moved', event: 'treasure_found', player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount };
|
||||
}
|
||||
|
||||
return { status: 'moved', player: this.player.getState(), visibleTiles: this.visibleTiles, moves: this.moveCount };
|
||||
}
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`Move BLOCKED: ${newX},${newY} - cell value: ${this.dungeon && this.dungeon.map[newY] ? this.dungeon.map[newY][newX] : 'undefined'}`);
|
||||
}
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
|
||||
moveMonster() {
|
||||
if (!this.monster || !this.player) {
|
||||
return { status: 'idle' };
|
||||
}
|
||||
|
||||
const movesPerPlayerMove = this.difficultyConfig?.monster?.movesPerPlayerMove ?? 1.0;
|
||||
const chaseAggressiveness = this.difficultyConfig?.monster?.chaseAggressiveness ?? 0.8;
|
||||
|
||||
// Fractional rates accumulate until they buy at least one whole step.
|
||||
this.monsterMoveAccumulator = (this.monsterMoveAccumulator || 0) + movesPerPlayerMove;
|
||||
|
||||
while (this.monsterMoveAccumulator >= 1) {
|
||||
this.monsterMoveAccumulator -= 1;
|
||||
|
||||
// Apply chase aggressiveness (chance to move randomly instead of chasing).
|
||||
// Use the per-game seeded RNG so monster behaviour is deterministic from the seed.
|
||||
if (this.seededRNG() < chaseAggressiveness) {
|
||||
this.monster.moveTowardPlayer(this.player, this.dungeon, this.seededRNG);
|
||||
} else {
|
||||
// A null target still moves the monster, just not toward the player.
|
||||
this.monster.moveTowardPlayer(null, this.dungeon, this.seededRNG);
|
||||
}
|
||||
|
||||
// Collision is checked after each sub-step. With movesPerPlayerMove > 1 (the casino preset
|
||||
// runs 1.5x) the monster takes several steps per turn, and a check only after the loop
|
||||
// would let it step onto the player's tile and off again between checks, phasing through
|
||||
// the player without catching them.
|
||||
if (this.monster.hasCaughtPlayer(this.player)) {
|
||||
this.gameState = 'lost';
|
||||
return {
|
||||
status: 'monster_caught',
|
||||
event: 'monster_caught',
|
||||
player: this.player.getState(),
|
||||
monster: this.monster.getState()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (this.monster.hasCaughtPlayer(this.player)) {
|
||||
this.gameState = 'lost';
|
||||
return {
|
||||
status: 'monster_caught',
|
||||
event: 'monster_caught',
|
||||
player: this.player.getState(),
|
||||
monster: this.monster.getState()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'moved',
|
||||
player: this.player.getState(),
|
||||
monster: this.monster.getState()
|
||||
};
|
||||
}
|
||||
|
||||
/** Is this dungeon cell inside the player's CURRENT field of view? */
|
||||
_isVisible(point) {
|
||||
if (!point) return false;
|
||||
const x = Array.isArray(point) ? point[0] : point.x;
|
||||
const y = Array.isArray(point) ? point[1] : point.y;
|
||||
if (typeof x !== 'number' || typeof y !== 'number') return false;
|
||||
const row = this.visibleTiles && this.visibleTiles[y];
|
||||
return !!row && row[x] !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky record of which dungeon features the player has actually laid eyes on.
|
||||
*
|
||||
* Fog of war is enforced server-side: undiscovered entrance, exit and treasure coordinates never
|
||||
* enter a `game_start` or `game_update` frame. Client-side concealment alone is not enough,
|
||||
* because reading the socket frame reveals the treasure and exit from move 0, which in a paid
|
||||
* mode is a direct attack on the payout. Discovery is sticky, matching the client's explored
|
||||
* memory, so a feature stays on the map once found.
|
||||
*/
|
||||
_updateDiscovered() {
|
||||
if (!this._discovered) this._discovered = { entrance: false, exit: false, treasure: false };
|
||||
if (!this.dungeon) return this._discovered;
|
||||
if (!this._discovered.entrance && this._isVisible(this.dungeon.entrance)) this._discovered.entrance = true;
|
||||
if (!this._discovered.exit && this._isVisible(this.dungeon.exit)) this._discovered.exit = true;
|
||||
if (!this._discovered.treasure && this._isVisible(this.dungeon.treasure)) this._discovered.treasure = true;
|
||||
return this._discovered;
|
||||
}
|
||||
|
||||
getState() {
|
||||
const discovered = this._updateDiscovered();
|
||||
const state = {
|
||||
gameState: this.gameState,
|
||||
player: this.player.getState(),
|
||||
// The monster ships only while in view, matching the client's concealment. Sending it
|
||||
// unconditionally would let a player track the hunter through walls.
|
||||
monster: (this.monster && this._isVisible(this.monster)) ? this.monster.getState() : null,
|
||||
visibleTiles: { ...this.visibleTiles },
|
||||
lighting: this.calculateLighting(),
|
||||
entrance: (this.dungeon && discovered.entrance) ? this.dungeon.entrance : null,
|
||||
exit: (this.dungeon && discovered.exit) ? this.dungeon.exit : null,
|
||||
treasure: (this.dungeon && discovered.treasure) ? this.dungeon.treasure : null,
|
||||
depth: this.depth,
|
||||
maxDepth: this.maxDepth,
|
||||
moves: this.moveCount,
|
||||
startedAt: this.startedAt
|
||||
};
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log("🎮 getState() sending entities - Monster:", state.monster, "Entrance:", state.entrance, "Exit:", state.exit, "Treasure:", state.treasure);
|
||||
if (state.treasure === null) {
|
||||
console.log("✅ TREASURE NULL: Confirmed treasure is null in getState() - treasure was picked up!");
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
calculateLighting() {
|
||||
return LightingAndFov.calculateLighting(
|
||||
this.player,
|
||||
this.dungeon?.torches || [],
|
||||
this.visibleTiles,
|
||||
10
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* End this game and update user statistics
|
||||
* @param {string} result - 'won', 'lost', or 'abandoned'
|
||||
* @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);
|
||||
}
|
||||
this.gameState = 'ended';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provably fair commitment to show BEFORE game starts
|
||||
* This is the hash of the seed - player can verify after game
|
||||
* @returns {object} Pre-game commitment data
|
||||
*/
|
||||
getProofCommitment() {
|
||||
return getPreGameCommitment(this.gameProof);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provably fair reveal data to show AFTER game ends
|
||||
* This includes the seed so player can verify
|
||||
* @returns {object} Post-game reveal data with seed
|
||||
*/
|
||||
getProofReveal() {
|
||||
return getPostGameReveal(this.gameProof, {
|
||||
won: this.finalResult === 'won' || this.gameState === 'won',
|
||||
treasureFound: this.player?.hasTreasure ?? false,
|
||||
moves: this.moveCount,
|
||||
duration: Math.floor((Date.now() - this.startedAt) / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the game seed (only after game ends for fairness)
|
||||
* @returns {string|null} Seed if game ended, null otherwise
|
||||
*/
|
||||
getSeed() {
|
||||
if (this.gameState === 'ended' || this.gameState === 'won' || this.gameState === 'lost') {
|
||||
return this.gameProof.seed;
|
||||
}
|
||||
return null; // Don't reveal seed during active game
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Game;
|
||||
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* Game Manager Module
|
||||
* Handles game creation, game over scenarios, and game lifecycle management
|
||||
*/
|
||||
|
||||
const Game = require('../game/game');
|
||||
|
||||
class GameManager {
|
||||
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;
|
||||
this._admissionClosed = false;
|
||||
this._creationsInFlight = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the spectator manager (allows late binding after initialization)
|
||||
* @param {SpectatorManager} spectatorManager
|
||||
*/
|
||||
setSpectatorManager(spectatorManager) {
|
||||
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
|
||||
* @param {Object} options - Additional game options
|
||||
* @returns {Object} Created game instance
|
||||
*/
|
||||
async createGameForUser(user, options = {}) {
|
||||
if (this._admissionClosed) {
|
||||
const error = new Error('Game admission is closed for server shutdown');
|
||||
error.code = 'SERVER_SHUTTING_DOWN';
|
||||
throw error;
|
||||
}
|
||||
const creation = this._createGameForUser(user, options);
|
||||
this._creationsInFlight.add(creation);
|
||||
try {
|
||||
return await creation;
|
||||
} finally {
|
||||
this._creationsInFlight.delete(creation);
|
||||
}
|
||||
}
|
||||
|
||||
async _createGameForUser(user, 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');
|
||||
}
|
||||
|
||||
const 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);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[createGameForUser] Created game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Synchronously close the lowest-level solo creation gate. */
|
||||
beginShutdown() {
|
||||
this._admissionClosed = true;
|
||||
this._isShuttingDown = true;
|
||||
}
|
||||
|
||||
/** Wait for creations that crossed the gate before beginShutdown(). */
|
||||
async drainAdmissions() {
|
||||
while (this._creationsInFlight.size > 0) {
|
||||
await Promise.allSettled(Array.from(this._creationsInFlight));
|
||||
}
|
||||
return { pending: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game over scenarios with comprehensive cleanup and payouts
|
||||
* @param {Object} socket - Socket instance (or fake socket with just id)
|
||||
* @param {Object} game - Game instance
|
||||
* @param {string} status - Game status ('won', 'lost')
|
||||
* @param {string} reason - Reason for game ending ('escaped', 'monster', 'timeout')
|
||||
* @param {string} message - Message to display to user
|
||||
* @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 moves = game.moveCount || 0;
|
||||
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 treasure = !!game.player?.hasTreasure;
|
||||
const terminal = Object.freeze({
|
||||
status,
|
||||
reason,
|
||||
message,
|
||||
score: finalScore,
|
||||
moves,
|
||||
durationSeconds,
|
||||
treasure,
|
||||
won: status === 'won',
|
||||
outcome: reason === 'escaped' ? 'escaped' : reason
|
||||
});
|
||||
|
||||
// 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
|
||||
});
|
||||
} 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 };
|
||||
}
|
||||
|
||||
// `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' };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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.beginShutdown();
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if monster caught player
|
||||
* @param {Object} player - Player object
|
||||
* @param {Object} monster - Monster object
|
||||
* @returns {boolean} True if monster caught player
|
||||
*/
|
||||
checkMonsterKill(player, monster) {
|
||||
return monster.x === player.x && monster.y === player.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log game update debug information
|
||||
* @param {string} socketId - Socket ID
|
||||
* @param {Object} gameState - Game state object
|
||||
*/
|
||||
logGameUpdate(socketId, gameState) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🔍 GAME UPDATE DEBUG for ${socketId}:`);
|
||||
console.log(` - Player position: (${gameState.player?.x}, ${gameState.player?.y})`);
|
||||
console.log(` - Visible tiles keys: ${Object.keys(gameState.visibleTiles || {}).length} rows`);
|
||||
console.log(` - Lighting data included: ${!!gameState.lighting}`);
|
||||
if (gameState.lighting) {
|
||||
const lightingTileCount = Object.keys(gameState.lighting).reduce((acc, yKey) =>
|
||||
acc + Object.keys(gameState.lighting[yKey] || {}).length, 0);
|
||||
console.log(` - Lighting tiles count: ${lightingTileCount}`);
|
||||
}
|
||||
console.log(` - Torch data included: ${!!gameState.torches}`);
|
||||
if (gameState.torches) {
|
||||
console.log(` - Torch count: ${gameState.torches.length}`);
|
||||
}
|
||||
console.log(`Sending game_update to ${socketId} after player move.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about active games
|
||||
* @returns {Object} Game statistics
|
||||
*/
|
||||
getStats() {
|
||||
const gameTypes = new Map();
|
||||
const gameStates = new Map();
|
||||
|
||||
for (const [socketId, game] of this.activeGames.entries()) {
|
||||
const type = game.type || 'unknown';
|
||||
gameTypes.set(type, (gameTypes.get(type) || 0) + 1);
|
||||
|
||||
const state = game.gameState || 'active';
|
||||
gameStates.set(state, (gameStates.get(state) || 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
totalActive: this.activeGames.size,
|
||||
byType: Object.fromEntries(gameTypes),
|
||||
byState: Object.fromEntries(gameStates)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup of stale games (emergency cleanup)
|
||||
* @param {Function} isStaleGame - Function that takes (socketId, game) and returns true if game should be cleaned
|
||||
* @returns {number} Number of games cleaned up
|
||||
*/
|
||||
cleanupStaleGames(isStaleGame) {
|
||||
let cleaned = 0;
|
||||
const toDelete = [];
|
||||
|
||||
for (const [socketId, game] of this.activeGames.entries()) {
|
||||
if (isStaleGame(socketId, game)) {
|
||||
toDelete.push(socketId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const socketId of toDelete) {
|
||||
if (this.activeGames.delete(socketId)) {
|
||||
cleaned++;
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`🧹 Cleaned up stale game for ${socketId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
_calculateScore(game, status, reason) {
|
||||
if (!game) return 0;
|
||||
const moves = game.moveCount || 0;
|
||||
const duration = game.startedAt
|
||||
? Math.max(1, Math.round((Date.now() - game.startedAt) / 1000))
|
||||
: 120;
|
||||
|
||||
if (status === 'won' && reason === 'escaped') {
|
||||
let score = 100; // base escape bonus
|
||||
|
||||
// Treasure bonus
|
||||
if (game.player?.hasTreasure) {
|
||||
score += 200;
|
||||
}
|
||||
|
||||
// Speed bonus: max 300, loses 5 points per second after 20s
|
||||
score += Math.max(0, 300 - Math.max(duration - 20, 0) * 5);
|
||||
|
||||
// Efficiency bonus: max 200, loses 3 points per move after 30 moves
|
||||
score += Math.max(0, 200 - Math.max(moves - 30, 0) * 3);
|
||||
|
||||
return Math.round(score);
|
||||
} else if (game.player?.hasTreasure) {
|
||||
return 50; // found treasure but died
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a database record for the game
|
||||
* @param {Object} game - Game instance
|
||||
* @param {Object} user - User object
|
||||
*/
|
||||
async _insertGameRecord(game, user) {
|
||||
if (this.gameModeManager && this.gameModeManager.db) {
|
||||
const db = this.gameModeManager.db;
|
||||
const gameMode = this.gameModeManager.gameMode || 'FREE';
|
||||
const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null;
|
||||
const socketId = user.id; // user.id is the socket id string
|
||||
|
||||
try {
|
||||
const result = await db.query(`
|
||||
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,
|
||||
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;
|
||||
|
||||
// Stamps the stable users.id onto the in-memory game so suspend/restore and
|
||||
// completeGame key on it rather than on socket_id, which is volatile across
|
||||
// reconnects. game.userId is canonical; game.dbUserId is an alias read by the
|
||||
// disconnect/suspend path.
|
||||
const stableUserId = result.rows[0]?.user_id ?? null;
|
||||
if (stableUserId != null) {
|
||||
game.userId = stableUserId;
|
||||
if (game.dbUserId == null) game.dbUserId = stableUserId;
|
||||
}
|
||||
|
||||
// Entry block height, captured at creation so the suspend path persists it and
|
||||
// the reconnect timeout logic stays consistent.
|
||||
game.blockRec = blockHeight;
|
||||
|
||||
// DB pool handle so restoreGame can keep games.socket_id aligned to the
|
||||
// reconnecting socket without holding its own handle.
|
||||
game.db = db;
|
||||
} catch (err) {
|
||||
console.error('Game insert failed:', err.message);
|
||||
game.dbId = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update game record with completion details
|
||||
* @param {Object} game - Game instance
|
||||
* @param {string} socketId - Socket ID
|
||||
* @param {string} status - Final game status
|
||||
* @param {string} reason - Reason for ending
|
||||
*/
|
||||
async _updateGameRecord(game, socketId, status, reason, moves = 0, durationSeconds = null) {
|
||||
if (this.gameModeManager && this.gameModeManager.db) {
|
||||
const db = this.gameModeManager.db;
|
||||
const outcome = reason === 'escaped' ? 'escaped' : (reason === 'monster' ? 'caught_by_monster' : reason);
|
||||
const score = this._calculateScore(game, status, reason);
|
||||
|
||||
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(),
|
||||
proof_revealed_at = NOW()
|
||||
WHERE dungeon_seed = $6 AND socket_id = $7
|
||||
`, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId, score]);
|
||||
|
||||
if (score > 0) {
|
||||
await db.query(`
|
||||
UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1)
|
||||
WHERE socket_id = $2
|
||||
`, [score, socketId]);
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`✅ Updated game record for ${socketId}: ${status} (${outcome}), score: ${score}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Game completion update failed:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GameManager;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// Delegates to the ChainProfile registry: one source for all chains (WOW 11, XMR 12, BTC/LTC 8,
|
||||
// GRIN 9; unknown -> 12). Previously WOW/XMR were hardcoded and everything else silently got 12.
|
||||
const inferCurrencyDecimals = (symbol) => ChainProfile.decimalsFor(symbol);
|
||||
|
||||
const getDecimalDivisor = (decimals = DEFAULT_DECIMALS) => {
|
||||
const normalized = Number.isFinite(decimals) ? decimals : DEFAULT_DECIMALS;
|
||||
return Math.pow(10, normalized);
|
||||
};
|
||||
|
||||
// The product name for a given currency (Monero -> Monerogue, otherwise Wownerogue).
|
||||
const gameNameFor = (cryptoType) => (String(cryptoType || '').toUpperCase() === 'XMR' ? 'Monerogue' : 'Wownerogue');
|
||||
|
||||
// The user-facing currency label, network-aware: Monero on a test network shows sXMR
|
||||
// (stagenet) / tXMR (testnet) so it's clear the coins aren't mainnet; mainnet shows XMR.
|
||||
// Wownero has no test networks, so it's always WOW.
|
||||
const currencyLabelFor = (cryptoType, network) => {
|
||||
const ct = String(cryptoType || '').toUpperCase();
|
||||
const net = String(network || 'mainnet').toLowerCase();
|
||||
if (ct === 'XMR' && (net === 'stagenet' || net === 'testnet')) {
|
||||
return (net === 'testnet' ? 't' : 's') + 'XMR';
|
||||
}
|
||||
return ct;
|
||||
};
|
||||
|
||||
// Smirk wallet only works on mainnet Monero/Wownero, so it must be off on test networks.
|
||||
const isTestNetworkFor = (network) => {
|
||||
const net = String(network || 'mainnet').toLowerCase();
|
||||
return net === 'stagenet' || net === 'testnet';
|
||||
};
|
||||
|
||||
const formatAtomic = ({ value, decimals, digits = 6 }) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '0';
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const formatAtomicHuman = ({ value, decimals, digits = 3 }) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '0';
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_DECIMALS,
|
||||
parseAtomicEnvValue,
|
||||
inferCurrencyDecimals,
|
||||
getDecimalDivisor,
|
||||
formatAtomic,
|
||||
formatAtomicHuman,
|
||||
gameNameFor,
|
||||
currencyLabelFor,
|
||||
isTestNetworkFor
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
const ROT = require('./rot.js');
|
||||
|
||||
class LightingAndFov {
|
||||
/**
|
||||
* Initializes the FOV calculator.
|
||||
* @param {object} dungeonMap - The dungeon map.
|
||||
* @param {object} gameConfig - Game configuration containing tile definitions.
|
||||
* @returns {object} ROT.FOV.PreciseShadowcasting instance.
|
||||
*/
|
||||
static initializeFOV(dungeonMap, gameConfig) {
|
||||
return new ROT.FOV.PreciseShadowcasting(
|
||||
(x, y) => {
|
||||
if (!dungeonMap[y] || dungeonMap[y][x] === undefined) return false;
|
||||
const tile = dungeonMap[y][x];
|
||||
const primaryFloor = gameConfig.primaryFloor || "'1";
|
||||
const secondaryFloor = gameConfig.secondaryFloor || "'2";
|
||||
return tile === primaryFloor || tile === secondaryFloor;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visible tiles based on player position and FOV.
|
||||
* @param {object} fov - The FOV calculator instance.
|
||||
* @param {object} player - The player object with x, y coordinates.
|
||||
* @param {object} dungeonMap - The dungeon map.
|
||||
* @param {number} fovRadius - The radius of the FOV.
|
||||
* @returns {object} The visible tiles object.
|
||||
*/
|
||||
static updateFOV(fov, player, dungeonMap, fovRadius = 10) {
|
||||
const visibleTiles = {};
|
||||
fov.compute(player.x, player.y, fovRadius, (x, y, r, visibility) => {
|
||||
if (visibility > 0) {
|
||||
if (!visibleTiles[y]) {
|
||||
visibleTiles[y] = {};
|
||||
}
|
||||
if (dungeonMap && dungeonMap[y] && dungeonMap[y][x] !== undefined) {
|
||||
visibleTiles[y][x] = dungeonMap[y][x];
|
||||
}
|
||||
}
|
||||
});
|
||||
return visibleTiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates lighting levels for all visible tiles based on distance from torches.
|
||||
* @param {object} player - The player object with x, y coordinates.
|
||||
* @param {Array} allTorches - An array of all torch positions [[x,y], ...].
|
||||
* @param {object} visibleTiles - The visible tiles object.
|
||||
* @param {number} fovRadius - The player's FOV radius.
|
||||
* @returns {object} The lighting data object.
|
||||
*/
|
||||
static calculateLighting(player, allTorches = [], visibleTiles, fovRadius = 10) {
|
||||
const lightingData = {};
|
||||
const playerX = player.x;
|
||||
const playerY = player.y;
|
||||
const maxDarknessDistance = 8; // Distance at which light fully drops to max darkness
|
||||
const maxTorchInfluenceRadius = fovRadius + maxDarknessDistance;
|
||||
|
||||
const nearbyTorches = allTorches.filter(torch => {
|
||||
const distToPlayer = Math.max(Math.abs(torch[0] - playerX), Math.abs(torch[1] - playerY));
|
||||
return distToPlayer < maxTorchInfluenceRadius;
|
||||
});
|
||||
|
||||
for (const yKey in visibleTiles) {
|
||||
const y = parseInt(yKey);
|
||||
lightingData[y] = {};
|
||||
for (const xKey in visibleTiles[y]) {
|
||||
const x = parseInt(xKey);
|
||||
let minDistanceToTorch = Infinity;
|
||||
|
||||
if (nearbyTorches.length === 0) {
|
||||
minDistanceToTorch = maxTorchInfluenceRadius; // No torch nearby resolves to max darkness
|
||||
} else {
|
||||
for (const torch of nearbyTorches) {
|
||||
const distance = Math.max(Math.abs(x - torch[0]), Math.abs(y - torch[1]));
|
||||
minDistanceToTorch = Math.min(minDistanceToTorch, distance);
|
||||
}
|
||||
}
|
||||
|
||||
let alpha = 0.0;
|
||||
if (minDistanceToTorch === 0) { // Tile is a torch
|
||||
alpha = 0.0;
|
||||
} else if (minDistanceToTorch === 1) {
|
||||
alpha = 0.05 + Math.random() * 0.15; // Flicker: 0.05 - 0.20
|
||||
} else if (minDistanceToTorch === 2) {
|
||||
alpha = 0.15 + Math.random() * 0.15; // Flicker: 0.15 - 0.30
|
||||
} else if (minDistanceToTorch === 3) {
|
||||
alpha = 0.25 + Math.random() * 0.15; // Flicker: 0.25 - 0.40
|
||||
} else if (minDistanceToTorch === 4) {
|
||||
alpha = 0.35 + Math.random() * 0.15; // Flicker: 0.35 - 0.50
|
||||
} else if (minDistanceToTorch === 5) {
|
||||
alpha = 0.45 + Math.random() * 0.15; // Flicker: 0.45 - 0.60
|
||||
} else {
|
||||
// Stable falloff for distances > 5 up to maxDarknessDistance
|
||||
const falloffStartDistance = 5; // End of the flicker zones
|
||||
const lightLevelAtFalloffStart = 0.6; // Max alpha of the distance-5 flicker (0.45 + 0.15)
|
||||
const maxDarknessAlpha = 0.9;
|
||||
|
||||
if (minDistanceToTorch >= maxDarknessDistance) {
|
||||
alpha = maxDarknessAlpha;
|
||||
} else { // minDistanceToTorch lies between falloffStartDistance + 1 and maxDarknessDistance - 1
|
||||
let progress = (minDistanceToTorch - falloffStartDistance) / (maxDarknessDistance - falloffStartDistance);
|
||||
alpha = lightLevelAtFalloffStart + progress * (maxDarknessAlpha - lightLevelAtFalloffStart);
|
||||
}
|
||||
}
|
||||
alpha = Math.max(0.0, Math.min(0.9, alpha));
|
||||
lightingData[y][x] = alpha;
|
||||
}
|
||||
}
|
||||
return lightingData;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LightingAndFov;
|
||||
@@ -0,0 +1,261 @@
|
||||
const ROT = require('./rot.js');
|
||||
const { seededShuffle } = require('./provablyFair');
|
||||
|
||||
/**
|
||||
* Monster class - Manages monster state and AI with improved pathfinding
|
||||
*
|
||||
* AI Behavior:
|
||||
* - Uses A* pathfinding when player is visible (line of sight)
|
||||
* - Patrols toward last known player position when player not visible
|
||||
* - Falls back to wandering when no target
|
||||
* - Vision limited to configurable range (not omniscient)
|
||||
*/
|
||||
class Monster {
|
||||
constructor(x = 0, y = 0, options = {}) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
|
||||
// AI state
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this.visionRange = options.visionRange || 12; // tiles the monster can "see"
|
||||
this.pathCache = null;
|
||||
this.pathCacheTarget = null;
|
||||
}
|
||||
|
||||
// Move monster to new position
|
||||
moveTo(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
// Check if monster is at given coordinates
|
||||
isAt(x, y) {
|
||||
return this.x === x && this.y === y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if monster has line of sight to a position
|
||||
* Uses Bresenham's line algorithm
|
||||
*/
|
||||
hasLineOfSight(targetX, targetY, dungeon, isPassable) {
|
||||
const dx = Math.abs(targetX - this.x);
|
||||
const dy = Math.abs(targetY - this.y);
|
||||
const sx = this.x < targetX ? 1 : -1;
|
||||
const sy = this.y < targetY ? 1 : -1;
|
||||
let err = dx - dy;
|
||||
let x = this.x;
|
||||
let y = this.y;
|
||||
|
||||
while (x !== targetX || y !== targetY) {
|
||||
const e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
|
||||
// Check if we hit the target
|
||||
if (x === targetX && y === targetY) return true;
|
||||
|
||||
// Check if blocked by wall
|
||||
if (!dungeon.map[y] || !isPassable(dungeon.map[y][x])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance to a point
|
||||
*/
|
||||
distanceTo(x, y) {
|
||||
return Math.sqrt((x - this.x) ** 2 + (y - this.y) ** 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find path using A* pathfinding
|
||||
* Returns array of [x, y] positions or null if no path
|
||||
*/
|
||||
findPath(targetX, targetY, dungeon, isPassable) {
|
||||
// Use ROT.js A* pathfinding
|
||||
const passableCallback = (x, y) => {
|
||||
if (!dungeon.map[y]) return false;
|
||||
return isPassable(dungeon.map[y][x]);
|
||||
};
|
||||
|
||||
const path = [];
|
||||
const astar = new ROT.Path.AStar(targetX, targetY, passableCallback, { topology: 4 });
|
||||
|
||||
astar.compute(this.x, this.y, (x, y) => {
|
||||
path.push([x, y]);
|
||||
});
|
||||
|
||||
// Remove the starting position from path
|
||||
if (path.length > 0 && path[0][0] === this.x && path[0][1] === this.y) {
|
||||
path.shift();
|
||||
}
|
||||
|
||||
return path.length > 0 ? path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved AI to move toward player using A* pathfinding with limited vision
|
||||
*/
|
||||
moveTowardPlayer(player, dungeon, rng = Math.random) {
|
||||
if (!dungeon || !dungeon.map) return;
|
||||
|
||||
// Check if a tile is passable (floor, stairs, treasure)
|
||||
const isPassable = (tile) => {
|
||||
return tile === "'1" || tile === "'2" || tile === 0 ||
|
||||
tile === '>' || tile === '$M'; // Can walk on stairs and treasure
|
||||
};
|
||||
|
||||
// If no player target, wander randomly
|
||||
if (!player) {
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
return;
|
||||
}
|
||||
|
||||
const distToPlayer = this.distanceTo(player.x, player.y);
|
||||
let canSeePlayer = false;
|
||||
|
||||
// Check if player is within vision range AND line of sight
|
||||
if (distToPlayer <= this.visionRange) {
|
||||
canSeePlayer = this.hasLineOfSight(player.x, player.y, dungeon, isPassable);
|
||||
}
|
||||
|
||||
if (canSeePlayer) {
|
||||
// Player visible - update last known position and use A* pathfinding
|
||||
this.lastKnownPlayerX = player.x;
|
||||
this.lastKnownPlayerY = player.y;
|
||||
|
||||
// Find path to player
|
||||
const path = this.findPath(player.x, player.y, dungeon, isPassable);
|
||||
if (path && path.length > 0) {
|
||||
// Move to next position in path
|
||||
const [nextX, nextY] = path[0];
|
||||
this.x = nextX;
|
||||
this.y = nextY;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: direct move toward player
|
||||
this._moveDirectToward(player.x, player.y, dungeon, isPassable, rng);
|
||||
|
||||
} else if (this.lastKnownPlayerX !== null) {
|
||||
// Player not visible but we have last known position - patrol toward it
|
||||
const distToLastKnown = this.distanceTo(this.lastKnownPlayerX, this.lastKnownPlayerY);
|
||||
|
||||
if (distToLastKnown < 1) {
|
||||
// Reached last known position, clear it and wander
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
} else {
|
||||
// Path toward last known position
|
||||
const path = this.findPath(this.lastKnownPlayerX, this.lastKnownPlayerY, dungeon, isPassable);
|
||||
if (path && path.length > 0) {
|
||||
const [nextX, nextY] = path[0];
|
||||
this.x = nextX;
|
||||
this.y = nextY;
|
||||
} else {
|
||||
// Can't reach last known position, clear and wander
|
||||
this.lastKnownPlayerX = null;
|
||||
this.lastKnownPlayerY = null;
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No player info - wander randomly
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple direct movement toward a target (fallback)
|
||||
*/
|
||||
_moveDirectToward(targetX, targetY, dungeon, isPassable, rng = Math.random) {
|
||||
const dx = Math.sign(targetX - this.x);
|
||||
const dy = Math.sign(targetY - this.y);
|
||||
|
||||
// Try both directions, prioritize the one with more distance to cover
|
||||
const xDist = Math.abs(targetX - this.x);
|
||||
const yDist = Math.abs(targetY - this.y);
|
||||
|
||||
if (xDist >= yDist && dx !== 0) {
|
||||
// Try horizontal first
|
||||
const newX = this.x + dx;
|
||||
if (dungeon.map[this.y] && isPassable(dungeon.map[this.y][newX])) {
|
||||
this.x = newX;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (dy !== 0) {
|
||||
const newY = this.y + dy;
|
||||
if (dungeon.map[newY] && isPassable(dungeon.map[newY][this.x])) {
|
||||
this.y = newY;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try horizontal if we haven't yet
|
||||
if (xDist < yDist && dx !== 0) {
|
||||
const newX = this.x + dx;
|
||||
if (dungeon.map[this.y] && isPassable(dungeon.map[this.y][newX])) {
|
||||
this.x = newX;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked in preferred directions, try diagonals (A* backup)
|
||||
this._wander(dungeon, isPassable, rng);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random wandering behavior
|
||||
*/
|
||||
_wander(dungeon, isPassable, rng = Math.random) {
|
||||
const dirs = [[0,1],[1,0],[0,-1],[-1,0]];
|
||||
// Deterministic shuffle from the per-game seeded RNG (provably fair).
|
||||
const shuffledDirs = seededShuffle(rng, dirs.slice());
|
||||
|
||||
for (const [dx, dy] of shuffledDirs) {
|
||||
const nx = this.x + dx;
|
||||
const ny = this.y + dy;
|
||||
if (dungeon.map[ny] && isPassable(dungeon.map[ny][nx])) {
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if monster has caught the player
|
||||
hasCaughtPlayer(player) {
|
||||
if (!player) return false;
|
||||
|
||||
// Check for direct overlap - monster on same tile as player
|
||||
return this.x === player.x && this.y === player.y;
|
||||
}
|
||||
|
||||
// Get monster state as object
|
||||
getState() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y
|
||||
};
|
||||
}
|
||||
|
||||
// Set monster state from object
|
||||
setState(state) {
|
||||
if (state.x !== undefined) this.x = state.x;
|
||||
if (state.y !== undefined) this.y = state.y;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Monster;
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* MovementManager
|
||||
* Handles rate limiting and applying player movement to games.
|
||||
*/
|
||||
class MovementManager {
|
||||
constructor({ activeGames, io, debugManager, moveCooldown = 100, postMoveHook = null, spectatorManager = null }) {
|
||||
this.activeGames = activeGames; // Map socketId -> Game
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.moveCooldown = moveCooldown;
|
||||
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;
|
||||
if (now - last < this.moveCooldown) return; // rate limit
|
||||
this._lastMove.set(socketId, now);
|
||||
|
||||
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, which
|
||||
// blocks replacement games. Game.movePlayer does not inspect gameState, so this guard is what
|
||||
// stops the retained Game object from accepting further moves.
|
||||
if (game.settlementPending || game.settlementCommitted || game.gameState === 'ended') return;
|
||||
|
||||
const dir = moveData.direction;
|
||||
let dx = 0, dy = 0;
|
||||
switch (dir) {
|
||||
case 'up': dy = -1; break;
|
||||
case 'down': dy = 1; break;
|
||||
case 'left': dx = -1; break;
|
||||
case 'right': dx = 1; break;
|
||||
default: return;
|
||||
}
|
||||
|
||||
let moveResult = null;
|
||||
if (typeof game.movePlayer === 'function') {
|
||||
moveResult = game.movePlayer(dx, dy);
|
||||
}
|
||||
|
||||
// The hook performs monster movement and other side-effects before the state snapshot.
|
||||
if (moveResult && moveResult.status === 'moved' && this.postMoveHook) {
|
||||
try {
|
||||
this.postMoveHook({ socketId, game, moveResult });
|
||||
} catch (e) {
|
||||
console.error('postMoveHook error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// State reflects both the player move and any monster move made by the hook.
|
||||
let state;
|
||||
if (typeof game.getState === 'function') {
|
||||
state = game.getState();
|
||||
} else {
|
||||
state = { player: game.player };
|
||||
}
|
||||
if (this.debugManager && typeof this.debugManager.getCurrentBlockHeight === 'function') {
|
||||
state.blockHeight = this.debugManager.getCurrentBlockHeight();
|
||||
}
|
||||
|
||||
this.io.to(socketId).emit('game_update', state);
|
||||
|
||||
// Broadcast to spectators
|
||||
if (this.spectatorManager && game.id) {
|
||||
this.spectatorManager.broadcastToSpectators(game.id, state);
|
||||
}
|
||||
|
||||
// Special events from moveResult (escape / treasure / descend). Depth and maxDepth accompany a
|
||||
// multi-level descend so the client can tell the player they took the stairs down.
|
||||
if (moveResult && moveResult.event) {
|
||||
this.io.to(socketId).emit('game_event', {
|
||||
event: moveResult.event, depth: moveResult.depth, maxDepth: moveResult.maxDepth
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
emitGameUpdate(socketId) {
|
||||
const game = this.activeGames.get(socketId);
|
||||
if (!game) {
|
||||
return;
|
||||
}
|
||||
let state;
|
||||
if (typeof game.getState === 'function') {
|
||||
state = game.getState();
|
||||
} else {
|
||||
state = { player: game.player };
|
||||
}
|
||||
if (this.debugManager && typeof this.debugManager.getCurrentBlockHeight === 'function') {
|
||||
state.blockHeight = this.debugManager.getCurrentBlockHeight();
|
||||
}
|
||||
this.io.to(socketId).emit('game_update', state);
|
||||
|
||||
// Broadcast to spectators
|
||||
if (this.spectatorManager && game.id) {
|
||||
this.spectatorManager.broadcastToSpectators(game.id, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the move-cooldown entry for a departed socket.
|
||||
*
|
||||
* `_lastMove` is keyed by the volatile socket.id and written on every accepted move. Without this
|
||||
* call it is only emptied by shutdown(), leaving one permanent entry per socket that ever moved.
|
||||
*/
|
||||
forgetSocket(socketId) {
|
||||
this._lastMove.delete(socketId);
|
||||
}
|
||||
|
||||
/** Freeze gameplay before the graceful-shutdown settlement drain takes its snapshot. */
|
||||
shutdown() {
|
||||
this._acceptingMoves = false;
|
||||
this._lastMove.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MovementManager;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Player class - Manages player state and actions
|
||||
*/
|
||||
class Player {
|
||||
constructor(x = 0, y = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.hasKey = false;
|
||||
this.hasTreasure = false;
|
||||
}
|
||||
|
||||
// Move player to new position
|
||||
moveTo(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
// Check if player is at given coordinates
|
||||
isAt(x, y) {
|
||||
return this.x === x && this.y === y;
|
||||
}
|
||||
|
||||
// Reset player state
|
||||
reset() {
|
||||
this.hasKey = false;
|
||||
this.hasTreasure = false;
|
||||
}
|
||||
|
||||
// Get player state as object
|
||||
getState() {
|
||||
return {
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
hasKey: this.hasKey,
|
||||
hasTreasure: this.hasTreasure
|
||||
};
|
||||
}
|
||||
|
||||
// Set player state from object
|
||||
setState(state) {
|
||||
if (state.x !== undefined) this.x = state.x;
|
||||
if (state.y !== undefined) this.y = state.y;
|
||||
if (state.hasKey !== undefined) this.hasKey = state.hasKey;
|
||||
if (state.hasTreasure !== undefined) this.hasTreasure = state.hasTreasure;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Player;
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Provably Fair Gaming System
|
||||
*
|
||||
* This module implements cryptographic verification for committed dungeon generation.
|
||||
*
|
||||
* How it works:
|
||||
* 1. Server generates a random seed before game starts
|
||||
* 2. Server computes SHA-256 hash of the seed
|
||||
* 3. Hash is shown to player BEFORE game starts (commitment)
|
||||
* 4. Seed is used to deterministically generate:
|
||||
* - Dungeon layout
|
||||
* - Player spawn position
|
||||
* - Monster spawn position
|
||||
* - Treasure position
|
||||
* - Exit position
|
||||
* 5. After game ends, seed is revealed
|
||||
* 6. Player can verify: hash(seed) === pre-game commitment
|
||||
* 7. Player can regenerate the dungeon using the seed to verify fairness
|
||||
*
|
||||
* This lets a player detect the server:
|
||||
* - Generating impossible dungeons after seeing player's payment
|
||||
* - 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');
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random seed
|
||||
* @returns {string} 64-character hex string (256 bits)
|
||||
*/
|
||||
function generateSeed() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a commitment hash of a seed
|
||||
* @param {string} seed - The game seed
|
||||
* @returns {string} SHA-256 hash of the seed (64-character hex)
|
||||
*/
|
||||
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
|
||||
* @param {string} commitment - The hash that was shown before the game
|
||||
* @returns {boolean} True if the seed matches the commitment
|
||||
*/
|
||||
function verifySeed(seed, commitment) {
|
||||
return hashSeed(seed) === commitment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the effective RNG seed from a server seed and an (optional) client seed.
|
||||
* Uses HMAC-SHA256 with the server seed as key and the client seed as message so
|
||||
* that neither party can unilaterally control the outcome:
|
||||
* - the server commits to hash(serverSeed) BEFORE seeing the client seed, and
|
||||
* - the client seed still perturbs the final layout.
|
||||
* The commitment stays hash(serverSeed); a verifier with (serverSeed, clientSeed)
|
||||
* can reproduce this derived seed and therefore the entire dungeon.
|
||||
* @param {string} serverSeed - The secret server seed (revealed after the game)
|
||||
* @param {string} [clientSeed=''] - Optional client-supplied seed
|
||||
* @returns {string} 64-character hex string used as the effective RNG seed
|
||||
*/
|
||||
function deriveSeed(serverSeed, clientSeed = '') {
|
||||
return crypto.createHmac('sha256', String(serverSeed))
|
||||
.update(String(clientSeed ?? ''))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a seeded random number generator
|
||||
* Uses the seed to create deterministic "random" numbers
|
||||
* This allows players to recreate the exact same dungeon
|
||||
*
|
||||
* @param {string} seed - The game seed
|
||||
* @returns {function} A function that returns deterministic random numbers [0, 1)
|
||||
*/
|
||||
function createSeededRNG(seed) {
|
||||
// Use seed to create a deterministic PRNG
|
||||
// We'll use a simple but effective approach: hash-based PRNG
|
||||
let state = seed;
|
||||
let counter = 0;
|
||||
|
||||
const rng = function() {
|
||||
// Generate next state by hashing current state + counter
|
||||
const hash = crypto.createHash('sha256')
|
||||
.update(state + counter.toString())
|
||||
.digest();
|
||||
counter++;
|
||||
|
||||
// Convert first 4 bytes to a number in [0, 1). Divide by 2^32 (not
|
||||
// 0xFFFFFFFF) so the maximum value stays strictly below 1.
|
||||
const num = hash.readUInt32BE(0);
|
||||
return num / 0x100000000;
|
||||
};
|
||||
|
||||
// A graceful restart must resume at the exact next draw; resetting the generator would make
|
||||
// post-restart monster behaviour diverge from the committed run. These deliberately narrow
|
||||
// accessors keep the normal callable-RNG API intact while allowing a validated runtime
|
||||
// snapshot to preserve/restore only the monotonically increasing counter.
|
||||
Object.defineProperties(rng, {
|
||||
getCounter: {
|
||||
value: () => counter,
|
||||
enumerable: false
|
||||
},
|
||||
setCounter: {
|
||||
value: (nextCounter) => {
|
||||
if (!Number.isSafeInteger(nextCounter) || nextCounter < 0) {
|
||||
throw new Error('Invalid seeded RNG counter');
|
||||
}
|
||||
counter = nextCounter;
|
||||
},
|
||||
enumerable: false
|
||||
}
|
||||
});
|
||||
|
||||
return rng;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a positive integer from a hex seed, suitable for seeding ROT.RNG.
|
||||
* 13 hex chars = 52 bits, which stays within Number.MAX_SAFE_INTEGER so the
|
||||
* value is represented exactly. Always returns >= 1.
|
||||
* @param {string} seed - The game seed (hex)
|
||||
* @returns {number} A positive integer derived deterministically from the seed
|
||||
*/
|
||||
function seedToInt(seed) {
|
||||
const hex = String(seed).replace(/[^0-9a-fA-F]/g, '').slice(0, 13) || '1';
|
||||
const n = parseInt(hex, 16);
|
||||
return Number.isFinite(n) && n > 0 ? n : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a per-level seed for a multi-level run. Level 1 uses the master seed verbatim (so
|
||||
* single-level games stay byte-identical to pre-multi-level behaviour); deeper levels salt the
|
||||
* master seed with the depth. The whole descent is therefore reproducible from the one committed
|
||||
* seed: feed (masterSeed, depth) to `createSeededRNG`/`seedToInt` to regenerate any level.
|
||||
* @param {string} masterSeed - The game's committed seed
|
||||
* @param {number} depth - 1-based level index
|
||||
* @returns {string} The seed string for that level
|
||||
*/
|
||||
function levelSeed(masterSeed, depth) {
|
||||
return depth > 1 ? String(masterSeed) + ':L' + depth : String(masterSeed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random integer in range [min, max] using seeded RNG
|
||||
* @param {function} rng - Seeded RNG function
|
||||
* @param {number} min - Minimum value (inclusive)
|
||||
* @param {number} max - Maximum value (inclusive)
|
||||
* @returns {number} Random integer
|
||||
*/
|
||||
function seededRandomInt(rng, min, max) {
|
||||
return Math.floor(rng() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle an array deterministically using seeded RNG
|
||||
* @param {function} rng - Seeded RNG function
|
||||
* @param {Array} array - Array to shuffle
|
||||
* @returns {Array} New shuffled array
|
||||
*/
|
||||
function seededShuffle(rng, array) {
|
||||
const result = [...array];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a game proof object for a new game
|
||||
* @param {string} gameId - Unique game identifier
|
||||
* @param {string} [clientSeed=''] - Optional client-supplied seed (non-breaking)
|
||||
* @returns {object} Proof object with seeds, commitment, and creation time
|
||||
*/
|
||||
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, 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: normalizedClientSeed, // Client contribution (may be '')
|
||||
seed, // Derived effective seed used to generate the dungeon
|
||||
commitment, // Show to player before game starts
|
||||
timestamp,
|
||||
verified: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the verification data to send to player BEFORE game starts
|
||||
* This is the "commitment" phase - player sees hash but not seed
|
||||
* @param {object} proof - Game proof object
|
||||
* @returns {object} Safe data to send to client
|
||||
*/
|
||||
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."
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the verification data to send to player AFTER game ends
|
||||
* This is the "reveal" phase - player gets seed to verify
|
||||
* @param {object} proof - Game proof object
|
||||
* @param {object} gameResult - Game outcome data
|
||||
* @returns {object} Full verification data for client
|
||||
*/
|
||||
function getPostGameReveal(proof, gameResult = {}) {
|
||||
const serverSeed = proof.serverSeed ?? proof.seed;
|
||||
const clientSeed = proof.clientSeed ?? '';
|
||||
// Effective seed a verifier regenerates the dungeon from.
|
||||
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, // 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,
|
||||
treasureFound: gameResult.treasureFound ?? false,
|
||||
moves: gameResult.moves ?? 0,
|
||||
duration: gameResult.duration ?? 0
|
||||
},
|
||||
verificationUrl: `/verify/${proof.gameId}`,
|
||||
instructions: [
|
||||
"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 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
|
||||
* @param {string} expectedCommitment - The pre-game commitment hash
|
||||
* @returns {object} Verification result
|
||||
*/
|
||||
function verifyGame(seed, expectedCommitment) {
|
||||
const computedHash = hashSeed(seed);
|
||||
const isValid = computedHash === expectedCommitment;
|
||||
|
||||
return {
|
||||
valid: isValid,
|
||||
seed: seed,
|
||||
expectedCommitment: expectedCommitment,
|
||||
computedHash: computedHash,
|
||||
match: isValid,
|
||||
message: isValid
|
||||
? "✅ Game verified as fair! The seed matches the pre-game commitment."
|
||||
: "❌ Verification failed! The seed does not match the commitment."
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSeed,
|
||||
hashSeed,
|
||||
normalizeClientSeed,
|
||||
verifySeed,
|
||||
deriveSeed,
|
||||
createSeededRNG,
|
||||
seedToInt,
|
||||
levelSeed,
|
||||
seededRandomInt,
|
||||
seededShuffle,
|
||||
createGameProof,
|
||||
getPreGameCommitment,
|
||||
getPostGameReveal,
|
||||
verifyGame,
|
||||
verifyGameProof
|
||||
};
|
||||
+7228
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Ruleset: gameplay as a data object (Pillar 4).
|
||||
*
|
||||
* A Ruleset captures everything that makes one game mode different from another: the world it
|
||||
* generates, the entities in it, how many players, how someone wins, the clock, and the economy.
|
||||
* The match engine READS a ruleset instead of hardcoding rules, so new modes (PvP Last-Alive,
|
||||
* Score Attack, Co-op) are data, not new subsystems, and operators/players can later author their
|
||||
* own lobbies by supplying a ruleset object.
|
||||
*
|
||||
* defineRuleset normalizes a partial spec to a complete, frozen ruleset with safe defaults. The
|
||||
* defaults reproduce the classic single-dungeon escape so an empty spec is a sane baseline.
|
||||
*/
|
||||
|
||||
const WIN = {
|
||||
FIRST_TO_EXIT: 'first-to-exit', // first player out the exit wins, ends immediately (classic race)
|
||||
LAST_ALIVE: 'last-alive', // last player still alive-and-in-play wins (PvP / battle royale)
|
||||
HIGH_SCORE: 'high-score', // highest score when the match ends (score attack)
|
||||
ALL_ESCAPE: 'all-escape' // co-op: everyone must reach the exit
|
||||
};
|
||||
|
||||
const ECONOMY = { FREE: 'free', CREDITS_PRESTIGE: 'credits_prestige', CRYPTO_RACE: 'crypto_race' };
|
||||
|
||||
const num = (v, d) => (Number.isFinite(v) ? v : d);
|
||||
const clampInt = (v, lo, hi, d) => {
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d;
|
||||
};
|
||||
|
||||
function defineRuleset(spec = {}) {
|
||||
const s = spec || {};
|
||||
const world = s.world || {};
|
||||
const entities = s.entities || {};
|
||||
const players = s.players || {};
|
||||
const winCondition = s.winCondition || {};
|
||||
const timing = s.timing || {};
|
||||
const economy = s.economy || {};
|
||||
|
||||
const rs = {
|
||||
id: String(s.id || 'custom'),
|
||||
label: String(s.label || s.id || 'Custom'),
|
||||
mode: s.mode || 'race', // 'solo' | 'race' | 'pvp' | 'coop': a coarse family label
|
||||
world: Object.freeze({
|
||||
difficultyPreset: world.difficultyPreset || 'normal',
|
||||
maps: clampInt(world.maps, 1, 10, 1) // reserved for multi-level; engine uses 1 today
|
||||
}),
|
||||
entities: Object.freeze({
|
||||
monster: entities.monster !== false, // default true
|
||||
monsterCount: clampInt(entities.monsterCount, 0, 8, entities.monster === false ? 0 : 1),
|
||||
pvpCombat: !!entities.pvpCombat // stepping onto a rival kills them instead of being blocked
|
||||
}),
|
||||
players: Object.freeze({
|
||||
min: clampInt(players.min, 1, 32, 1),
|
||||
max: clampInt(players.max, 1, 32, 4)
|
||||
}),
|
||||
winCondition: Object.freeze({
|
||||
type: Object.values(WIN).includes(winCondition.type) ? winCondition.type : WIN.FIRST_TO_EXIT
|
||||
}),
|
||||
timing: Object.freeze({
|
||||
tickMs: clampInt(timing.tickMs, 50, 5000, 250),
|
||||
minDurationMs: clampInt(timing.minDurationMs, 0, 600000, 20000),
|
||||
hardCeilingMs: clampInt(timing.hardCeilingMs, 1000, 3600000, 240000),
|
||||
blockDeadline: timing.blockDeadline !== false
|
||||
}),
|
||||
economy: Object.freeze({
|
||||
model: Object.values(ECONOMY).includes(economy.model) ? economy.model : ECONOMY.FREE,
|
||||
houseFeePercent: num(economy.houseFeePercent, 0),
|
||||
payoutMultipliers: Object.freeze({
|
||||
escape: num(economy.payoutMultipliers && economy.payoutMultipliers.escape, 2),
|
||||
escapeWithTreasure: num(economy.payoutMultipliers && economy.payoutMultipliers.escapeWithTreasure, 3)
|
||||
})
|
||||
}),
|
||||
metadata: Object.freeze({ ...(s.metadata || {}) })
|
||||
};
|
||||
// normalize min<=max
|
||||
if (rs.players.min > rs.players.max) {
|
||||
rs.players = Object.freeze({ min: rs.players.max, max: rs.players.max });
|
||||
}
|
||||
return Object.freeze(rs);
|
||||
}
|
||||
|
||||
module.exports = { defineRuleset, WIN, ECONOMY };
|
||||
@@ -0,0 +1,23 @@
|
||||
const { defineRuleset, WIN, ECONOMY } = require('./Ruleset');
|
||||
const { resolveWinCondition } = require('./winConditions');
|
||||
const {
|
||||
BUILTINS,
|
||||
getRuleset,
|
||||
listRulesets,
|
||||
listMatchRulesets,
|
||||
resolveMatchRuleset,
|
||||
rulesetFromMatchOpts
|
||||
} = require('./registry');
|
||||
|
||||
module.exports = {
|
||||
defineRuleset,
|
||||
WIN,
|
||||
ECONOMY,
|
||||
resolveWinCondition,
|
||||
BUILTINS,
|
||||
getRuleset,
|
||||
listRulesets,
|
||||
listMatchRulesets,
|
||||
resolveMatchRuleset,
|
||||
rulesetFromMatchOpts
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
const { defineRuleset, WIN, ECONOMY } = require('./Ruleset');
|
||||
|
||||
/**
|
||||
* Built-in rulesets. `race` reproduces the current match behavior exactly (so nothing changes for
|
||||
* existing matches). `last-alive` and `score-attack` are the new PvP / score modes the match engine
|
||||
* now supports as data. `solo-classic` describes the live single-player escape for catalog/parity:
|
||||
* the single-player engine isn't refactored to consume it yet, so it's a descriptor, not wired.
|
||||
*/
|
||||
const BUILTINS = {
|
||||
'solo-classic': defineRuleset({
|
||||
id: 'solo-classic', label: 'Classic Escape', mode: 'solo',
|
||||
players: { min: 1, max: 1 },
|
||||
winCondition: { type: WIN.FIRST_TO_EXIT },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
metadata: { description: 'Work together until every surviving player escapes.' }
|
||||
})
|
||||
};
|
||||
|
||||
function getRuleset(id, overrides = null) {
|
||||
const base = BUILTINS[id] || BUILTINS.race;
|
||||
if (!overrides) return base;
|
||||
return defineRuleset({ ...base, ...overrides, id: base.id, label: base.label });
|
||||
}
|
||||
|
||||
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
|
||||
* of `variant` (which was a stored-but-unused label), so wiring the engine to rulesets changes
|
||||
* nothing until a caller explicitly asks for a different ruleset by id.
|
||||
*/
|
||||
function rulesetFromMatchOpts(opts = {}) {
|
||||
return defineRuleset({
|
||||
id: 'race',
|
||||
label: 'Escape Race',
|
||||
mode: 'race',
|
||||
world: { difficultyPreset: opts.difficultyPreset || 'race' },
|
||||
players: { min: 2, max: opts.maxPlayers || 8 },
|
||||
winCondition: { type: WIN.FIRST_TO_EXIT },
|
||||
economy: { model: opts.economy || ECONOMY.FREE }
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BUILTINS,
|
||||
getRuleset,
|
||||
listRulesets,
|
||||
listMatchRulesets,
|
||||
resolveMatchRuleset,
|
||||
rulesetFromMatchOpts
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Win-condition strategies. Each is a small object the MatchRoom consults at three decision points,
|
||||
* so the room no longer hardcodes "first to the exit wins":
|
||||
*
|
||||
* 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 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.
|
||||
*/
|
||||
const { WIN } = require('./Ruleset');
|
||||
|
||||
function manhattanToExit(room, occ, onlyIfAlive, state) {
|
||||
const exit = room.dungeon.exit;
|
||||
if (!exit || !occ) return Infinity;
|
||||
if (onlyIfAlive && (!state || !state.alive)) return Infinity;
|
||||
return Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]);
|
||||
}
|
||||
|
||||
function buildRanked(room, onlyIfAlive) {
|
||||
return Array.from(room.playerStates.entries()).map(([id, state]) => {
|
||||
const occ = room.occupants.get(id);
|
||||
return { id, state, occ, dist: manhattanToExit(room, occ, onlyIfAlive, state) };
|
||||
});
|
||||
}
|
||||
|
||||
const FIRST_TO_EXIT = {
|
||||
onExit(room, id) {
|
||||
if (!room.winnerId) {
|
||||
room.winnerId = id;
|
||||
room.endReason = 'escaped';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
onDeath(room) {
|
||||
if (room.activePlayerCount === 0 && room.finishCount === 0 && room.status === 'active') {
|
||||
room.endReason = 'all_dead';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
// Verbatim port of the original finalize() sort.
|
||||
rank(room) {
|
||||
const preWinner = room.winnerId;
|
||||
const ranked = buildRanked(room, true);
|
||||
ranked.sort((a, b) => {
|
||||
if (preWinner) {
|
||||
const aw = a.id === preWinner ? 1 : 0;
|
||||
const bw = b.id === preWinner ? 1 : 0;
|
||||
if (aw !== bw) return bw - aw;
|
||||
}
|
||||
const finishedA = a.state.finished ? 1 : 0;
|
||||
const finishedB = b.state.finished ? 1 : 0;
|
||||
if (finishedA !== finishedB) return finishedB - finishedA;
|
||||
const aliveA = a.state.alive ? 1 : 0;
|
||||
const aliveB = b.state.alive ? 1 : 0;
|
||||
if (aliveA !== aliveB) return aliveB - aliveA;
|
||||
if (a.dist !== b.dist) return a.dist - b.dist;
|
||||
const tA = a.state.hasTreasure ? 1 : 0;
|
||||
const tB = b.state.hasTreasure ? 1 : 0;
|
||||
if (tA !== tB) return tB - tA;
|
||||
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
};
|
||||
|
||||
// Last player still alive-and-in-play wins. Reaching the exit means you SURVIVED (safe) but does
|
||||
// not instantly win; the match ends when at most one contender remains.
|
||||
const LAST_ALIVE = {
|
||||
_maybeEnd(room) {
|
||||
const started = room.playerStates.size;
|
||||
if (started >= 2 && room.activePlayerCount <= 1 && room.status === 'active') {
|
||||
room.status = 'finished';
|
||||
room.endReason = room.activePlayerCount === 1 ? 'last_alive' : 'all_dead';
|
||||
}
|
||||
},
|
||||
onExit(room /*, id */) { LAST_ALIVE._maybeEnd(room); },
|
||||
onDeath(room) { LAST_ALIVE._maybeEnd(room); },
|
||||
rank(room) {
|
||||
// in-play (last standing) > escaped survivor > dead-later > dead-earlier; then treasure, id.
|
||||
const tier = (s) => (s.alive && !s.finished ? 3 : (s.escaped ? 2 : (s.alive ? 1 : 0)));
|
||||
const ranked = buildRanked(room, false);
|
||||
ranked.sort((a, b) => {
|
||||
const ta = tier(a.state), tb = tier(b.state);
|
||||
if (ta !== tb) return tb - ta;
|
||||
// among the dead, whoever died later ranks higher (survived longer)
|
||||
const da = a.state.deathOrder || 0, db = b.state.deathOrder || 0;
|
||||
if (da !== db) return db - da;
|
||||
const tA = a.state.hasTreasure ? 1 : 0, tB = b.state.hasTreasure ? 1 : 0;
|
||||
if (tA !== tB) return tB - tA;
|
||||
if (a.dist !== b.dist) return a.dist - b.dist;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
};
|
||||
|
||||
// Highest score when everyone is resolved (or the clock expires). No instant win on exit.
|
||||
const HIGH_SCORE = {
|
||||
onExit(/* room, id */) { /* escaping just finishes the player; scoring decides the winner */ },
|
||||
onDeath(room) {
|
||||
if (room.activePlayerCount === 0 && room.status === 'active') {
|
||||
room.endReason = 'resolved';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
rank(room) {
|
||||
const pre = (s, dist) => {
|
||||
let v = 0;
|
||||
if (s.escaped) v += 200;
|
||||
if (s.hasTreasure) v += 200;
|
||||
v += Math.max(0, 300 - Math.max(s.moves - 30, 0) * 3);
|
||||
if (!s.escaped && Number.isFinite(dist)) v += Math.max(0, 100 - dist * 2);
|
||||
return v;
|
||||
};
|
||||
const ranked = buildRanked(room, false);
|
||||
ranked.sort((a, b) => {
|
||||
const sa = pre(a.state, a.dist), sb = pre(b.state, b.dist);
|
||||
if (sa !== sb) return sb - sa;
|
||||
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return ranked;
|
||||
}
|
||||
};
|
||||
|
||||
// ALL_ESCAPE (co-op) is a lightweight variant of race: the match ends when everyone still alive has
|
||||
// escaped (or all died). Ranking falls back to the race ordering.
|
||||
const ALL_ESCAPE = {
|
||||
onExit(room) {
|
||||
if (room.activePlayerCount === 0 && room.finishCount > 0 && room.status === 'active') {
|
||||
room.endReason = 'all_escaped';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
onDeath(room) {
|
||||
if (room.activePlayerCount === 0 && room.status === 'active') {
|
||||
room.endReason = room.finishCount > 0 ? 'all_escaped' : 'all_dead';
|
||||
room.status = 'finished';
|
||||
}
|
||||
},
|
||||
rank(room) { return FIRST_TO_EXIT.rank(room); }
|
||||
};
|
||||
|
||||
const STRATEGIES = {
|
||||
[WIN.FIRST_TO_EXIT]: FIRST_TO_EXIT,
|
||||
[WIN.LAST_ALIVE]: LAST_ALIVE,
|
||||
[WIN.HIGH_SCORE]: HIGH_SCORE,
|
||||
[WIN.ALL_ESCAPE]: ALL_ESCAPE
|
||||
};
|
||||
|
||||
function resolveWinCondition(type) {
|
||||
return STRATEGIES[type] || FIRST_TO_EXIT;
|
||||
}
|
||||
|
||||
module.exports = { resolveWinCondition, FIRST_TO_EXIT, LAST_ALIVE, HIGH_SCORE, ALL_ESCAPE };
|
||||
@@ -0,0 +1,339 @@
|
||||
const { isDeepStrictEqual } = require('node:util');
|
||||
const Game = require('./game');
|
||||
const DungeonGenerator = require('./dungeon');
|
||||
|
||||
const SNAPSHOT_VERSION = 1;
|
||||
const MAX_RNG_COUNTER = 100000000;
|
||||
|
||||
function invariant(condition, message) {
|
||||
if (!condition) {
|
||||
const error = new Error(`Invalid solo restart snapshot: ${message}`);
|
||||
error.code = 'SOLO_RESTART_SNAPSHOT_INVALID';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function integer(value, label, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
|
||||
invariant(Number.isSafeInteger(value), `${label} must be a safe integer`);
|
||||
invariant(value >= min && value <= max, `${label} is outside the accepted range`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableInteger(value, label, bounds = {}) {
|
||||
return value == null ? null : integer(value, label, bounds);
|
||||
}
|
||||
|
||||
function finiteNumber(value, label, { min = -Number.MAX_VALUE, max = Number.MAX_VALUE } = {}) {
|
||||
invariant(Number.isFinite(value), `${label} must be finite`);
|
||||
invariant(value >= min && value <= max, `${label} is outside the accepted range`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonemptyString(value, label, maxLength = 255) {
|
||||
invariant(typeof value === 'string' && value.length > 0 && value.length <= maxLength,
|
||||
`${label} must be a nonempty bounded string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function jsonClone(value, label) {
|
||||
try {
|
||||
const encoded = JSON.stringify(value);
|
||||
invariant(typeof encoded === 'string', `${label} is not JSON serializable`);
|
||||
return JSON.parse(encoded);
|
||||
} catch (error) {
|
||||
if (error?.code === 'SOLO_RESTART_SNAPSHOT_INVALID') throw error;
|
||||
invariant(false, `${label} is not JSON serializable`);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonValue(value, label) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_) {
|
||||
invariant(false, `${label} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizedHex(value, label) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
invariant(/^[0-9a-f]{64}$/.test(normalized), `${label} must be 64 lowercase hex characters`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function captureSoloRestartSnapshot(game, { paymentMonitoringActive = false } = {}) {
|
||||
invariant(game && typeof game === 'object', 'game is required');
|
||||
invariant(!game.settlementPending && !game.settlementCommitted,
|
||||
'terminal or settlement-pending games cannot be captured');
|
||||
invariant(game.gameState === 'active', 'only active games can be captured');
|
||||
|
||||
const dbId = integer(Number(game.dbId), 'game.dbId', { min: 1 });
|
||||
const userId = integer(Number(game.dbUserId ?? game.userId), 'stable user id', { min: 1 });
|
||||
const gameId = nonemptyString(game.id, 'game.id', 50);
|
||||
const originalSocketId = nonemptyString(game.socketId, 'game.socketId');
|
||||
invariant(typeof game.seededRNG?.getCounter === 'function', 'seeded RNG is not snapshot-capable');
|
||||
|
||||
const player = game.player?.getState?.();
|
||||
const monster = game.monster;
|
||||
invariant(player && monster, 'player and monster state are required');
|
||||
|
||||
const state = {
|
||||
version: SNAPSHOT_VERSION,
|
||||
gameId,
|
||||
dbId,
|
||||
userId,
|
||||
proofTimestamp: integer(Number(game.gameProof?.timestamp), 'proof timestamp', { min: 0 }),
|
||||
gameConfig: jsonClone(game.gameConfig, 'game config'),
|
||||
difficultyConfig: jsonClone(game.difficultyConfig, 'difficulty config'),
|
||||
depth: integer(Number(game.depth), 'depth', { min: 1 }),
|
||||
maxDepth: integer(Number(game.maxDepth), 'max depth', { min: 1 }),
|
||||
player: {
|
||||
x: integer(Number(player.x), 'player.x'),
|
||||
y: integer(Number(player.y), 'player.y'),
|
||||
hasKey: player.hasKey === true,
|
||||
hasTreasure: player.hasTreasure === true
|
||||
},
|
||||
monster: {
|
||||
x: integer(Number(monster.x), 'monster.x'),
|
||||
y: integer(Number(monster.y), 'monster.y'),
|
||||
lastKnownPlayerX: nullableInteger(monster.lastKnownPlayerX, 'monster.lastKnownPlayerX'),
|
||||
lastKnownPlayerY: nullableInteger(monster.lastKnownPlayerY, 'monster.lastKnownPlayerY'),
|
||||
visionRange: finiteNumber(Number(monster.visionRange), 'monster.visionRange', { min: 1, max: 1000 })
|
||||
},
|
||||
moveCount: integer(Number(game.moveCount || 0), 'move count', { min: 0 }),
|
||||
startedAt: integer(Number(game.startedAt), 'startedAt', { min: 0 }),
|
||||
monsterMoveAccumulator: finiteNumber(Number(game.monsterMoveAccumulator || 0),
|
||||
'monster move accumulator', { min: 0, max: 1000 }),
|
||||
rngCounter: integer(Number(game.seededRNG.getCounter()), 'seeded RNG counter', {
|
||||
min: 0,
|
||||
max: MAX_RNG_COUNTER
|
||||
}),
|
||||
blockRec: nullableInteger(game.blockRec ?? game.blockHeight ?? null, 'entry block', { min: 0 }),
|
||||
startBlock: nullableInteger(game.startBlock ?? null, 'start block', { min: 0 }),
|
||||
fee: finiteNumber(Number(game.fee || 0), 'fee', { min: 0 })
|
||||
};
|
||||
|
||||
invariant(state.depth <= state.maxDepth, 'depth exceeds maxDepth');
|
||||
|
||||
return {
|
||||
gameId: dbId,
|
||||
userId,
|
||||
dungeonSeed: gameId,
|
||||
snapshotVersion: SNAPSHOT_VERSION,
|
||||
originalSocketId,
|
||||
paymentMonitoringActive: paymentMonitoringActive === true,
|
||||
state
|
||||
};
|
||||
}
|
||||
|
||||
function restoreSoloRestartSnapshot(row, { db } = {}) {
|
||||
invariant(row && typeof row === 'object', 'database row is required');
|
||||
invariant(db && typeof db.query === 'function', 'database handle is required');
|
||||
invariant(row.status === 'active' && row.completed_at == null,
|
||||
'database game must still be active and nonterminal');
|
||||
|
||||
const state = jsonValue(row.state, 'state');
|
||||
invariant(state && typeof state === 'object' && !Array.isArray(state), 'state must be an object');
|
||||
invariant(Number(row.snapshot_version) === SNAPSHOT_VERSION && state.version === SNAPSHOT_VERSION,
|
||||
'unsupported snapshot version');
|
||||
|
||||
const dbId = integer(Number(row.game_id), 'game id', { min: 1 });
|
||||
const userId = integer(Number(row.user_id), 'user id', { min: 1 });
|
||||
const dungeonSeed = nonemptyString(String(row.dungeon_seed || ''), 'dungeon seed', 50);
|
||||
const originalSocketId = nonemptyString(String(row.original_socket_id || ''), 'original socket id');
|
||||
invariant(Number(row.joined_game_id ?? row.game_id) === dbId
|
||||
&& Number(row.game_user_id ?? row.user_id) === userId
|
||||
&& String(row.game_dungeon_seed ?? row.dungeon_seed) === dungeonSeed,
|
||||
'snapshot identity does not exactly join its game row');
|
||||
invariant(state.dbId === dbId && state.userId === userId && state.gameId === dungeonSeed,
|
||||
'snapshot identity does not match its database anchors');
|
||||
const snapshotCreatedAt = row.created_at instanceof Date
|
||||
? row.created_at.getTime()
|
||||
: Date.parse(row.created_at);
|
||||
invariant(Number.isFinite(snapshotCreatedAt), 'snapshot created_at is invalid');
|
||||
const now = Date.now();
|
||||
invariant(snapshotCreatedAt <= now + 60000, 'snapshot created_at is in the future');
|
||||
|
||||
const serverSeed = normalizedHex(row.server_seed, 'server seed');
|
||||
const commitment = normalizedHex(row.proof_commitment, 'proof commitment');
|
||||
const effectiveSeed = normalizedHex(row.effective_seed, 'effective seed');
|
||||
const clientSeed = String(row.client_seed ?? '').trim().toLowerCase();
|
||||
invariant(clientSeed === '' || /^[0-9a-f]{1,64}$/.test(clientSeed), 'client seed is invalid');
|
||||
let offerIssuedAt = null;
|
||||
if (row.fairness_offer_issued_at != null) {
|
||||
offerIssuedAt = row.fairness_offer_issued_at instanceof Date
|
||||
? row.fairness_offer_issued_at.getTime()
|
||||
: Number(row.fairness_offer_issued_at);
|
||||
if (!Number.isFinite(offerIssuedAt)) offerIssuedAt = Date.parse(row.fairness_offer_issued_at);
|
||||
invariant(Number.isSafeInteger(offerIssuedAt) && offerIssuedAt >= 0,
|
||||
'fairness offer issued timestamp is invalid');
|
||||
}
|
||||
|
||||
const gameConfig = jsonValue(state.gameConfig, 'game config');
|
||||
const difficultyConfig = jsonValue(state.difficultyConfig, 'difficulty config');
|
||||
invariant(gameConfig && typeof gameConfig === 'object' && !Array.isArray(gameConfig),
|
||||
'game config must be an object');
|
||||
invariant(difficultyConfig && typeof difficultyConfig === 'object' && !Array.isArray(difficultyConfig),
|
||||
'difficulty config must be an object');
|
||||
const persistedGeneratorVersion = String(row.generator_version || '').trim();
|
||||
invariant(DungeonGenerator.SUPPORTED_GENERATOR_VERSIONS.includes(persistedGeneratorVersion),
|
||||
'dungeon generator version is unsupported');
|
||||
const persistedContext = jsonValue(row.proof_context, 'proof context');
|
||||
const persistedFingerprints = jsonValue(row.layout_fingerprints, 'layout fingerprints');
|
||||
invariant(persistedContext?.generatorVersion === persistedGeneratorVersion,
|
||||
'proof context generator version does not match the game row');
|
||||
|
||||
const placeholderUser = {
|
||||
id: originalSocketId,
|
||||
joinGame() {},
|
||||
endGame() {}
|
||||
};
|
||||
const game = Game.createRestoredStandardGame(originalSocketId, placeholderUser, {
|
||||
...gameConfig,
|
||||
fairnessProof: {
|
||||
proofVersion: Number(row.proof_version) || 1,
|
||||
offerId: row.fairness_offer_id || null,
|
||||
offerIssuedAt,
|
||||
serverSeed,
|
||||
clientSeed,
|
||||
commitment
|
||||
}
|
||||
}, persistedGeneratorVersion);
|
||||
|
||||
invariant(game.gameProof.seed === effectiveSeed, 'effective seed does not match the proof inputs');
|
||||
invariant(game.gameProof.commitment === commitment, 'commitment does not match the server seed');
|
||||
invariant(game.maxDepth === state.maxDepth, 'runtime maxDepth has drifted');
|
||||
invariant(isDeepStrictEqual(jsonClone(game.gameConfig, 'restored game config'), gameConfig),
|
||||
'runtime game configuration has drifted');
|
||||
invariant(isDeepStrictEqual(jsonClone(game.difficultyConfig, 'restored difficulty config'), difficultyConfig),
|
||||
'runtime difficulty configuration has drifted');
|
||||
|
||||
invariant(isDeepStrictEqual(game.gameProof.context, persistedContext), 'proof context has drifted');
|
||||
invariant(isDeepStrictEqual(game.gameProof.layoutFingerprints, persistedFingerprints),
|
||||
'layout fingerprint manifest has drifted');
|
||||
invariant(game.gameProof.generatorVersion === persistedGeneratorVersion,
|
||||
'dungeon generator version has drifted');
|
||||
invariant(game.gameProof.layoutFingerprint === String(row.layout_fingerprint || '').trim(),
|
||||
'level-one layout fingerprint has drifted');
|
||||
|
||||
const depth = integer(Number(state.depth), 'depth', { min: 1, max: state.maxDepth });
|
||||
if (depth !== 1) game._generateLevel(depth);
|
||||
game.depth = depth;
|
||||
|
||||
const width = integer(Number(game.width), 'game width', { min: 1 });
|
||||
const height = integer(Number(game.height), 'game height', { min: 1 });
|
||||
const player = state.player || {};
|
||||
const monster = state.monster || {};
|
||||
game.player.setState({
|
||||
x: integer(Number(player.x), 'player.x', { min: 0, max: width - 1 }),
|
||||
y: integer(Number(player.y), 'player.y', { min: 0, max: height - 1 }),
|
||||
hasKey: player.hasKey === true,
|
||||
hasTreasure: player.hasTreasure === true
|
||||
});
|
||||
game.monster.moveTo(
|
||||
integer(Number(monster.x), 'monster.x', { min: 0, max: width - 1 }),
|
||||
integer(Number(monster.y), 'monster.y', { min: 0, max: height - 1 })
|
||||
);
|
||||
game.monster.lastKnownPlayerX = nullableInteger(monster.lastKnownPlayerX,
|
||||
'monster.lastKnownPlayerX', { min: 0, max: width - 1 });
|
||||
game.monster.lastKnownPlayerY = nullableInteger(monster.lastKnownPlayerY,
|
||||
'monster.lastKnownPlayerY', { min: 0, max: height - 1 });
|
||||
game.monster.visionRange = finiteNumber(Number(monster.visionRange),
|
||||
'monster.visionRange', { min: 1, max: 1000 });
|
||||
|
||||
const primaryFloor = game.gameConfig.primaryFloor || "'1";
|
||||
const secondaryFloor = game.gameConfig.secondaryFloor || "'2";
|
||||
const passable = (x, y) => {
|
||||
const tile = game.dungeon?.map?.[y]?.[x];
|
||||
return tile === primaryFloor || tile === secondaryFloor
|
||||
|| tile === 0 || tile === '>' || tile === '$M';
|
||||
};
|
||||
invariant(passable(game.player.x, game.player.y), 'player is not on a passable tile');
|
||||
invariant(passable(game.monster.x, game.monster.y), 'monster is not on a passable tile');
|
||||
invariant(game.player.x !== game.monster.x || game.player.y !== game.monster.y,
|
||||
'active player and monster overlap');
|
||||
const onExit = Array.isArray(game.dungeon?.exit)
|
||||
&& game.player.x === game.dungeon.exit[0]
|
||||
&& game.player.y === game.dungeon.exit[1];
|
||||
invariant(!onExit, 'active player is already on the exit');
|
||||
|
||||
const generatedTreasure = Array.isArray(game.dungeon?.treasure)
|
||||
? [...game.dungeon.treasure]
|
||||
: null;
|
||||
if (game.player.hasTreasure) {
|
||||
invariant(depth === state.maxDepth, 'treasure cannot be held before the final depth');
|
||||
invariant(generatedTreasure !== null, 'final-depth treasure cannot be reconstructed');
|
||||
// Picking treasure removes the live dungeon marker immediately. The player may still
|
||||
// stand on the former pickup coordinate; it is no longer an active treasure tile.
|
||||
game.dungeon.treasure = null;
|
||||
} else if (depth === state.maxDepth) {
|
||||
invariant(generatedTreasure !== null, 'uncollected final-depth treasure is missing');
|
||||
const onUncollectedTreasure = game.player.x === generatedTreasure[0]
|
||||
&& game.player.y === generatedTreasure[1];
|
||||
invariant(!onUncollectedTreasure, 'active player is already on uncollected treasure');
|
||||
} else {
|
||||
invariant(generatedTreasure === null, 'treasure exists before the final depth');
|
||||
}
|
||||
|
||||
game.moveCount = integer(Number(state.moveCount), 'move count', { min: 0 });
|
||||
game.startedAt = integer(Number(state.startedAt), 'startedAt', { min: 0 });
|
||||
invariant(game.startedAt <= snapshotCreatedAt + 60000 && game.startedAt <= now + 60000,
|
||||
'startedAt is later than the durable snapshot');
|
||||
game.monsterMoveAccumulator = finiteNumber(Number(state.monsterMoveAccumulator),
|
||||
'monster move accumulator', { min: 0, max: 1000 });
|
||||
const generatedRngCounter = game.seededRNG.getCounter();
|
||||
const restoredRngCounter = integer(Number(state.rngCounter), 'seeded RNG counter', {
|
||||
min: 0,
|
||||
max: MAX_RNG_COUNTER
|
||||
});
|
||||
invariant(restoredRngCounter >= generatedRngCounter,
|
||||
'seeded RNG counter predates deterministic dungeon generation');
|
||||
game.seededRNG.setCounter(restoredRngCounter);
|
||||
game.blockRec = nullableInteger(state.blockRec, 'entry block', { min: 0 });
|
||||
const durableEntryBlock = nullableInteger(
|
||||
row.start_block_height == null ? null : Number(row.start_block_height),
|
||||
'durable entry block',
|
||||
{ min: 0 }
|
||||
);
|
||||
invariant(game.blockRec === durableEntryBlock,
|
||||
'entry block does not match the durable game row');
|
||||
game.blockHeight = game.blockRec;
|
||||
game.startBlock = nullableInteger(state.startBlock, 'start block', { min: 0 });
|
||||
game.fee = finiteNumber(Number(state.fee), 'fee', { min: 0 });
|
||||
game.gameState = 'active';
|
||||
game.updateFOV();
|
||||
|
||||
game.id = dungeonSeed;
|
||||
game.socketId = originalSocketId;
|
||||
game.dbId = dbId;
|
||||
game.userId = userId;
|
||||
game.dbUserId = userId;
|
||||
game.db = db;
|
||||
game.gameMode = String(row.game_mode || 'FREE').toUpperCase();
|
||||
game.gameProof.gameId = dungeonSeed;
|
||||
game.gameProof.timestamp = integer(Number(state.proofTimestamp), 'proof timestamp', { min: 0 });
|
||||
invariant(game.gameProof.timestamp <= snapshotCreatedAt + 60000
|
||||
&& game.gameProof.timestamp >= game.startedAt - 60000,
|
||||
'proof timestamp is outside the game/snapshot lifetime');
|
||||
game.gameProof.proofVersion = Number(row.proof_version) || 1;
|
||||
game.gameProof.offerId = row.fairness_offer_id || null;
|
||||
game.gameProof.offerIssuedAt = offerIssuedAt;
|
||||
game.gameProof.context = persistedContext;
|
||||
game.gameProof.layoutFingerprints = persistedFingerprints;
|
||||
game.gameProof.layoutFingerprint = String(row.layout_fingerprint || '').trim();
|
||||
return {
|
||||
game,
|
||||
userId,
|
||||
originalSocketId,
|
||||
paymentMonitoringActive: row.payment_monitoring_active === true,
|
||||
snapshotVersion: SNAPSHOT_VERSION
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SNAPSHOT_VERSION,
|
||||
captureSoloRestartSnapshot,
|
||||
restoreSoloRestartSnapshot
|
||||
};
|
||||
+1385
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
// Run tests in Node environment
|
||||
testEnvironment: 'node',
|
||||
// Move Jest's logical root to the project root (one level above this config file)
|
||||
rootDir: '..',
|
||||
// Look for tests inside the existing top-level test directory
|
||||
roots: ['<rootDir>/test'],
|
||||
collectCoverage: false,
|
||||
moduleFileExtensions: ['js','json'],
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = function asyncHandler(handler) {
|
||||
return function wrappedHandler(req, res, next) {
|
||||
Promise.resolve(handler(req, res, next)).catch(next);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
const { normalizeError, isTrustedError } = require('../utils/errors');
|
||||
|
||||
module.exports = function createErrorMiddleware({ logger = console } = {}) {
|
||||
return function errorMiddleware(err, req, res, next) { // eslint-disable-line no-unused-vars
|
||||
const normalized = normalizeError(err);
|
||||
const status = normalized.statusCode || 500;
|
||||
const response = {
|
||||
error: normalized.code || 'INTERNAL_ERROR',
|
||||
message: normalized.safeMessage || 'An unexpected error occurred.'
|
||||
};
|
||||
|
||||
if (normalized.details) {
|
||||
response.details = normalized.details;
|
||||
}
|
||||
|
||||
if (logger && typeof logger.error === 'function') {
|
||||
const logPayload = {
|
||||
message: normalized.message,
|
||||
code: normalized.code,
|
||||
status,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
trusted: isTrustedError(normalized)
|
||||
};
|
||||
if (normalized.details) {
|
||||
logPayload.details = normalized.details;
|
||||
}
|
||||
if (normalized.cause) {
|
||||
logPayload.cause = normalized.cause?.message || normalized.cause;
|
||||
}
|
||||
logger.error('Request failed', logPayload);
|
||||
}
|
||||
|
||||
res.status(status).json(response);
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,186 @@
|
||||
-- Wowgue Database Schema
|
||||
-- PostgreSQL migration script
|
||||
|
||||
-- Drop existing tables if they exist (for clean setup)
|
||||
DROP TABLE IF EXISTS payouts CASCADE;
|
||||
DROP TABLE IF EXISTS payments CASCADE;
|
||||
DROP TABLE IF EXISTS games CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
|
||||
-- Create users table
|
||||
-- Tracks user accounts, credits, and statistics
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
socket_id VARCHAR(255),
|
||||
ip_address INET,
|
||||
username VARCHAR(50),
|
||||
payout_address VARCHAR(95), -- Monero/Wownero address for payouts
|
||||
credits INTEGER DEFAULT 0, -- For PAID_CREDITS mode
|
||||
total_games_played INTEGER DEFAULT 0,
|
||||
total_games_won INTEGER DEFAULT 0,
|
||||
total_amount_paid BIGINT DEFAULT 0, -- In atomic units
|
||||
total_amount_won BIGINT DEFAULT 0, -- In atomic units
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index on socket_id for quick lookups
|
||||
CREATE INDEX idx_users_socket_id ON users(socket_id);
|
||||
CREATE INDEX idx_users_ip_address ON users(ip_address);
|
||||
|
||||
-- Create games table
|
||||
-- Tracks individual game sessions and outcomes
|
||||
CREATE TABLE games (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
socket_id VARCHAR(255),
|
||||
game_mode VARCHAR(20) NOT NULL, -- PAID_SINGLE, PAID_CREDITS, FREE
|
||||
payment_id INTEGER, -- Reference to payment (for PAID_SINGLE)
|
||||
start_block_height INTEGER,
|
||||
end_block_height INTEGER,
|
||||
status VARCHAR(20) DEFAULT 'waiting', -- waiting, active, won, lost, expired
|
||||
outcome VARCHAR(20), -- escaped, caught_by_monster, expired, treasure_found
|
||||
treasure_found BOOLEAN DEFAULT FALSE,
|
||||
dungeon_seed VARCHAR(50),
|
||||
moves_made INTEGER DEFAULT 0,
|
||||
duration_seconds INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for games
|
||||
CREATE INDEX idx_games_user_id ON games(user_id);
|
||||
CREATE INDEX idx_games_socket_id ON games(socket_id);
|
||||
CREATE INDEX idx_games_status ON games(status);
|
||||
CREATE INDEX idx_games_start_block ON games(start_block_height);
|
||||
|
||||
-- Create payments table
|
||||
-- Tracks incoming payments from players
|
||||
CREATE TABLE payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
socket_id VARCHAR(255),
|
||||
subaddress VARCHAR(95) NOT NULL, -- Wallet RPC generated subaddress
|
||||
expected_amount BIGINT NOT NULL, -- In atomic units
|
||||
received_amount BIGINT DEFAULT 0, -- In atomic units
|
||||
payment_type VARCHAR(20) NOT NULL, -- single_game, credits_package
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, confirmed, expired
|
||||
description TEXT,
|
||||
tx_hash VARCHAR(64),
|
||||
block_height INTEGER,
|
||||
confirmations INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
confirmed_at TIMESTAMP,
|
||||
expires_at TIMESTAMP DEFAULT (CURRENT_TIMESTAMP + INTERVAL '2 hours')
|
||||
);
|
||||
|
||||
-- Create indexes for payments
|
||||
CREATE INDEX idx_payments_subaddress ON payments(subaddress);
|
||||
CREATE INDEX idx_payments_user_id ON payments(user_id);
|
||||
CREATE INDEX idx_payments_status ON payments(status);
|
||||
CREATE INDEX idx_payments_tx_hash ON payments(tx_hash);
|
||||
|
||||
-- Create payouts table
|
||||
-- Tracks outgoing payments to winners
|
||||
CREATE TABLE payouts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
game_id INTEGER REFERENCES games(id),
|
||||
payout_address VARCHAR(95) NOT NULL,
|
||||
amount BIGINT NOT NULL, -- In atomic units
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, batched, completed, failed
|
||||
batch_id VARCHAR(50), -- Groups payouts that are sent together
|
||||
tx_hash VARCHAR(64),
|
||||
fee BIGINT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for payouts
|
||||
CREATE INDEX idx_payouts_user_id ON payouts(user_id);
|
||||
CREATE INDEX idx_payouts_game_id ON payouts(game_id);
|
||||
CREATE INDEX idx_payouts_status ON payouts(status);
|
||||
CREATE INDEX idx_payouts_batch_id ON payouts(batch_id);
|
||||
|
||||
-- Create triggers to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Create function to get user statistics
|
||||
CREATE OR REPLACE FUNCTION get_user_stats(user_socket_id VARCHAR(255))
|
||||
RETURNS TABLE (
|
||||
total_games INTEGER,
|
||||
games_won INTEGER,
|
||||
win_rate DECIMAL(5,2),
|
||||
total_paid BIGINT,
|
||||
total_won BIGINT,
|
||||
net_profit BIGINT,
|
||||
credits_remaining INTEGER
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
u.total_games_played,
|
||||
u.total_games_won,
|
||||
CASE
|
||||
WHEN u.total_games_played > 0
|
||||
THEN ROUND((u.total_games_won::DECIMAL / u.total_games_played) * 100, 2)
|
||||
ELSE 0.00
|
||||
END as win_rate,
|
||||
u.total_amount_paid,
|
||||
u.total_amount_won,
|
||||
(u.total_amount_won - u.total_amount_paid) as net_profit,
|
||||
u.credits
|
||||
FROM users u
|
||||
WHERE u.socket_id = user_socket_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Insert default admin user for testing
|
||||
INSERT INTO users (socket_id, username, ip_address)
|
||||
VALUES ('admin', 'admin', '127.0.0.1');
|
||||
|
||||
-- Create view for active games
|
||||
CREATE VIEW active_games AS
|
||||
SELECT
|
||||
g.id,
|
||||
g.socket_id,
|
||||
u.username,
|
||||
g.game_mode,
|
||||
g.status,
|
||||
g.start_block_height,
|
||||
g.treasure_found,
|
||||
g.moves_made,
|
||||
g.created_at
|
||||
FROM games g
|
||||
JOIN users u ON g.user_id = u.id
|
||||
WHERE g.status IN ('waiting', 'active')
|
||||
ORDER BY g.created_at DESC;
|
||||
|
||||
-- Create view for pending payouts
|
||||
CREATE VIEW pending_payouts AS
|
||||
SELECT
|
||||
p.id,
|
||||
p.user_id,
|
||||
u.username,
|
||||
p.payout_address,
|
||||
p.amount,
|
||||
p.status,
|
||||
p.created_at,
|
||||
g.outcome as game_outcome
|
||||
FROM payouts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN games g ON p.game_id = g.id
|
||||
WHERE p.status = 'pending'
|
||||
ORDER BY p.created_at ASC;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration 002: Increase address field lengths for Wownero compatibility and add socket_id to payments inserts going forward
|
||||
-- Wownero (WOW) standard & subaddresses are typically 97 chars (vs Monero 95); allow buffer.
|
||||
|
||||
ALTER TABLE payments
|
||||
ALTER COLUMN subaddress TYPE VARCHAR(110);
|
||||
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN payout_address TYPE VARCHAR(110);
|
||||
|
||||
-- Optional: index remains valid; no changes needed.
|
||||
|
||||
-- End 002
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration 003: Add anonymous session token + last_seen
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS anon_token VARCHAR(64) UNIQUE,
|
||||
ADD COLUMN IF NOT EXISTS last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_anon_token ON users(anon_token);
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Migration 004: Security improvements and performance indexes
|
||||
|
||||
-- Add missing indexes for performance (regular CREATE INDEX, not CONCURRENTLY in migration)
|
||||
CREATE INDEX IF NOT EXISTS idx_games_user_id ON games(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_created_at ON games(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_last_seen ON users(last_seen);
|
||||
|
||||
-- Add foreign key constraints if they don't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_games_user'
|
||||
) THEN
|
||||
ALTER TABLE games ADD CONSTRAINT fk_games_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_payments_user'
|
||||
) THEN
|
||||
ALTER TABLE payments ADD CONSTRAINT fk_payments_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add credit_transactions table for audit trail
|
||||
CREATE TABLE IF NOT EXISTS credit_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
amount INTEGER NOT NULL,
|
||||
reason VARCHAR(100) NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_transactions_created_at ON credit_transactions(created_at);
|
||||
|
||||
-- Add audit_log table for security tracking
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
details JSONB,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
||||
|
||||
-- Add constraints to prevent negative credits (with safety check)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'check_credits_non_negative'
|
||||
) THEN
|
||||
ALTER TABLE users ADD CONSTRAINT check_credits_non_negative CHECK (credits >= 0);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,149 @@
|
||||
-- Migration 005: Unified payment system foundation
|
||||
|
||||
-- Extend payments table to track unified payment metadata
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS payment_mode VARCHAR(20);
|
||||
ALTER TABLE payments
|
||||
ALTER COLUMN payment_mode SET DEFAULT 'direct';
|
||||
UPDATE payments
|
||||
SET payment_mode = 'direct'
|
||||
WHERE payment_mode IS NULL;
|
||||
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS credit_package_id VARCHAR(20);
|
||||
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS credits_purchased INTEGER DEFAULT 0;
|
||||
UPDATE payments
|
||||
SET credits_purchased = 0
|
||||
WHERE credits_purchased IS NULL;
|
||||
|
||||
-- Extend user profile with payment preferences and stats
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS preferred_payment_mode VARCHAR(20) DEFAULT 'direct';
|
||||
UPDATE users
|
||||
SET preferred_payment_mode = 'direct'
|
||||
WHERE preferred_payment_mode IS NULL;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS total_credits_purchased INTEGER DEFAULT 0;
|
||||
UPDATE users
|
||||
SET total_credits_purchased = 0
|
||||
WHERE total_credits_purchased IS NULL;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS total_payouts_received BIGINT DEFAULT 0;
|
||||
UPDATE users
|
||||
SET total_payouts_received = 0
|
||||
WHERE total_payouts_received IS NULL;
|
||||
|
||||
-- Ensure total_games_played always has a sensible default
|
||||
ALTER TABLE users
|
||||
ALTER COLUMN total_games_played SET DEFAULT 0;
|
||||
UPDATE users
|
||||
SET total_games_played = 0
|
||||
WHERE total_games_played IS NULL;
|
||||
|
||||
-- Ensure credit_transactions table exists for installs that missed migration 004
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'credit_transactions'
|
||||
) THEN
|
||||
CREATE TABLE credit_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
amount INTEGER NOT NULL,
|
||||
reason VARCHAR(100) NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
transaction_type VARCHAR(20) DEFAULT 'legacy',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
metadata JSONB
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Tag credit transactions with a transaction type
|
||||
ALTER TABLE credit_transactions
|
||||
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(20);
|
||||
UPDATE credit_transactions
|
||||
SET transaction_type = COALESCE(transaction_type, 'legacy');
|
||||
ALTER TABLE credit_transactions
|
||||
ALTER COLUMN transaction_type SET DEFAULT 'legacy';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_transactions_user_id ON credit_transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_transactions_created_at ON credit_transactions(created_at);
|
||||
|
||||
-- Create payouts table if it was dropped in the past (fresh installs)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'payouts'
|
||||
) THEN
|
||||
CREATE TABLE payouts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
|
||||
payout_address VARCHAR(110) NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
multiplier DECIMAL(3,1) DEFAULT 0,
|
||||
reason VARCHAR(50) DEFAULT 'legacy' NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
batch_id VARCHAR(50),
|
||||
tx_hash VARCHAR(64),
|
||||
fee BIGINT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
processed_at TIMESTAMP,
|
||||
error_message TEXT
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add new payout tracking columns for existing installs
|
||||
ALTER TABLE payouts
|
||||
ADD COLUMN IF NOT EXISTS multiplier DECIMAL(3,1) DEFAULT 0;
|
||||
UPDATE payouts
|
||||
SET multiplier = 0
|
||||
WHERE multiplier IS NULL;
|
||||
ALTER TABLE payouts
|
||||
ALTER COLUMN multiplier SET NOT NULL;
|
||||
|
||||
ALTER TABLE payouts
|
||||
ADD COLUMN IF NOT EXISTS reason VARCHAR(50) DEFAULT 'legacy';
|
||||
UPDATE payouts
|
||||
SET reason = 'legacy'
|
||||
WHERE reason IS NULL;
|
||||
ALTER TABLE payouts
|
||||
ALTER COLUMN reason SET NOT NULL;
|
||||
|
||||
ALTER TABLE payouts
|
||||
ADD COLUMN IF NOT EXISTS error_message TEXT;
|
||||
|
||||
-- Align payout address length with Wownero expectations
|
||||
DROP VIEW IF EXISTS pending_payouts;
|
||||
|
||||
ALTER TABLE payouts
|
||||
ALTER COLUMN payout_address TYPE VARCHAR(110);
|
||||
|
||||
CREATE OR REPLACE VIEW pending_payouts AS
|
||||
SELECT
|
||||
p.id,
|
||||
p.user_id,
|
||||
u.username,
|
||||
p.payout_address,
|
||||
p.amount,
|
||||
p.status,
|
||||
p.created_at,
|
||||
g.outcome AS game_outcome
|
||||
FROM payouts p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN games g ON p.game_id = g.id
|
||||
WHERE p.status = 'pending'
|
||||
ORDER BY p.created_at ASC;
|
||||
|
||||
-- Add helpful indexes for new queries
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_status ON payouts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_user_id ON payouts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_payment_mode ON payments(payment_mode);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Chat History Migration
|
||||
-- Persistent storage for chat messages
|
||||
|
||||
-- Create chat_messages table
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
socket_id VARCHAR(255),
|
||||
username VARCHAR(50),
|
||||
message TEXT NOT NULL,
|
||||
message_type VARCHAR(20) DEFAULT 'chat', -- 'chat', 'system', 'event'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for efficient retrieval of recent messages
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at DESC);
|
||||
|
||||
-- Optional: Index for user-specific history lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_socket_id ON chat_messages(socket_id);
|
||||
|
||||
-- Comment on table
|
||||
COMMENT ON TABLE chat_messages IS 'Stores chat message history for display to new users';
|
||||
COMMENT ON COLUMN chat_messages.message_type IS 'Type of message: chat (user), system (server), event (game events)';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration 007: Add retry tracking for failed payouts
|
||||
-- Allows payouts to be retried up to PAYOUT_MAX_RETRIES times before permanent failure
|
||||
|
||||
-- Add retry_count column to track attempts
|
||||
ALTER TABLE payouts ADD COLUMN IF NOT EXISTS retry_count INTEGER DEFAULT 0;
|
||||
|
||||
-- Add last_error column to store the most recent failure reason
|
||||
ALTER TABLE payouts ADD COLUMN IF NOT EXISTS last_error TEXT;
|
||||
|
||||
-- Add last_retry_at to track when the last retry happened
|
||||
ALTER TABLE payouts ADD COLUMN IF NOT EXISTS last_retry_at TIMESTAMP;
|
||||
|
||||
-- Create index for finding payouts that need retry
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_retry ON payouts(status, retry_count) WHERE status = 'pending';
|
||||
|
||||
-- Update any existing failed payouts to have retry_count = max (so they stay failed)
|
||||
-- This prevents old failures from being retried unexpectedly
|
||||
UPDATE payouts SET retry_count = 999 WHERE status = 'failed' AND retry_count = 0;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration 008: Add UNIQUE constraints on tx_hash for idempotency
|
||||
-- Prevents duplicate transaction processing which could lead to double-crediting or double-payouts
|
||||
|
||||
-- Unique constraint on payments.tx_hash (partial index - only non-null values)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_tx_hash_unique
|
||||
ON payments(tx_hash)
|
||||
WHERE tx_hash IS NOT NULL;
|
||||
|
||||
-- Unique constraint on payouts.tx_hash (partial index - only non-null values)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_tx_hash_unique
|
||||
ON payouts(tx_hash)
|
||||
WHERE tx_hash IS NOT NULL;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Migration 009: Smirk wallet integration
|
||||
-- Adds support for "Login with Smirk" wallet authentication
|
||||
|
||||
-- Add Smirk public key to users table
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS smirk_public_key VARCHAR(128);
|
||||
|
||||
-- Unique constraint on Smirk public key (one wallet per user account)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_smirk_public_key
|
||||
ON users(smirk_public_key)
|
||||
WHERE smirk_public_key IS NOT NULL;
|
||||
|
||||
-- Challenge storage for authentication flow
|
||||
-- Challenges expire after 5 minutes and are single-use
|
||||
CREATE TABLE IF NOT EXISTS smirk_challenges (
|
||||
id SERIAL PRIMARY KEY,
|
||||
challenge VARCHAR(64) NOT NULL UNIQUE,
|
||||
socket_id VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP DEFAULT (NOW() + INTERVAL '5 minutes'),
|
||||
used BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
-- Index for looking up challenges by socket
|
||||
CREATE INDEX IF NOT EXISTS idx_smirk_challenges_socket
|
||||
ON smirk_challenges(socket_id);
|
||||
|
||||
-- Index for cleanup of expired challenges
|
||||
CREATE INDEX IF NOT EXISTS idx_smirk_challenges_expires
|
||||
ON smirk_challenges(expires_at);
|
||||
|
||||
-- Cleanup function for expired challenges (run periodically)
|
||||
-- DELETE FROM smirk_challenges WHERE expires_at < NOW() OR used = TRUE;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Migration 010: Security constraints to prevent invalid data states
|
||||
-- These constraints enforce business rules at the database level as a safety net.
|
||||
--
|
||||
-- Each ADD CONSTRAINT is guarded by a conname check so the migration is idempotent and
|
||||
-- safe to re-run / recover from a partial apply. (Postgres has no ADD CONSTRAINT IF NOT
|
||||
-- EXISTS, and a bare ADD CONSTRAINT on an existing constraint hard-fails, which previously
|
||||
-- could wedge startup.)
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_credits_non_negative') THEN
|
||||
ALTER TABLE users ADD CONSTRAINT users_credits_non_negative CHECK (credits >= 0);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_total_won_non_negative') THEN
|
||||
ALTER TABLE users ADD CONSTRAINT users_total_won_non_negative CHECK (total_amount_won >= 0);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_games_played_non_negative') THEN
|
||||
ALTER TABLE users ADD CONSTRAINT users_games_played_non_negative CHECK (total_games_played >= 0);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_games_won_non_negative') THEN
|
||||
ALTER TABLE users ADD CONSTRAINT users_games_won_non_negative CHECK (total_games_won >= 0);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payouts_amount_positive') THEN
|
||||
ALTER TABLE payouts ADD CONSTRAINT payouts_amount_positive CHECK (amount > 0);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_amount_positive') THEN
|
||||
ALTER TABLE payments ADD CONSTRAINT payments_amount_positive CHECK (expected_amount > 0);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payouts_multiplier_non_negative') THEN
|
||||
ALTER TABLE payouts ADD CONSTRAINT payouts_multiplier_non_negative CHECK (multiplier >= 0);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add index for faster payout retry queries
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_retry_candidates ON payouts (status, retry_count, last_retry_at)
|
||||
WHERE status IN ('pending', 'failed') AND (retry_count IS NULL OR retry_count < 3);
|
||||
|
||||
-- Add index for finding unprocessed payments during recovery
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_recovery_candidates ON payments (user_id, status, payment_type, credits_purchased)
|
||||
WHERE status = 'confirmed' AND payment_type = 'credits_package' AND (credits_purchased IS NULL OR credits_purchased = 0);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Chat Moderation Migration
|
||||
-- Adds moderation capabilities to chat system
|
||||
|
||||
-- Add moderation fields to chat_messages
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id);
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS player_id VARCHAR(10);
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_by VARCHAR(255);
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS delete_reason VARCHAR(255);
|
||||
|
||||
-- Index for non-deleted messages
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_active
|
||||
ON chat_messages(created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Add chat ban fields to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned_at TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned_reason VARCHAR(255);
|
||||
|
||||
-- Seed initial messages (only insert if table is empty or no system messages exist)
|
||||
-- These give context and make the chat feel alive on fresh deploy
|
||||
INSERT INTO chat_messages (socket_id, username, player_id, message, message_type, created_at)
|
||||
SELECT 'system', 'SERVER', 'SERVER', msg, 'system', ts
|
||||
FROM (VALUES
|
||||
('Welcome to Wownerogue! Use arrow keys or WASD to move.', NOW() - INTERVAL '1 hour'),
|
||||
('Escape the dungeon to win. Collect treasure for bonus payout!', NOW() - INTERVAL '59 minutes'),
|
||||
('Watch out for monsters - they''re fast!', NOW() - INTERVAL '58 minutes'),
|
||||
('Type /help for commands.', NOW() - INTERVAL '57 minutes'),
|
||||
('Good luck and have fun!', NOW() - INTERVAL '56 minutes')
|
||||
) AS seed_data(msg, ts)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM chat_messages WHERE socket_id = 'system' LIMIT 1
|
||||
);
|
||||
|
||||
-- Comment on new columns
|
||||
COMMENT ON COLUMN chat_messages.user_id IS 'Reference to users table for registered users';
|
||||
COMMENT ON COLUMN chat_messages.player_id IS 'Display ID like PiMjKP shown in chat';
|
||||
COMMENT ON COLUMN chat_messages.deleted_at IS 'Soft delete timestamp for moderation';
|
||||
COMMENT ON COLUMN chat_messages.deleted_by IS 'Admin who deleted the message';
|
||||
COMMENT ON COLUMN chat_messages.delete_reason IS 'Reason for deletion';
|
||||
COMMENT ON COLUMN users.chat_banned IS 'Whether user is banned from chat';
|
||||
COMMENT ON COLUMN users.chat_banned_at IS 'When the chat ban was applied';
|
||||
COMMENT ON COLUMN users.chat_banned_reason IS 'Reason for chat ban';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration 012: Payout safety constraints
|
||||
-- Prevents double payouts and locks payout address at game start
|
||||
|
||||
-- Only one active (pending/completed) payout per game
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_one_per_game
|
||||
ON payouts(game_id)
|
||||
WHERE game_id IS NOT NULL AND status IN ('pending', 'completed');
|
||||
|
||||
-- Store the payout address at game start so it can't be changed mid-game
|
||||
ALTER TABLE games
|
||||
ADD COLUMN IF NOT EXISTS payout_address VARCHAR(200);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add address_index column to payments table
|
||||
-- Stores the wallet subaddress index so payment monitoring can be fully
|
||||
-- restored from DB after server restart (needed for get_transfers RPC call)
|
||||
|
||||
ALTER TABLE payments ADD COLUMN IF NOT EXISTS address_index INTEGER;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration 014: Score persistence and leaderboard support
|
||||
-- Adds score column to games, high_score and display_name to users
|
||||
|
||||
-- Add score to games table (persists calculated score at game end)
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS score INTEGER DEFAULT 0;
|
||||
|
||||
-- Add high_score to users for quick leaderboard lookups
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS high_score INTEGER DEFAULT 0;
|
||||
|
||||
-- Add display_name for leaderboard identity (set via /nick command)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name VARCHAR(50);
|
||||
|
||||
-- Indexes for leaderboard queries
|
||||
CREATE INDEX IF NOT EXISTS idx_games_score ON games(score DESC) WHERE status = 'won';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_high_score ON users(high_score DESC) WHERE high_score > 0;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration 015: Allow batched payouts to share a tx_hash; tighten one-per-game guard
|
||||
--
|
||||
-- `transfer_split` sends ONE on-chain transaction to many destinations, so multiple
|
||||
-- payout rows in a batch legitimately share the same tx_hash. The old UNIQUE index on
|
||||
-- payouts.tx_hash (migration 008) made the 2nd+ row in a batch fail to record its
|
||||
-- tx_hash, leaving funds already sent but rows stranded in 'processing', and at risk
|
||||
-- of being re-sent by the retry service (a double-payout vector).
|
||||
--
|
||||
-- Payout idempotency does NOT depend on tx_hash uniqueness; it is enforced by
|
||||
-- idx_payouts_one_per_game (one active payout per game). So we drop the payouts
|
||||
-- tx_hash unique index. (The payments.tx_hash unique index stays: incoming payments
|
||||
-- are 1:1 with a transaction.)
|
||||
DROP INDEX IF EXISTS idx_payouts_tx_hash_unique;
|
||||
|
||||
-- Extend the one-active-payout-per-game guard to also cover 'processing', so a second
|
||||
-- payable row cannot be inserted for a game while its payout is mid-flight.
|
||||
DROP INDEX IF EXISTS idx_payouts_one_per_game;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_one_per_game
|
||||
ON payouts(game_id)
|
||||
WHERE game_id IS NOT NULL AND status IN ('pending', 'processing', 'completed');
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Migration 016: Snapshot payout terms on the game at start
|
||||
--
|
||||
-- Payout amounts were computed at completion time from LIVE config, so an admin changing
|
||||
-- multipliers mid-game would change the payout of an already-in-progress game ("what you
|
||||
-- saw at entry" was not honoured). Snapshot the resolved payout amounts (and the
|
||||
-- multipliers, for audit) onto the games row at start; completion reads the snapshot.
|
||||
--
|
||||
-- Amounts are atomic units (BIGINT, exact). Multipliers are stored for the audit trail.
|
||||
ALTER TABLE games
|
||||
ADD COLUMN IF NOT EXISTS payout_escape_amount BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS payout_treasure_amount BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS payout_escape_mult NUMERIC(8,3),
|
||||
ADD COLUMN IF NOT EXISTS payout_treasure_mult NUMERIC(8,3);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Migration 017: CHECK constraints on money-table status/type columns
|
||||
--
|
||||
-- payments.status, payouts.status and payments.payment_type were free-form VARCHARs.
|
||||
-- A typo in any one query (e.g. 'complete' vs 'completed') silently produces a row that
|
||||
-- no index/predicate matches, orphaning money (a payout stuck in a misspelled status is
|
||||
-- invisible to both the batcher and the retry candidate query). These CHECKs catch typos.
|
||||
--
|
||||
-- Added NOT VALID so they enforce all NEW writes without failing on any pre-existing rows,
|
||||
-- and guarded by conname checks so the migration is idempotent / safe to re-run.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_status_check') THEN
|
||||
ALTER TABLE payments
|
||||
ADD CONSTRAINT payments_status_check
|
||||
CHECK (status IN ('pending', 'confirmed', 'expired', 'refunded')) NOT VALID;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payments_payment_type_check') THEN
|
||||
ALTER TABLE payments
|
||||
ADD CONSTRAINT payments_payment_type_check
|
||||
CHECK (payment_type IN ('single_game', 'credits_package')) NOT VALID;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'payouts_status_check') THEN
|
||||
ALTER TABLE payouts
|
||||
ADD CONSTRAINT payouts_status_check
|
||||
CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'needs_review', 'permanently_failed', 'batched')) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration 018: Widen credit columns to BIGINT
|
||||
--
|
||||
-- users.credits and credit_transactions.amount/balance_after were INTEGER (max ~2.1e9),
|
||||
-- inconsistent with the BIGINT atomic-money columns and a latent overflow risk since
|
||||
-- CREDITS_PACKAGES is admin-configurable with no upper bound. ALTER ... TYPE BIGINT is a
|
||||
-- no-op when the column is already BIGINT, so this is safe to re-run.
|
||||
ALTER TABLE users ALTER COLUMN credits TYPE BIGINT;
|
||||
|
||||
ALTER TABLE credit_transactions ALTER COLUMN amount TYPE BIGINT;
|
||||
ALTER TABLE credit_transactions ALTER COLUMN balance_after TYPE BIGINT;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration 019: Index for the per-game leaderboard split (Pleb vs Hall of Champions)
|
||||
--
|
||||
-- The leaderboard is split by game_mode: free games -> Pleb board, paid (credits/entry)
|
||||
-- games -> Hall of Champions. This composite index supports both filtered queries
|
||||
-- (score-ranked, restricted to ranked games).
|
||||
CREATE INDEX IF NOT EXISTS idx_games_leaderboard
|
||||
ON games (game_mode, score DESC)
|
||||
WHERE status IN ('won', 'lost') AND score > 0;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Migration 020: Canonical cosmetic identity and pack entitlements.
|
||||
--
|
||||
-- `users.appearance` stores the normalized public cosmetic identity used by tavern,
|
||||
-- future PvP rooms, and eventually single-player rendering. `user_pack_entitlements`
|
||||
-- is the operator/admin override table for individual premium pack unlocks. The
|
||||
-- current default premium policy still comes from users.total_credits_purchased > 0.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS appearance JSONB NOT NULL DEFAULT
|
||||
'{"avatar":"default","tint":"none","equipment":{"body":"none","head":"none","shield":"none","weapon":"none"}}'::jsonb;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS premium_level VARCHAR(32) NOT NULL DEFAULT 'free';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_pack_entitlements (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
pack_id VARCHAR(80) NOT NULL,
|
||||
source VARCHAR(40) NOT NULL DEFAULT 'operator',
|
||||
granted_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (user_id, pack_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pack_entitlements_user_id
|
||||
ON user_pack_entitlements(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_pack_entitlements_active
|
||||
ON user_pack_entitlements(user_id, pack_id)
|
||||
WHERE expires_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN users.appearance IS 'Normalized cosmetic identity: avatar, tint, equipment.';
|
||||
COMMENT ON COLUMN users.premium_level IS 'Operator-defined premium tier; free by default. Credit purchases also unlock preview premium packs.';
|
||||
COMMENT ON TABLE user_pack_entitlements IS 'Per-user premium cosmetic/render pack grants for future individual pack purchases or operator unlocks.';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration 021: Durable product grants on payments.
|
||||
--
|
||||
-- A payment product may grant credits, cosmetic/render packs, and/or a premium level.
|
||||
-- Persist the product id and normalized grants on the payment row so confirmation/recovery
|
||||
-- does not depend on in-memory socket state.
|
||||
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS product_id VARCHAR(100);
|
||||
|
||||
ALTER TABLE payments
|
||||
ADD COLUMN IF NOT EXISTS product_grants JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_product_id
|
||||
ON payments(product_id);
|
||||
|
||||
-- Allow standalone cosmetic purchases without granting a game entry or credits.
|
||||
ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_payment_type_check;
|
||||
ALTER TABLE payments
|
||||
ADD CONSTRAINT payments_payment_type_check
|
||||
CHECK (payment_type IN ('single_game', 'credits_package', 'cosmetic_pack')) NOT VALID;
|
||||
|
||||
COMMENT ON COLUMN payments.product_id IS 'Operator product id used to create this payment request.';
|
||||
COMMENT ON COLUMN payments.product_grants IS 'Normalized grant payload applied when this payment confirms.';
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Migration 022: Match (race) mode foundation
|
||||
--
|
||||
-- Adds normalized tables for multiplayer races. Keeps existing games/payments/payouts
|
||||
-- tables untouched except for one nullable foreign-key column on payouts.
|
||||
--
|
||||
-- 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.
|
||||
-- - 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.
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. Matches: one row per race
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'starting', 'active', 'finished', 'cancelled')),
|
||||
economy VARCHAR(20) NOT NULL
|
||||
CHECK (economy IN ('free', 'credits_prestige', 'crypto_race')),
|
||||
variant VARCHAR(20) NOT NULL DEFAULT 'race'
|
||||
CHECK (variant IN ('race', 'pvp')),
|
||||
difficulty_preset VARCHAR(20) NOT NULL,
|
||||
max_players INT NOT NULL DEFAULT 4
|
||||
CHECK (max_players >= 2 AND max_players <= 32),
|
||||
seed_hash VARCHAR(64) NOT NULL,
|
||||
seed VARCHAR(255), -- revealed on finish for verification
|
||||
dungeon JSONB NOT NULL, -- deterministic dungeon snapshot
|
||||
start_block_height BIGINT, -- block that triggered the race start
|
||||
end_block_height BIGINT, -- block that ended the race (if applicable)
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
-- Economy fields (all atomic currency units, BIGINT)
|
||||
entry_fee_atomic BIGINT DEFAULT 0
|
||||
CHECK (entry_fee_atomic >= 0),
|
||||
pot_atomic BIGINT DEFAULT 0
|
||||
CHECK (pot_atomic >= 0),
|
||||
house_fee_atomic BIGINT DEFAULT 0
|
||||
CHECK (house_fee_atomic >= 0),
|
||||
house_fee_percent DECIMAL(5, 2) DEFAULT 0
|
||||
CHECK (house_fee_percent >= 0 AND house_fee_percent <= 100),
|
||||
winner_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_economy_finished ON matches(economy, ended_at)
|
||||
WHERE status = 'finished';
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_start_block ON matches(start_block_height)
|
||||
WHERE start_block_height IS NOT NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. Match entrants: one row per player per match
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS match_entrants (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
socket_id VARCHAR(255) NOT NULL,
|
||||
placement INT,
|
||||
escaped BOOLEAN DEFAULT FALSE,
|
||||
has_treasure BOOLEAN DEFAULT FALSE,
|
||||
killed_by VARCHAR(50), -- 'monster', 'player:<id>', 'timeout', 'afk'
|
||||
score INT DEFAULT 0
|
||||
CHECK (score >= 0),
|
||||
payment_id BIGINT REFERENCES payments(id) ON DELETE SET NULL,
|
||||
entry_consumed BOOLEAN DEFAULT FALSE, -- true once match actually starts
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_match_entrants_user_match
|
||||
ON match_entrants(match_id, user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_match_entrants_match
|
||||
ON match_entrants(match_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_match_entrants_placement
|
||||
ON match_entrants(match_id, placement, score DESC);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. Match events: replay / spectator / audit feed
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS match_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
tick INT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_match_events_match_tick
|
||||
ON match_events(match_id, tick);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. Match queue entries: persisted queue state for restart safety
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS match_queue_entries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
economy VARCHAR(20) NOT NULL
|
||||
CHECK (economy IN ('free', 'credits_prestige', 'crypto_race')),
|
||||
socket_id VARCHAR(255) NOT NULL,
|
||||
session_token VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued', 'matched', 'cancelled')),
|
||||
match_id UUID REFERENCES matches(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
matched_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_match_queue_user_economy_queued
|
||||
ON match_queue_entries(user_id, economy)
|
||||
WHERE status = 'queued';
|
||||
CREATE INDEX IF NOT EXISTS idx_match_queue_economy_created
|
||||
ON match_queue_entries(economy, created_at)
|
||||
WHERE status = 'queued';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. Race entry ticket balance and ledger
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS race_entries INT NOT NULL DEFAULT 0
|
||||
CHECK (race_entries >= 0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS race_entry_transactions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
reason VARCHAR(50) NOT NULL
|
||||
CHECK (reason IN ('purchase', 'refund', 'queue_join', 'queue_leave', 'match_start', 'match_cancel', 'admin_grant')),
|
||||
match_id UUID REFERENCES matches(id) ON DELETE SET NULL,
|
||||
payment_id BIGINT REFERENCES payments(id) ON DELETE SET NULL,
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_race_entry_tx_user
|
||||
ON race_entry_transactions(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_race_entry_tx_match
|
||||
ON race_entry_transactions(match_id)
|
||||
WHERE match_id IS NOT NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. Link payouts to matches (nullable, additive only)
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE payouts
|
||||
ADD COLUMN IF NOT EXISTS match_id UUID REFERENCES matches(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payouts_match
|
||||
ON payouts(match_id)
|
||||
WHERE match_id IS NOT NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 7. Prestige leaderboard view (credit-prestige races)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW prestige_leaderboard AS
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.display_name,
|
||||
MAX(me.score) AS best_score,
|
||||
COUNT(*) FILTER (WHERE me.escaped) AS wins,
|
||||
COUNT(*) AS races,
|
||||
MAX(m.ended_at) AS last_race_at
|
||||
FROM match_entrants me
|
||||
JOIN matches m ON me.match_id = m.id
|
||||
JOIN users u ON me.user_id = u.id
|
||||
WHERE m.economy = 'credits_prestige'
|
||||
AND m.status = 'finished'
|
||||
AND me.score > 0
|
||||
GROUP BY u.id, u.display_name;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 8. Safety constraints
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Only one active (pending/completed) payout per match, preventing double payouts.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_payouts_one_per_match
|
||||
ON payouts(match_id)
|
||||
WHERE match_id IS NOT NULL AND status IN ('pending', 'completed');
|
||||
|
||||
-- Only one finished winner record per match (business rule, not a constraint, because
|
||||
-- ties are resolved deterministically by the engine; this index supports the lookup).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_matches_finished_winner
|
||||
ON matches(id, winner_user_id)
|
||||
WHERE status = 'finished' AND winner_user_id IS NOT NULL;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Migration 023: Payment safety: one game per confirmed single_game payment
|
||||
--
|
||||
-- M3: claiming a confirmed single_game payment must be atomic. The application locks the
|
||||
-- payment row FOR UPDATE while linking the game, but this partial unique index is the
|
||||
-- final backstop: at most one games row may reference a given payment_id. A concurrent
|
||||
-- start that races past the lock hits a 23505 unique_violation (handled gracefully in
|
||||
-- gameModeManager._processGameStartWithPayment as "already consumed"), so a single paid
|
||||
-- entry can never spawn two games.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_games_payment_id
|
||||
ON games(payment_id)
|
||||
WHERE payment_id IS NOT NULL;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Migration 024: operator-owned cosmetic catalog (the 10× foundation stone).
|
||||
--
|
||||
-- Replaces the split-brain, hand-synced pack definitions that live in BOTH
|
||||
-- src/multiplayer/entitlements.js (server PACKS) and
|
||||
-- html/js/render/assetPacks.js (client RK.PACKS)
|
||||
-- with a single server-authoritative table the operator owns. The server snapshot and the
|
||||
-- client renderer both read from this (the client gets it in the entitlement/config payload),
|
||||
-- so an operator can define a new skin/tile pack, its price, tier, and unlock rule without
|
||||
-- editing any JS.
|
||||
--
|
||||
-- UNLOCK MODEL (fixes the current "any purchase unlocks EVERY premium pack" short-circuit in
|
||||
-- entitlements.js snapshotForUser). A user owns a pack if ANY of:
|
||||
-- 1. an explicit row in user_pack_entitlements (a grant or a direct cosmetic purchase), OR
|
||||
-- 2. unlock_min_credits IS NOT NULL AND users.total_credits_purchased >= unlock_min_credits, OR
|
||||
-- 3. the user's tier >= this pack's tier (ordered ladder; 0 = free).
|
||||
-- This makes "buy 1 credit → unlock skin A, buy the 10-pack → unlock pack B" expressible:
|
||||
-- skin A: unlock_min_credits = 1 | pack B: unlock_min_credits = 10 (or grant_only + a
|
||||
-- per-product grant on the 10-pack). Tiers cover blanket "supporter unlocks everything at
|
||||
-- tier ≤ N" if the operator wants it.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cosmetic_catalog (
|
||||
pack_id VARCHAR(64) PRIMARY KEY, -- 'iso-dungeon', 'generated-skins', …
|
||||
label VARCHAR(120) NOT NULL,
|
||||
kind VARCHAR(24) NOT NULL DEFAULT 'render-pack', -- 'render-pack' | 'skin' | 'tile'
|
||||
projection VARCHAR(24), -- 'topdown' | 'iso' | '3d' | NULL (any)
|
||||
tier INTEGER NOT NULL DEFAULT 0, -- 0 = free; ordered ladder (1,2,…)
|
||||
unlock_min_credits BIGINT, -- lifetime-spend threshold; NULL = not credit-unlockable
|
||||
grant_only BOOLEAN NOT NULL DEFAULT FALSE,-- true = only via explicit grant/purchase
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
metadata JSONB NOT NULL DEFAULT '{}', -- asset urls, render-mode binding, etc.
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT cosmetic_catalog_tier_nonneg CHECK (tier >= 0),
|
||||
CONSTRAINT cosmetic_catalog_min_credits_nonneg CHECK (unlock_min_credits IS NULL OR unlock_min_credits >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cosmetic_catalog_active ON cosmetic_catalog(active, sort_order);
|
||||
|
||||
-- Seed the four packs that exist today so nothing regresses on first migration. These mirror the
|
||||
-- current hardcoded definitions (roguelike-interior free; the rest premium, credit-unlockable at
|
||||
-- 1 lifetime credit, matching today's {kind:'credits_purchase', minTotalCreditsPurchased:1}).
|
||||
INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, sort_order) VALUES
|
||||
('roguelike-interior', 'Kenney Roguelike Interior', 'render-pack', 'topdown', 0, NULL, FALSE, 0),
|
||||
('generated-skins', 'Premium Generated Skins', 'render-pack', 'topdown', 1, 1, FALSE, 1),
|
||||
('iso-dungeon', 'Kenney Isometric Dungeon', 'render-pack', 'iso', 1, 1, FALSE, 2),
|
||||
('kenney-3d-characters', 'Kenney Animated 3D Chars', 'render-pack', '3d', 1, 1, FALSE, 3)
|
||||
ON CONFLICT (pack_id) DO NOTHING;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 025: graduate the cosmetic ladder.
|
||||
--
|
||||
-- Migration 024 seeded every premium pack at tier 1 / unlock_min_credits 1, so buying ANY credit
|
||||
-- unlocked skins + iso + 3D at once (flat, not tiered). Turn it into a real ladder: unlock by
|
||||
-- lifetime credit spend OR by subscription tier, whichever comes first:
|
||||
-- Fancy (generated-skins) 1 credit / supporter (tier 1)
|
||||
-- Iso (iso-dungeon) 10 credits / premium (tier 2)
|
||||
-- 3D (kenney-3d-characters) 25 credits / operator (tier 3)
|
||||
-- Idempotent (plain UPDATEs); the free roguelike-interior pack is untouched.
|
||||
|
||||
UPDATE cosmetic_catalog SET tier = 1, unlock_min_credits = 1 WHERE pack_id = 'generated-skins';
|
||||
UPDATE cosmetic_catalog SET tier = 2, unlock_min_credits = 10 WHERE pack_id = 'iso-dungeon';
|
||||
UPDATE cosmetic_catalog SET tier = 3, unlock_min_credits = 25 WHERE pack_id = 'kenney-3d-characters';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 026: add the Medieval Town isometric pack as a catalog product.
|
||||
-- A second iso environment (interchangeable across tavern + dungeon), sold at iso-tier: unlock by
|
||||
-- 15 lifetime credits OR premium (tier 2). Idempotent.
|
||||
INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, sort_order, metadata)
|
||||
VALUES ('iso-medieval', 'Medieval Town (Isometric)', 'render-pack', 'iso', 2, 15, false, true, 40, '{}'::jsonb)
|
||||
ON CONFLICT (pack_id) DO UPDATE
|
||||
SET label = EXCLUDED.label, projection = EXCLUDED.projection, tier = EXCLUDED.tier,
|
||||
unlock_min_credits = EXCLUDED.unlock_min_credits, active = true;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 027: add the Roguelike Dungeon topdown tile pack as a catalog product.
|
||||
-- Same sheet, a dungeon palette (a distinct 2D tileset). Cheap topdown-variety tier: 1 credit /
|
||||
-- supporter (tier 1). Idempotent.
|
||||
INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, sort_order, metadata)
|
||||
VALUES ('roguelike-dungeon', 'Roguelike Dungeon (Tiles)', 'render-pack', 'topdown', 1, 1, false, true, 15, '{}'::jsonb)
|
||||
ON CONFLICT (pack_id) DO UPDATE
|
||||
SET label = EXCLUDED.label, projection = EXCLUDED.projection, tier = EXCLUDED.tier,
|
||||
unlock_min_credits = EXCLUDED.unlock_min_credits, active = true;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 028: lock the cosmetic ladder (loyalty model: unlock by lifetime credits purchased, cumulative).
|
||||
-- Adds the FREE `original` bare-tiles baseline and reprices the ladder: Interior 1, skins 5, iso 10,
|
||||
-- +tilepack 20, +iso 40, 3D 50. Mirrors DEFAULT_CATALOG in src/multiplayer/entitlements.js.
|
||||
-- Idempotent. See docs/MONETIZATION.md.
|
||||
INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, sort_order, metadata) VALUES
|
||||
('original', 'Original Tiles', 'render-pack', 'topdown', 0, NULL, false, true, 0, '{}'::jsonb),
|
||||
('roguelike-interior', 'Kenney Roguelike Interior', 'render-pack', 'topdown', 1, 1, false, true, 10, '{}'::jsonb),
|
||||
('generated-skins', 'Premium Character Skins', 'render-pack', 'topdown', 1, 5, false, true, 15, '{}'::jsonb),
|
||||
('iso-dungeon', 'Kenney Isometric Dungeon', 'render-pack', 'iso', 2, 10, false, true, 20, '{}'::jsonb),
|
||||
('roguelike-dungeon', 'Roguelike Dungeon (Tiles)', 'render-pack', 'topdown', 2, 20, false, true, 25, '{}'::jsonb),
|
||||
('iso-medieval', 'Medieval Town (Isometric)', 'render-pack', 'iso', 3, 40, false, true, 30, '{}'::jsonb),
|
||||
('kenney-3d-characters', 'Kenney Animated 3D Characters', 'render-pack', '3d', 3, 50, false, true, 40, '{}'::jsonb)
|
||||
ON CONFLICT (pack_id) DO UPDATE
|
||||
SET label = EXCLUDED.label, kind = EXCLUDED.kind, projection = EXCLUDED.projection, tier = EXCLUDED.tier,
|
||||
unlock_min_credits = EXCLUDED.unlock_min_credits, grant_only = EXCLUDED.grant_only,
|
||||
active = true, sort_order = EXCLUDED.sort_order;
|
||||
@@ -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();
|
||||
@@ -0,0 +1,181 @@
|
||||
-- Close economic-identity mutation gaps for databases that already applied migrations 032/035.
|
||||
-- Operational status, transaction evidence, retry counters, fees, and timestamps remain mutable;
|
||||
-- owners, promise amounts, linkage, and destinations do not.
|
||||
|
||||
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;
|
||||
|
||||
CREATE OR REPLACE FUNCTION reject_payment_fairness_binding_mutation()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
identity_locked BOOLEAN;
|
||||
BEGIN
|
||||
-- A receipt is authorization evidence even before the final status update. Once evidence
|
||||
-- exists (or the invoice has reached a terminal money state), its economic promise and
|
||||
-- provider/destination identity may no longer be rewritten. Operational settlement fields
|
||||
-- such as status, received_amount, confirmations, and refund timestamps remain mutable.
|
||||
identity_locked := OLD.fairness_bound_at IS NOT NULL
|
||||
OR OLD.status IN ('confirmed', 'refunded', 'expired')
|
||||
OR EXISTS (SELECT 1 FROM payment_receipts r WHERE r.payment_id = OLD.id);
|
||||
|
||||
IF identity_locked 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.provider_id IS DISTINCT FROM OLD.provider_id
|
||||
OR NEW.provider_invoice_id IS DISTINCT FROM OLD.provider_invoice_id
|
||||
OR NEW.payment_mode IS DISTINCT FROM OLD.payment_mode
|
||||
OR NEW.credit_package_id IS DISTINCT FROM OLD.credit_package_id
|
||||
OR NEW.product_id IS DISTINCT FROM OLD.product_id
|
||||
OR NEW.product_grants IS DISTINCT FROM OLD.product_grants
|
||||
OR NEW.description IS DISTINCT FROM OLD.description
|
||||
OR NEW.expires_at IS DISTINCT FROM OLD.expires_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'payment economic identity is immutable for payment %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF OLD.fairness_bound_at IS NOT NULL AND (
|
||||
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
|
||||
OR NEW.provider_id IS DISTINCT FROM OLD.provider_id
|
||||
OR NEW.provider_invoice_id IS DISTINCT FROM OLD.provider_invoice_id
|
||||
OR NEW.payment_mode IS DISTINCT FROM OLD.payment_mode
|
||||
OR NEW.credit_package_id IS DISTINCT FROM OLD.credit_package_id
|
||||
OR NEW.product_id IS DISTINCT FROM OLD.product_id
|
||||
OR NEW.product_grants IS DISTINCT FROM OLD.product_grants
|
||||
OR NEW.description IS DISTINCT FROM OLD.description
|
||||
OR NEW.expires_at IS DISTINCT FROM OLD.expires_at
|
||||
) 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;
|
||||
|
||||
-- Receipt evidence is an append-only authorization ledger. If an observation is later found to
|
||||
-- be wrong, add adjudication metadata elsewhere; never rewrite or erase the evidence that allowed
|
||||
-- an invoice to confirm.
|
||||
CREATE OR REPLACE FUNCTION reject_payment_receipt_mutation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'payment receipt evidence is append-only for receipt %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_payment_receipts_append_only ON payment_receipts;
|
||||
CREATE TRIGGER trg_payment_receipts_append_only
|
||||
BEFORE UPDATE OR DELETE ON payment_receipts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reject_payment_receipt_mutation();
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'payouts_no_address_review_shape'
|
||||
) THEN
|
||||
ALTER TABLE payouts
|
||||
ADD CONSTRAINT payouts_no_address_review_shape CHECK (
|
||||
payout_address <> 'PENDING_NO_ADDRESS'
|
||||
OR (
|
||||
status = 'needs_review'
|
||||
AND reason IN ('match_winner_no_address', 'solo_winner_no_address')
|
||||
AND tx_hash IS NULL
|
||||
)
|
||||
) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION reject_payout_obligation_mutation()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
allowed_address_claim BOOLEAN := FALSE;
|
||||
BEGIN
|
||||
IF NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.game_id IS DISTINCT FROM OLD.game_id
|
||||
OR NEW.match_id IS DISTINCT FROM OLD.match_id
|
||||
OR NEW.amount IS DISTINCT FROM OLD.amount
|
||||
OR NEW.multiplier IS DISTINCT FROM OLD.multiplier
|
||||
OR NEW.reason IS DISTINCT FROM OLD.reason
|
||||
THEN
|
||||
RAISE EXCEPTION 'payout economic identity is immutable for payout %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF NEW.payout_address IS DISTINCT FROM OLD.payout_address THEN
|
||||
allowed_address_claim :=
|
||||
OLD.payout_address = 'PENDING_NO_ADDRESS'
|
||||
AND OLD.status = 'needs_review'
|
||||
AND OLD.reason IN ('match_winner_no_address', 'solo_winner_no_address')
|
||||
AND OLD.tx_hash IS NULL
|
||||
AND NEW.status = 'pending'
|
||||
AND NEW.tx_hash IS NULL
|
||||
AND BTRIM(COALESCE(NEW.payout_address, '')) <> ''
|
||||
AND NEW.payout_address <> 'PENDING_NO_ADDRESS'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM users u
|
||||
WHERE u.id = OLD.user_id
|
||||
AND u.payout_address = NEW.payout_address
|
||||
);
|
||||
IF NOT allowed_address_claim THEN
|
||||
RAISE EXCEPTION 'payout destination is immutable for payout %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_payouts_immutable_obligation ON payouts;
|
||||
CREATE TRIGGER trg_payouts_immutable_obligation
|
||||
BEFORE UPDATE ON payouts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reject_payout_obligation_mutation();
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Migration 039: Preserve unresolved solo-win payout obligations for manual review.
|
||||
--
|
||||
-- completeGame records a paid win even when its committed user identity cannot be resolved. That
|
||||
-- row must remain a durable, non-dispatchable obligation rather than rolling back the terminal
|
||||
-- game transaction. Migration 038's first constraint omitted this explicit review reason.
|
||||
--
|
||||
-- This forward replacement deliberately changes only the accepted row shape. The immutable
|
||||
-- payout trigger continues to exclude solo_winner_identity_review from its saved-address claim
|
||||
-- exception, so an operator cannot accidentally turn an ownerless obligation into a transfer.
|
||||
|
||||
ALTER TABLE payouts
|
||||
DROP CONSTRAINT IF EXISTS payouts_no_address_review_shape;
|
||||
|
||||
ALTER TABLE payouts
|
||||
ADD CONSTRAINT payouts_no_address_review_shape CHECK (
|
||||
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
|
||||
)
|
||||
) NOT VALID;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Migration 040: durable proof that a paid entrant freeze committed before its entropy block.
|
||||
--
|
||||
-- A pending match first commits its exact entrants and future target. Only after that transaction
|
||||
-- is durably visible may the server read a fresh daemon tip and record that the target still did
|
||||
-- not exist. These two columns are that post-commit witness. Legacy/unverified freezes are refunded
|
||||
-- at startup and may never activate.
|
||||
|
||||
ALTER TABLE matches
|
||||
ADD COLUMN IF NOT EXISTS entropy_precommit_tip_height BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS entropy_precommit_verified_at TIMESTAMPTZ;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'matches_entropy_precommit_shape'
|
||||
AND conrelid = 'matches'::regclass
|
||||
) THEN
|
||||
ALTER TABLE matches
|
||||
ADD CONSTRAINT matches_entropy_precommit_shape CHECK (
|
||||
(entropy_precommit_tip_height IS NULL
|
||||
AND entropy_precommit_verified_at IS NULL)
|
||||
OR
|
||||
(entropy_precommit_tip_height IS NOT NULL
|
||||
AND entropy_precommit_verified_at IS NOT NULL
|
||||
AND entropy_precommit_tip_height >= 0
|
||||
AND start_block_height IS NOT NULL
|
||||
AND entropy_precommit_tip_height < start_block_height
|
||||
AND economy IN ('credits_prestige', 'crypto_race'))
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION reject_match_entropy_precommit_mutation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.entropy_precommit_verified_at IS NOT NULL AND (
|
||||
NEW.entropy_precommit_tip_height IS DISTINCT FROM OLD.entropy_precommit_tip_height
|
||||
OR NEW.entropy_precommit_verified_at IS DISTINCT FROM OLD.entropy_precommit_verified_at
|
||||
) THEN
|
||||
RAISE EXCEPTION 'paid entropy precommit proof is immutable for match %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
-- Recording the proof locks the paid freeze identity. The one permitted identity-changing
|
||||
-- transition is the application's pending -> starting activation, whose conditional UPDATE
|
||||
-- proves the commitment, target, economy, and marker. Once starting, normal lifecycle writes
|
||||
-- change only status/times, seed reveal, winner, and liability fields.
|
||||
IF OLD.entropy_precommit_verified_at IS NOT NULL
|
||||
AND NOT (OLD.status = 'pending' AND NEW.status = 'starting')
|
||||
AND (
|
||||
NEW.id IS DISTINCT FROM OLD.id
|
||||
OR NEW.economy IS DISTINCT FROM OLD.economy
|
||||
OR NEW.variant IS DISTINCT FROM OLD.variant
|
||||
OR NEW.ruleset_id IS DISTINCT FROM OLD.ruleset_id
|
||||
OR NEW.difficulty_preset IS DISTINCT FROM OLD.difficulty_preset
|
||||
OR NEW.max_players IS DISTINCT FROM OLD.max_players
|
||||
OR NEW.seed_hash IS DISTINCT FROM OLD.seed_hash
|
||||
OR NEW.dungeon IS DISTINCT FROM OLD.dungeon
|
||||
OR NEW.start_block_height IS DISTINCT FROM OLD.start_block_height
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'paid match fairness identity is immutable after precommit for match %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF OLD.entropy_precommit_verified_at IS NULL
|
||||
AND NEW.entropy_precommit_verified_at IS NOT NULL
|
||||
AND (
|
||||
OLD.status <> 'pending'
|
||||
OR OLD.economy NOT IN ('credits_prestige', 'crypto_race')
|
||||
OR NEW.status IS DISTINCT FROM OLD.status
|
||||
OR NEW.economy IS DISTINCT FROM OLD.economy
|
||||
OR NEW.seed_hash IS DISTINCT FROM OLD.seed_hash
|
||||
OR NEW.dungeon IS DISTINCT FROM OLD.dungeon
|
||||
OR NEW.start_block_height IS DISTINCT FROM OLD.start_block_height
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'paid freeze identity cannot change while recording precommit proof for match %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_matches_immutable_entropy_precommit ON matches;
|
||||
CREATE TRIGGER trg_matches_immutable_entropy_precommit
|
||||
BEFORE UPDATE ON matches
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reject_match_entropy_precommit_mutation();
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Durable, backend-neutral accounting event export. Payment confirmation never waits for the
|
||||
-- accounting receiver: this trigger only appends an outbox identity inside the payment transaction.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS financial_event_outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_kind VARCHAR(40) NOT NULL,
|
||||
aggregate_id INTEGER NOT NULL REFERENCES payments(id) ON DELETE RESTRICT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'in_flight', 'delivered')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
lease_until TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT financial_event_outbox_once UNIQUE (event_kind, aggregate_id),
|
||||
CONSTRAINT financial_event_outbox_state CHECK (
|
||||
(status = 'pending' AND lease_until IS NULL AND delivered_at IS NULL)
|
||||
OR (status = 'in_flight' AND lease_until IS NOT NULL AND delivered_at IS NULL)
|
||||
OR (status = 'delivered' AND lease_until IS NULL AND delivered_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_financial_event_outbox_due
|
||||
ON financial_event_outbox(next_attempt_at, created_at)
|
||||
WHERE delivered_at IS NULL;
|
||||
|
||||
CREATE OR REPLACE FUNCTION enqueue_confirmed_payment_financial_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'confirmed'
|
||||
AND COALESCE(NEW.received_amount, 0) > 0
|
||||
AND (
|
||||
TG_OP = 'INSERT'
|
||||
OR OLD.status IS DISTINCT FROM 'confirmed'
|
||||
OR COALESCE(OLD.received_amount, 0) <= 0
|
||||
) THEN
|
||||
INSERT INTO financial_event_outbox (event_kind, aggregate_id)
|
||||
VALUES ('payment.confirmed', NEW.id)
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enqueue_confirmed_payment_financial_event ON payments;
|
||||
CREATE TRIGGER trg_enqueue_confirmed_payment_financial_event
|
||||
AFTER INSERT OR UPDATE OF status, received_amount ON payments
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_confirmed_payment_financial_event();
|
||||
|
||||
-- Backfill only receipt-valued rows. Legacy confirmed rows with received_amount=0 lack adequate
|
||||
-- economic evidence and must not be manufactured into revenue events.
|
||||
INSERT INTO financial_event_outbox (event_kind, aggregate_id)
|
||||
SELECT 'payment.confirmed', id
|
||||
FROM payments
|
||||
WHERE status = 'confirmed' AND COALESCE(received_amount, 0) > 0
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
@@ -0,0 +1,366 @@
|
||||
-- Correct the migration-041 accounting outbox before it is used in production.
|
||||
--
|
||||
-- The payment/refund transaction only appends an immutable, non-PII source snapshot. Network
|
||||
-- policy and HTTP delivery remain asynchronous in FinancialEventExporter, so a slow or failed
|
||||
-- accounting sink can never hold a payment-confirmation transaction open. The exporter writes
|
||||
-- the exact delivery document once and PostgreSQL prevents either snapshot from being rewritten.
|
||||
|
||||
ALTER TABLE financial_event_outbox
|
||||
ADD COLUMN IF NOT EXISTS source_type VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS source_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS event_id VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS payload_snapshot JSONB,
|
||||
ADD COLUMN IF NOT EXISTS delivery_payload JSONB,
|
||||
ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS ignored_reason VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS dead_lettered_at TIMESTAMPTZ;
|
||||
|
||||
-- Migration 041 had a three-state constraint. Drop it before converting malformed legacy rows
|
||||
-- to an explicit terminal state; a replacement five-state constraint is installed below.
|
||||
ALTER TABLE financial_event_outbox
|
||||
DROP CONSTRAINT IF EXISTS financial_event_outbox_status_check,
|
||||
DROP CONSTRAINT IF EXISTS financial_event_outbox_state;
|
||||
|
||||
-- Snapshot every 041 identity without consulting the payment again at delivery time. Confirmed
|
||||
-- receipt totals are preferred for old rows because receipt evidence is append-only; received_amount
|
||||
-- is the compatibility fallback for pre-receipt history. A missing confirmed_at is not allowed to
|
||||
-- poison the queue: the durable outbox/payment creation timestamp is used instead.
|
||||
WITH payment_source AS (
|
||||
SELECT
|
||||
o.id AS outbox_id,
|
||||
p.id AS payment_id,
|
||||
COALESCE(
|
||||
NULLIF((
|
||||
SELECT COALESCE(SUM(r.amount), 0)
|
||||
FROM payment_receipts r
|
||||
WHERE r.payment_id = p.id AND r.confirmed = TRUE
|
||||
), 0),
|
||||
p.received_amount
|
||||
) AS atomic_amount,
|
||||
COALESCE(p.confirmed_at, p.created_at, o.created_at, NOW()) AS occurred_at,
|
||||
p.payment_type,
|
||||
p.product_id,
|
||||
p.provider_id,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM payment_receipts r
|
||||
WHERE r.payment_id = p.id AND r.confirmed = TRUE
|
||||
) AS receipt_count
|
||||
FROM financial_event_outbox o
|
||||
JOIN payments p ON p.id = o.aggregate_id
|
||||
WHERE o.event_kind = 'payment.confirmed'
|
||||
)
|
||||
UPDATE financial_event_outbox o
|
||||
SET source_type = 'payment',
|
||||
source_id = s.payment_id,
|
||||
event_id = 'payment-confirmed-' || s.payment_id::text,
|
||||
payload_snapshot = jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', 'payment-confirmed-' || s.payment_id::text,
|
||||
'event_kind', 'payment.confirmed',
|
||||
'source_type', 'payment',
|
||||
'source_id', s.payment_id,
|
||||
'aggregate_id', s.payment_id,
|
||||
'occurred_at', s.occurred_at,
|
||||
'atomic_amount', COALESCE(s.atomic_amount, 0)::text,
|
||||
'payment_type', COALESCE(s.payment_type, ''),
|
||||
'product_id', COALESCE(s.product_id, ''),
|
||||
'provider_id', COALESCE(s.provider_id, ''),
|
||||
'receipt_count', s.receipt_count
|
||||
),
|
||||
updated_at = NOW()
|
||||
FROM payment_source s
|
||||
WHERE o.id = s.outbox_id
|
||||
AND o.payload_snapshot IS NULL;
|
||||
|
||||
-- A 041 row whose historical amount has become unusable must terminate safely instead of being
|
||||
-- retried forever. Already-delivered rows are retained as delivered audit history.
|
||||
UPDATE financial_event_outbox
|
||||
SET status = 'ignored',
|
||||
source_type = COALESCE(source_type, 'payment'),
|
||||
source_id = COALESCE(source_id, aggregate_id),
|
||||
event_id = COALESCE(event_id, 'ignored-legacy-outbox-' || id::text),
|
||||
payload_snapshot = COALESCE(payload_snapshot, jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', COALESCE(event_id, 'ignored-legacy-outbox-' || id::text),
|
||||
'event_kind', event_kind,
|
||||
'source_type', COALESCE(source_type, 'payment'),
|
||||
'source_id', COALESCE(source_id, aggregate_id),
|
||||
'aggregate_id', aggregate_id,
|
||||
'occurred_at', COALESCE(created_at, NOW()),
|
||||
'atomic_amount', '0'
|
||||
)),
|
||||
lease_until = NULL,
|
||||
ignored_at = NOW(),
|
||||
ignored_reason = 'malformed_legacy_snapshot',
|
||||
last_error = 'Malformed legacy financial-event snapshot was suppressed',
|
||||
updated_at = NOW()
|
||||
WHERE delivered_at IS NULL
|
||||
AND (
|
||||
payload_snapshot IS NULL
|
||||
OR payload_snapshot->>'atomic_amount' !~ '^[1-9][0-9]*$'
|
||||
);
|
||||
|
||||
-- Build the exact safe source document at the confirmation boundary for all future payments.
|
||||
CREATE OR REPLACE FUNCTION enqueue_confirmed_payment_financial_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
safe_occurred_at TIMESTAMPTZ;
|
||||
safe_receipt_count BIGINT;
|
||||
stable_event_id TEXT;
|
||||
BEGIN
|
||||
IF NEW.status = 'confirmed'
|
||||
AND COALESCE(NEW.received_amount, 0) > 0
|
||||
AND (
|
||||
TG_OP = 'INSERT'
|
||||
OR OLD.status IS DISTINCT FROM 'confirmed'
|
||||
OR COALESCE(OLD.received_amount, 0) <= 0
|
||||
) THEN
|
||||
safe_occurred_at := COALESCE(NEW.confirmed_at, NEW.created_at, NOW());
|
||||
stable_event_id := 'payment-confirmed-' || NEW.id::text;
|
||||
SELECT COUNT(*) INTO safe_receipt_count
|
||||
FROM payment_receipts r
|
||||
WHERE r.payment_id = NEW.id AND r.confirmed = TRUE;
|
||||
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
) VALUES (
|
||||
'payment.confirmed',
|
||||
NEW.id,
|
||||
'payment',
|
||||
NEW.id,
|
||||
stable_event_id,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', stable_event_id,
|
||||
'event_kind', 'payment.confirmed',
|
||||
'source_type', 'payment',
|
||||
'source_id', NEW.id,
|
||||
'aggregate_id', NEW.id,
|
||||
'occurred_at', safe_occurred_at,
|
||||
'atomic_amount', NEW.received_amount::text,
|
||||
'payment_type', COALESCE(NEW.payment_type, ''),
|
||||
'product_id', COALESCE(NEW.product_id, ''),
|
||||
'provider_id', COALESCE(NEW.provider_id, ''),
|
||||
'receipt_count', safe_receipt_count
|
||||
)
|
||||
)
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enqueue_confirmed_payment_financial_event ON payments;
|
||||
CREATE TRIGGER trg_enqueue_confirmed_payment_financial_event
|
||||
AFTER INSERT OR UPDATE OF status, received_amount ON payments
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_confirmed_payment_financial_event();
|
||||
|
||||
-- If migration 041 was applied while writes continued, add any valid confirmation identity it
|
||||
-- missed. This backfill is deliberately receipt/value based and never copies customer identity,
|
||||
-- invoice destinations, wallet addresses, transaction hashes, provider invoice IDs, or metadata.
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
)
|
||||
SELECT
|
||||
'payment.confirmed',
|
||||
p.id,
|
||||
'payment',
|
||||
p.id,
|
||||
'payment-confirmed-' || p.id::text,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', 'payment-confirmed-' || p.id::text,
|
||||
'event_kind', 'payment.confirmed',
|
||||
'source_type', 'payment',
|
||||
'source_id', p.id,
|
||||
'aggregate_id', p.id,
|
||||
'occurred_at', COALESCE(p.confirmed_at, p.created_at, NOW()),
|
||||
'atomic_amount', p.received_amount::text,
|
||||
'payment_type', COALESCE(p.payment_type, ''),
|
||||
'product_id', COALESCE(p.product_id, ''),
|
||||
'provider_id', COALESCE(p.provider_id, ''),
|
||||
'receipt_count', (
|
||||
SELECT COUNT(*) FROM payment_receipts r
|
||||
WHERE r.payment_id = p.id AND r.confirmed = TRUE
|
||||
)
|
||||
)
|
||||
FROM payments p
|
||||
WHERE p.status = 'confirmed' AND COALESCE(p.received_amount, 0) > 0
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
|
||||
-- A refund produces exactly one final reversal identity. `recorded` is the terminal bookkeeping
|
||||
-- reversal used when no wallet transfer was requested; `completed` is the terminal wallet refund.
|
||||
-- requested/processing/needs_review states are intentionally never accounting events.
|
||||
CREATE OR REPLACE FUNCTION enqueue_final_payment_refund_financial_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
stable_event_id TEXT;
|
||||
safe_occurred_at TIMESTAMPTZ;
|
||||
BEGIN
|
||||
IF NEW.status IN ('recorded', 'completed')
|
||||
AND (
|
||||
TG_OP = 'INSERT'
|
||||
OR OLD.status IS DISTINCT FROM NEW.status
|
||||
) THEN
|
||||
stable_event_id := 'payment-refund-' || NEW.id::text;
|
||||
safe_occurred_at := CASE
|
||||
WHEN NEW.status = 'completed' THEN COALESCE(NEW.completed_at, NEW.updated_at, NOW())
|
||||
ELSE COALESCE(NEW.created_at, NEW.updated_at, NOW())
|
||||
END;
|
||||
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
) VALUES (
|
||||
'payment.refund',
|
||||
NEW.payment_id,
|
||||
'payment_refund',
|
||||
NEW.id,
|
||||
stable_event_id,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', stable_event_id,
|
||||
'event_kind', 'payment.refund',
|
||||
'source_type', 'payment_refund',
|
||||
'source_id', NEW.id,
|
||||
'aggregate_id', NEW.payment_id,
|
||||
'occurred_at', safe_occurred_at,
|
||||
'atomic_amount', NEW.amount::text,
|
||||
'refund_status', NEW.status
|
||||
)
|
||||
)
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_enqueue_final_payment_refund_financial_event ON payment_refunds;
|
||||
CREATE TRIGGER trg_enqueue_final_payment_refund_financial_event
|
||||
AFTER INSERT OR UPDATE OF status ON payment_refunds
|
||||
FOR EACH ROW EXECUTE FUNCTION enqueue_final_payment_refund_financial_event();
|
||||
|
||||
INSERT INTO financial_event_outbox (
|
||||
event_kind, aggregate_id, source_type, source_id, event_id, payload_snapshot
|
||||
)
|
||||
SELECT
|
||||
'payment.refund',
|
||||
r.payment_id,
|
||||
'payment_refund',
|
||||
r.id,
|
||||
'payment-refund-' || r.id::text,
|
||||
jsonb_build_object(
|
||||
'schema', 'financial-event-source/v1',
|
||||
'event_id', 'payment-refund-' || r.id::text,
|
||||
'event_kind', 'payment.refund',
|
||||
'source_type', 'payment_refund',
|
||||
'source_id', r.id,
|
||||
'aggregate_id', r.payment_id,
|
||||
'occurred_at', CASE
|
||||
WHEN r.status = 'completed' THEN COALESCE(r.completed_at, r.updated_at, NOW())
|
||||
ELSE COALESCE(r.created_at, r.updated_at, NOW())
|
||||
END,
|
||||
'atomic_amount', r.amount::text,
|
||||
'refund_status', r.status
|
||||
)
|
||||
FROM payment_refunds r
|
||||
WHERE r.status IN ('recorded', 'completed')
|
||||
ON CONFLICT (event_kind, aggregate_id) DO NOTHING;
|
||||
|
||||
-- All 041 rows now have a complete identity or are terminally ignored. Require new writes to
|
||||
-- carry one, constrain the source document to the deliberately small non-PII key set, and make
|
||||
-- each source/event identity globally stable.
|
||||
ALTER TABLE financial_event_outbox
|
||||
ALTER COLUMN source_type SET NOT NULL,
|
||||
ALTER COLUMN source_id SET NOT NULL,
|
||||
ALTER COLUMN event_id SET NOT NULL,
|
||||
ALTER COLUMN payload_snapshot SET NOT NULL;
|
||||
|
||||
ALTER TABLE financial_event_outbox
|
||||
ADD CONSTRAINT financial_event_outbox_event_id_once UNIQUE (event_id),
|
||||
ADD CONSTRAINT financial_event_outbox_source_once UNIQUE (event_kind, source_type, source_id),
|
||||
ADD CONSTRAINT financial_event_outbox_source_shape CHECK (
|
||||
source_type IN ('payment', 'payment_refund')
|
||||
AND jsonb_typeof(payload_snapshot) = 'object'
|
||||
AND (payload_snapshot->>'schema') IS NOT DISTINCT FROM 'financial-event-source/v1'
|
||||
AND (payload_snapshot->>'event_id') IS NOT DISTINCT FROM event_id
|
||||
AND (payload_snapshot->>'event_kind') IS NOT DISTINCT FROM event_kind
|
||||
AND (payload_snapshot->>'source_type') IS NOT DISTINCT FROM source_type
|
||||
AND (payload_snapshot->>'source_id') IS NOT DISTINCT FROM source_id::text
|
||||
AND (payload_snapshot->>'aggregate_id') IS NOT DISTINCT FROM aggregate_id::text
|
||||
AND (payload_snapshot - ARRAY[
|
||||
'schema', 'event_id', 'event_kind', 'source_type', 'source_id', 'aggregate_id',
|
||||
'occurred_at', 'atomic_amount', 'payment_type', 'product_id', 'provider_id',
|
||||
'receipt_count', 'refund_status'
|
||||
]) = '{}'::jsonb
|
||||
),
|
||||
ADD CONSTRAINT financial_event_outbox_delivery_shape CHECK (
|
||||
delivery_payload IS NULL
|
||||
OR (
|
||||
jsonb_typeof(delivery_payload) = 'object'
|
||||
AND (delivery_payload->>'schema') IS NOT DISTINCT FROM 'financial-event/v1'
|
||||
AND (delivery_payload->>'id') IS NOT DISTINCT FROM event_id
|
||||
)
|
||||
),
|
||||
ADD CONSTRAINT financial_event_outbox_status_check CHECK (
|
||||
status IN ('pending', 'in_flight', 'delivered', 'ignored', 'dead_letter')
|
||||
),
|
||||
ADD CONSTRAINT financial_event_outbox_state CHECK (
|
||||
(status = 'pending'
|
||||
AND lease_until IS NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NULL)
|
||||
OR (status = 'in_flight'
|
||||
AND lease_until IS NOT NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NULL)
|
||||
OR (status = 'delivered'
|
||||
AND lease_until IS NULL AND delivered_at IS NOT NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NULL)
|
||||
OR (status = 'ignored'
|
||||
AND lease_until IS NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NOT NULL AND ignored_reason IS NOT NULL
|
||||
AND dead_lettered_at IS NULL)
|
||||
OR (status = 'dead_letter'
|
||||
AND lease_until IS NULL AND delivered_at IS NULL
|
||||
AND ignored_at IS NULL AND dead_lettered_at IS NOT NULL)
|
||||
);
|
||||
|
||||
-- The source snapshot and stable identity are immutable immediately. The delivery document has
|
||||
-- one allowed transition (NULL -> exact payload) so the worker can bind the deployment's mainnet
|
||||
-- asset/network/account reference before its first HTTP attempt. Retries must reuse that document.
|
||||
CREATE OR REPLACE FUNCTION reject_financial_event_identity_mutation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.event_kind IS DISTINCT FROM OLD.event_kind
|
||||
OR NEW.aggregate_id IS DISTINCT FROM OLD.aggregate_id
|
||||
OR NEW.source_type IS DISTINCT FROM OLD.source_type
|
||||
OR NEW.source_id IS DISTINCT FROM OLD.source_id
|
||||
OR NEW.event_id IS DISTINCT FROM OLD.event_id
|
||||
OR NEW.payload_snapshot IS DISTINCT FROM OLD.payload_snapshot
|
||||
OR (OLD.delivery_payload IS NOT NULL
|
||||
AND NEW.delivery_payload IS DISTINCT FROM OLD.delivery_payload)
|
||||
OR (OLD.delivery_payload IS NULL AND NEW.delivery_payload IS NOT NULL
|
||||
AND (OLD.status IS DISTINCT FROM 'in_flight'
|
||||
OR NEW.status IS DISTINCT FROM 'in_flight'))
|
||||
OR (OLD.delivery_payload IS NULL AND NEW.delivery_payload IS NULL
|
||||
AND NEW.status IN ('delivered'))
|
||||
THEN
|
||||
RAISE EXCEPTION 'financial event identity/payload is immutable for outbox row %', OLD.id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_financial_event_identity_immutable ON financial_event_outbox;
|
||||
CREATE TRIGGER trg_financial_event_identity_immutable
|
||||
BEFORE UPDATE ON financial_event_outbox
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_financial_event_identity_mutation();
|
||||
|
||||
DROP INDEX IF EXISTS idx_financial_event_outbox_due;
|
||||
CREATE INDEX idx_financial_event_outbox_due
|
||||
ON financial_event_outbox(next_attempt_at, created_at, id)
|
||||
WHERE status IN ('pending', 'in_flight');
|
||||
|
||||
CREATE INDEX idx_financial_event_outbox_terminal_health
|
||||
ON financial_event_outbox(status, updated_at)
|
||||
WHERE status IN ('dead_letter', 'ignored');
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Durable, versioned snapshots for nonterminal solo games during a graceful process restart.
|
||||
--
|
||||
-- The existing suspended-game manager is intentionally an in-memory reconnect cache. A systemd
|
||||
-- restart destroys that cache, so a controlled shutdown must first anchor the exact runtime state
|
||||
-- to the durable game/user identity. Snapshots never authorize a payout or alter accepted economic
|
||||
-- terms; they only allow the same active run to be reconstructed before public admission resumes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS solo_restart_snapshots (
|
||||
game_id INTEGER PRIMARY KEY REFERENCES games(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
dungeon_seed VARCHAR(50) NOT NULL,
|
||||
snapshot_version SMALLINT NOT NULL,
|
||||
original_socket_id VARCHAR(255) NOT NULL,
|
||||
payment_monitoring_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
state JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT solo_restart_snapshot_version_check CHECK (snapshot_version = 1),
|
||||
CONSTRAINT solo_restart_snapshot_state_object_check CHECK (jsonb_typeof(state) = 'object'),
|
||||
CONSTRAINT solo_restart_snapshot_user_once UNIQUE (user_id),
|
||||
CONSTRAINT solo_restart_snapshot_game_seed_once UNIQUE (dungeon_seed)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solo_restart_snapshots_created
|
||||
ON solo_restart_snapshots (created_at);
|
||||
|
||||
-- A terminal game can never retain a resumable state. Keeping this invariant in PostgreSQL makes
|
||||
-- every completion path (including future/manual paths) clean up the snapshot automatically.
|
||||
CREATE OR REPLACE FUNCTION delete_terminal_solo_restart_snapshot()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.status IS DISTINCT FROM 'active' THEN
|
||||
DELETE FROM solo_restart_snapshots WHERE game_id = NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_games_delete_terminal_restart_snapshot ON games;
|
||||
CREATE TRIGGER trg_games_delete_terminal_restart_snapshot
|
||||
AFTER UPDATE OF status ON games
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.status IS DISTINCT FROM 'active')
|
||||
EXECUTE FUNCTION delete_terminal_solo_restart_snapshot();
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Money: atomic-unit arithmetic on BigInt.
|
||||
*
|
||||
* The single source of truth for money math. All monetary values in this app are integer
|
||||
* atomic units (1 WOW = 10^11, 1 XMR = 10^12). JavaScript `number` is float64 and loses
|
||||
* precision above 2^53, so sums/products of large atomic amounts must NOT be done with `+`
|
||||
* or `*` on numbers. Everything here operates on BigInt and only narrows back to `number`
|
||||
* when the value is provably exact (<= MAX_SAFE_INTEGER).
|
||||
*/
|
||||
|
||||
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
/**
|
||||
* Coerce a string | number | bigint atomic amount to BigInt.
|
||||
* Rejects non-integer numbers and non-numeric strings (money is never fractional atomic).
|
||||
* @param {string|number|bigint} v
|
||||
* @returns {bigint}
|
||||
*/
|
||||
function toBig(v) {
|
||||
if (typeof v === 'bigint') return v;
|
||||
if (typeof v === 'number') {
|
||||
if (!Number.isFinite(v) || !Number.isSafeInteger(v)) {
|
||||
throw new Error(`Invalid atomic amount (not an exact safe integer number): ${v}`);
|
||||
}
|
||||
return BigInt(v);
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const s = v.trim();
|
||||
if (!/^-?\d+$/.test(s)) {
|
||||
throw new Error(`Invalid atomic amount (not an integer string): ${v}`);
|
||||
}
|
||||
return BigInt(s);
|
||||
}
|
||||
throw new Error(`Invalid atomic amount type: ${typeof v}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a BigInt atomic amount to a JS number when it is exactly representable,
|
||||
* otherwise return its decimal string. This keeps small amounts ergonomic (and
|
||||
* backward compatible with existing number-typed call sites and tests) while never
|
||||
* silently losing precision on large amounts.
|
||||
* @param {bigint} big
|
||||
* @returns {number|string}
|
||||
*/
|
||||
function toSafe(big) {
|
||||
const b = toBig(big);
|
||||
return (b <= MAX_SAFE && b >= -MAX_SAFE) ? Number(b) : b.toString();
|
||||
}
|
||||
|
||||
/** Sum a list of atomic amounts exactly. @returns {bigint} */
|
||||
function sum(values) {
|
||||
return (values || []).reduce((acc, v) => acc + toBig(v), 0n);
|
||||
}
|
||||
|
||||
/** Add two atomic amounts exactly. @returns {bigint} */
|
||||
function add(a, b) {
|
||||
return toBig(a) + toBig(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply an atomic amount by a non-negative decimal multiplier (e.g. 2, 3, 1.5, 2.5)
|
||||
* using exact integer math with half-up rounding: no float multiplication.
|
||||
* @param {string|number|bigint} atomic
|
||||
* @param {number|string} multiplier
|
||||
* @returns {bigint}
|
||||
*/
|
||||
function mulByDecimal(atomic, multiplier) {
|
||||
const a = toBig(atomic);
|
||||
const s = String(multiplier);
|
||||
if (!/^\d+(\.\d+)?$/.test(s)) {
|
||||
throw new Error(`Invalid multiplier (expected non-negative decimal): ${multiplier}`);
|
||||
}
|
||||
const [intPart, fracPart = ''] = s.split('.');
|
||||
const places = fracPart.length;
|
||||
const scale = 10n ** BigInt(places);
|
||||
const numerator = BigInt(intPart + fracPart); // "2.5" -> 25n, scale 10n
|
||||
// Half-up rounding: add half the denominator before truncating division.
|
||||
return (a * numerator + scale / 2n) / scale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an atomic amount as a human-readable decimal string for the given currency
|
||||
* decimals (e.g. format(150000000000n, 11) -> "1.5"). Trims trailing fractional zeros.
|
||||
* @param {string|number|bigint} atomic
|
||||
* @param {number} decimals
|
||||
* @returns {string}
|
||||
*/
|
||||
function format(atomic, decimals) {
|
||||
const a = toBig(atomic);
|
||||
const neg = a < 0n;
|
||||
const abs = neg ? -a : a;
|
||||
const d = BigInt(decimals);
|
||||
const divisor = 10n ** d;
|
||||
const whole = abs / divisor;
|
||||
const frac = abs % divisor;
|
||||
let fracStr = frac.toString().padStart(Number(decimals), '0').replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toString() + (fracStr ? '.' + fracStr : '');
|
||||
}
|
||||
|
||||
module.exports = { toBig, toSafe, sum, add, mulByDecimal, format, MAX_SAFE };
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* MatchEngine: server tick driver for a MatchRoom.
|
||||
*
|
||||
* The engine is intentionally thin: all state/rules live in MatchRoom, and this class owns
|
||||
* the timer that calls `room.resolveTick()` at a fixed interval. It also handles graceful
|
||||
* shutdown and can be used by MatchManager to drive many concurrent matches.
|
||||
*
|
||||
* Transport-agnostic: no Socket.IO here. The manager that owns this engine consumes the
|
||||
* tick result and broadcasts it.
|
||||
*/
|
||||
|
||||
class MatchEngine {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {MatchRoom} opts.room
|
||||
* @param {number} [opts.tickMs] default 250ms
|
||||
* @param {Function} [opts.onTick] callback(tickResult, room)
|
||||
* @param {Function} [opts.onFinish] callback(room)
|
||||
*/
|
||||
constructor({ room, tickMs = 250, onTick = null, onFinish = null } = {}) {
|
||||
if (!room) throw new Error('MatchEngine requires a MatchRoom');
|
||||
this.room = room;
|
||||
this.tickMs = tickMs;
|
||||
this.onTick = onTick;
|
||||
this.onFinish = onFinish;
|
||||
this.timer = null;
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running) return false;
|
||||
const started = this.room.start();
|
||||
if (!started) return false;
|
||||
this.running = true;
|
||||
this.timer = setInterval(() => this._tick(), this.tickMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single tick synchronously. Useful for tests and for manually stepping.
|
||||
* @returns {{tick:number, events:Array, finished:boolean}}
|
||||
*/
|
||||
tick() {
|
||||
return this._tick();
|
||||
}
|
||||
|
||||
_tick() {
|
||||
const result = this.room.resolveTick();
|
||||
if (this.onTick) this.onTick(result, this.room);
|
||||
if (result.finished) {
|
||||
this.stop();
|
||||
this.room.finalize();
|
||||
if (this.onFinish) this.onFinish(this.room);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force end (e.g. block deadline or hard ceiling). The manager should call this when
|
||||
* it knows the match must end regardless of in-engine state.
|
||||
* @param {string} reason
|
||||
*/
|
||||
expire(reason) {
|
||||
this.room.expire(reason);
|
||||
this.stop();
|
||||
this.room.finalize();
|
||||
if (this.onFinish) this.onFinish(this.room);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MatchEngine;
|
||||
@@ -0,0 +1,787 @@
|
||||
/**
|
||||
* MatchRoom: a server-authoritative multiplayer race room.
|
||||
*
|
||||
* Extends Room with a shared dungeon, shared monsters, treasure, and per-player
|
||||
* life/death/finish state. The engine is transport-agnostic; MatchManager owns the
|
||||
* Socket.IO rooms and the server tick timer.
|
||||
*
|
||||
* Invariants:
|
||||
* • One shared deterministic dungeon per match (provably fair from match seed).
|
||||
* • All player moves are queued and resolved together by MatchEngine each tick.
|
||||
* • Player-on-player collision is enabled (solidOccupants = true).
|
||||
* • One shared treasure exists; first player to reach it picks it up.
|
||||
* • If the treasure carrier dies, the treasure drops at their corpse.
|
||||
* • The selected ruleset owns resolution and ranking; timer/block expiry remains deterministic.
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const crypto = require('crypto');
|
||||
const Room = require('./Room');
|
||||
const Occupant = require('./Occupant');
|
||||
const DungeonGenerator = require('../game/dungeon');
|
||||
const Monster = require('../game/monster');
|
||||
const { createGameProof, createSeededRNG, seedToInt } = require('../game/provablyFair');
|
||||
const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('../game/difficultyConfig');
|
||||
const { defineRuleset, getRuleset, rulesetFromMatchOpts, resolveWinCondition } = require('../game/rulesets');
|
||||
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
const DEFAULT_MATCH_PRESET = 'race';
|
||||
|
||||
class MatchRoom extends Room {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.id]
|
||||
* @param {string} [opts.economy] 'free' | 'credits_prestige' | 'crypto_race'
|
||||
* @param {string} [opts.variant] 'race' | 'pvp' (default 'race')
|
||||
* @param {string} [opts.difficultyPreset] key passed to getDifficultyConfig
|
||||
* @param {number} [opts.maxPlayers] 2–32
|
||||
* @param {object} [opts.entrants] id -> { userId, name, avatar, appearance }
|
||||
* @param {string} [opts.seed] optional explicit seed (testing)
|
||||
* @param {number} [opts.startBlockHeight] block that triggered this race
|
||||
* @param {object} [opts.cryptoType] 'WOW' | 'XMR'
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
const id = opts.id || uuidv4();
|
||||
// Room constructor needs a layout or roomData. We'll build our own map afterwards,
|
||||
// so pass a minimal placeholder and then overwrite the map state.
|
||||
super({ id, type: 'match', layout: ['.'], solidOccupants: true, maxOccupants: opts.maxPlayers || 4 });
|
||||
|
||||
// Resolve the ruleset (Pillar 4). Precedence: an explicit ruleset object, then a rulesetId
|
||||
// from the registry, else one synthesized from the legacy opts, which preserves the classic
|
||||
// race behavior exactly, so nothing changes until a caller asks for a different ruleset.
|
||||
this.ruleset = opts.ruleset ? defineRuleset(opts.ruleset)
|
||||
: (opts.rulesetId ? getRuleset(opts.rulesetId, opts.rulesetOverrides) : rulesetFromMatchOpts(opts));
|
||||
this.winCondition = resolveWinCondition(this.ruleset.winCondition.type);
|
||||
this.pvpCombat = this.ruleset.entities.pvpCombat;
|
||||
this._deathCounter = 0; // increments per death → last-alive tiebreak (died later ranks higher)
|
||||
|
||||
this.economy = opts.economy || this.ruleset.economy.model;
|
||||
this.variant = opts.variant || (this.pvpCombat ? 'pvp' : 'race');
|
||||
this.difficultyPreset = opts.difficultyPreset || this.ruleset.world.difficultyPreset || DEFAULT_MATCH_PRESET;
|
||||
this.maxPlayers = Math.max(2, Math.min(32, opts.maxPlayers || this.ruleset.players.max || 4));
|
||||
this.cryptoType = opts.cryptoType || process.env.CRYPTO_TYPE || 'WOW';
|
||||
this.startBlockHeight = opts.startBlockHeight || null;
|
||||
this.createdAt = Date.now();
|
||||
this.tickCount = 0;
|
||||
this.status = 'starting'; // starting -> active -> finished | cancelled
|
||||
|
||||
// Provably fair proof
|
||||
const proof = createGameProof(id);
|
||||
this.seed = opts.seed || proof.seed;
|
||||
this.seedHash = crypto.createHash("sha256").update(this.seed).digest("hex");
|
||||
this.seededRNG = createSeededRNG(this.seed);
|
||||
this.seedInt = seedToInt(this.seed);
|
||||
|
||||
// Difficulty
|
||||
this.difficultyConfig = getDifficultyConfig(this.cryptoType, { preset: this.difficultyPreset });
|
||||
|
||||
// Generate the shared dungeon
|
||||
this._generateDungeon();
|
||||
|
||||
// Spawn players at entrance, and the shared monster when the ruleset includes one.
|
||||
this.monster = null;
|
||||
if (this.ruleset.entities.monster) this._spawnMonster();
|
||||
this._resetEntrants(opts.entrants || {});
|
||||
|
||||
// Shared treasure state
|
||||
this.treasure = this.dungeon.treasure ? { x: this.dungeon.treasure[0], y: this.dungeon.treasure[1], carrierId: null } : null;
|
||||
|
||||
// Movement queue for current tick: occupantId -> { dx, dy }
|
||||
this.moveQueue = new Map();
|
||||
// Carries fractional monster speed between ticks (for example 0.5 = one move every
|
||||
// other tick). This is match state, so it remains deterministic from the committed seed.
|
||||
this.monsterMoveAccumulator = 0;
|
||||
|
||||
// Timer / deadline bookkeeping (from the ruleset; defaults reproduce the classic values).
|
||||
this.minDurationMs = this.ruleset.timing.minDurationMs; // floor before next-block expiry can fire (20s)
|
||||
this.hardCeilingMs = this.ruleset.timing.hardCeilingMs; // absolute max (4m)
|
||||
this.startedAt = null;
|
||||
this.endedAt = null;
|
||||
this.endReason = null;
|
||||
|
||||
// Economy bookkeeping (set later by MatchManager).
|
||||
this.entryFeeAtomic = opts.entryFeeAtomic || 0;
|
||||
this.potAtomic = opts.potAtomic || 0;
|
||||
this.houseFeeAtomic = opts.houseFeeAtomic || 0;
|
||||
this.houseFeePercent = opts.houseFeePercent || 0;
|
||||
this.winnerId = null;
|
||||
|
||||
// Event log for this match (also persisted to match_events by manager)
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
_generateDungeon() {
|
||||
const cfg = this.difficultyConfig.dungeon;
|
||||
const gameConfig = {
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
floorVariation: 0.01,
|
||||
torchEnabled: true,
|
||||
torchDensity: 0.1,
|
||||
primaryFloor: "'1",
|
||||
secondaryFloor: "'2",
|
||||
torchTile: 'torch',
|
||||
rng: this.seededRNG,
|
||||
seedInt: this.seedInt
|
||||
};
|
||||
this.dungeon = DungeonGenerator.generate(cfg.width, cfg.height, gameConfig);
|
||||
|
||||
// Build a walkable grid for Room's movement/collision helpers.
|
||||
this.rows = cfg.height;
|
||||
this.cols = cfg.width;
|
||||
this.walkable = [];
|
||||
for (let y = 0; y < this.rows; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < this.cols; x++) {
|
||||
const tile = this.dungeon.map[y] && this.dungeon.map[y][x];
|
||||
row.push(tile === "'1" || tile === "'2" || tile === '>' || tile === '$M');
|
||||
}
|
||||
this.walkable.push(row);
|
||||
}
|
||||
|
||||
// Spawns: entrance first, then nearby walkable tiles if many players.
|
||||
const ex = this.dungeon.entrance[0];
|
||||
const ey = this.dungeon.entrance[1];
|
||||
const spawnCandidates = [{ x: ex, y: ey, d: 0 }];
|
||||
for (let y = 0; y < this.rows; y++) {
|
||||
for (let x = 0; x < this.cols; x++) {
|
||||
if (this.isWalkable(x, y) && (x !== ex || y !== ey)) {
|
||||
const d = Math.abs(x - ex) + Math.abs(y - ey);
|
||||
spawnCandidates.push({ x, y, d });
|
||||
}
|
||||
}
|
||||
}
|
||||
spawnCandidates.sort((a, b) => a.d - b.d);
|
||||
this.spawns = spawnCandidates.slice(0, this.maxPlayers);
|
||||
}
|
||||
|
||||
_spawnMonster() {
|
||||
if (this.dungeon.rooms && this.dungeon.rooms.length > 2) {
|
||||
const idx = getMonsterSpawnRoomIndex(
|
||||
this.dungeon.rooms,
|
||||
this.difficultyConfig.monster.startDistanceFromPlayer
|
||||
);
|
||||
const room = this.dungeon.rooms[idx];
|
||||
const center = room.getCenter();
|
||||
this.monster = new Monster(center[0], center[1], {
|
||||
visionRange: this.difficultyConfig.monster.visionRange || 12
|
||||
});
|
||||
} else {
|
||||
this.monster = new Monster(0, 0, {
|
||||
visionRange: this.difficultyConfig.monster.visionRange || 12
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the inherited tavern occupants with match entrants placed at the entrance.
|
||||
* @param {object} entrants - id -> { userId, name, avatar, appearance }
|
||||
*/
|
||||
_resetEntrants(entrants) {
|
||||
this.occupants.clear();
|
||||
this.playerStates = new Map(); // id -> { alive, finished, escaped, hasTreasure, placement, killedBy }
|
||||
|
||||
const ids = Object.keys(entrants);
|
||||
for (let i = 0; i < ids.length && i < this.maxPlayers; i++) {
|
||||
const id = ids[i];
|
||||
const e = entrants[id] || {};
|
||||
const spawn = this.spawns[i] || this.spawns[0] || { x: 0, y: 0 };
|
||||
const occ = new Occupant(id, {
|
||||
x: spawn.x,
|
||||
y: spawn.y,
|
||||
name: e.name || null,
|
||||
avatar: e.avatar || 'default',
|
||||
appearance: e.appearance || null,
|
||||
facing: 'down'
|
||||
});
|
||||
this.occupants.set(id, occ);
|
||||
this.playerStates.set(id, {
|
||||
userId: e.userId || null,
|
||||
// Stable across socket reconnect/re-keying. Same-tick action priority is derived
|
||||
// from this slot + the committed seed, never packet arrival order or socket id.
|
||||
entrantSlot: i,
|
||||
alive: true,
|
||||
finished: false,
|
||||
escaped: false,
|
||||
hasTreasure: false,
|
||||
placement: null,
|
||||
killedBy: null,
|
||||
score: 0,
|
||||
moves: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of players still alive and not finished. */
|
||||
get activePlayerCount() {
|
||||
let n = 0;
|
||||
for (const s of this.playerStates.values()) {
|
||||
if (s.alive && !s.finished) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Number of players who have reached the exit. */
|
||||
get finishCount() {
|
||||
let n = 0;
|
||||
for (const s of this.playerStates.values()) {
|
||||
if (s.finished) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a move for an occupant. Moves are not applied until resolveTick().
|
||||
* @param {string} id
|
||||
* @param {number} dx - -1, 0, or 1
|
||||
* @param {number} dy - -1, 0, or 1
|
||||
*/
|
||||
queueMove(id, dx, dy) {
|
||||
const occ = this.occupants.get(id);
|
||||
const state = this.playerStates.get(id);
|
||||
if (!occ || !state || !state.alive || state.finished) return false;
|
||||
dx = Math.sign(dx || 0);
|
||||
dy = Math.sign(dy || 0);
|
||||
if (Math.abs(dx) + Math.abs(dy) !== 1) return false;
|
||||
this.moveQueue.set(id, { dx, dy });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one server tick: apply all queued moves, resolve collisions, move the
|
||||
* monster, check deaths/treasure/exits. Returns a tick result with events.
|
||||
* @returns {{tick:number, events:Array, finished:boolean}}
|
||||
*/
|
||||
resolveTick() {
|
||||
if (this.status !== 'active') {
|
||||
return { tick: this.tickCount, events: [], finished: this.status === 'finished' };
|
||||
}
|
||||
this.tickCount++;
|
||||
const events = [];
|
||||
|
||||
// 1. Resolve player moves simultaneously.
|
||||
const moves = this._resolvePlayerMoves(events);
|
||||
|
||||
// 2. Move the shared monster toward the nearest visible active player.
|
||||
const monsterEvents = this._moveMonster();
|
||||
events.push(...monsterEvents);
|
||||
|
||||
// 3. Check win/death/treasure after all entities have moved.
|
||||
const resolutionEvents = this._checkResolution();
|
||||
events.push(...resolutionEvents);
|
||||
|
||||
this.moveQueue.clear();
|
||||
|
||||
if (this.status === 'finished') {
|
||||
this.endedAt = Date.now();
|
||||
events.push({ type: 'match_end', reason: this.endReason, winnerId: this.winnerId, tick: this.tickCount });
|
||||
}
|
||||
|
||||
return { tick: this.tickCount, events, finished: this.status === 'finished' };
|
||||
}
|
||||
|
||||
_resolvePlayerMoves(events) {
|
||||
// Build a map of intended destinations so we can detect head-on swaps and pile-ups.
|
||||
const plans = [];
|
||||
for (const [id, { dx, dy }] of this.moveQueue.entries()) {
|
||||
const occ = this.occupants.get(id);
|
||||
const state = this.playerStates.get(id);
|
||||
if (!occ || !state || !state.alive || state.finished) continue;
|
||||
const nx = occ.x + dx;
|
||||
const ny = occ.y + dy;
|
||||
const facing = Room.facingFor(dx, dy);
|
||||
plans.push({
|
||||
id,
|
||||
from: { x: occ.x, y: occ.y },
|
||||
to: { x: nx, y: ny },
|
||||
facing,
|
||||
priority: this._tickActionPriority(id)
|
||||
});
|
||||
}
|
||||
|
||||
// Network arrival order must never decide a paid match. Resolve the tick in a committed,
|
||||
// reproducible order which changes per tick but remains stable across reconnects.
|
||||
plans.sort((a, b) => a.priority.localeCompare(b.priority));
|
||||
|
||||
// If A moves to B's tile and B moves to A's tile, both bounce.
|
||||
const swapPairs = new Set();
|
||||
for (let i = 0; i < plans.length; i++) {
|
||||
for (let j = i + 1; j < plans.length; j++) {
|
||||
const a = plans[i];
|
||||
const b = plans[j];
|
||||
if (a.to.x === b.from.x && a.to.y === b.from.y &&
|
||||
b.to.x === a.from.x && b.to.y === a.from.y) {
|
||||
swapPairs.add(a.id);
|
||||
swapPairs.add(b.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Destination counts for pile-up detection.
|
||||
const destCount = new Map();
|
||||
for (const p of plans) {
|
||||
if (swapPairs.has(p.id)) continue;
|
||||
const key = `${p.to.x},${p.to.y}`;
|
||||
destCount.set(key, (destCount.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
// Apply each valid move.
|
||||
for (const p of plans) {
|
||||
if (this.status !== 'active') break;
|
||||
const occ = this.occupants.get(p.id);
|
||||
const actorState = this.playerStates.get(p.id);
|
||||
// An earlier same-tick strike may have eliminated this actor after intents were
|
||||
// captured. A dead/escaped actor never gets a post-mortem move or attack.
|
||||
if (!occ || !actorState || !actorState.alive || actorState.finished) {
|
||||
events.push({
|
||||
type: 'move_failed', id: p.id, reason: 'eliminated_before_action',
|
||||
from: p.from, to: p.to, tick: this.tickCount
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let reason = null;
|
||||
|
||||
if (swapPairs.has(p.id)) {
|
||||
reason = 'collision_swap';
|
||||
} else if (!this.isWalkable(p.to.x, p.to.y)) {
|
||||
reason = 'blocked';
|
||||
} else if (this.solidOccupants && this._isBlockingOccupantAt(p.to.x, p.to.y, p.id)) {
|
||||
// PvP: stepping onto a living rival strikes them down; the attacker holds position.
|
||||
if (this.pvpCombat) {
|
||||
const victimId = this._livingOccupantIdAt(p.to.x, p.to.y, p.id);
|
||||
if (victimId) {
|
||||
this._killPlayer(victimId, p.id);
|
||||
events.push({ type: 'player_death', id: victimId, killedBy: p.id, tick: this.tickCount });
|
||||
reason = 'attack';
|
||||
} else {
|
||||
reason = 'occupied'; // only a corpse / non-target is here
|
||||
}
|
||||
} else {
|
||||
reason = 'occupied';
|
||||
}
|
||||
} else if (destCount.get(`${p.to.x},${p.to.y}`) > 1) {
|
||||
reason = 'collision_pileup';
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
if (p.facing) occ.facing = p.facing;
|
||||
events.push({ type: 'move_failed', id: p.id, reason, from: p.from, to: p.to, tick: this.tickCount });
|
||||
continue;
|
||||
}
|
||||
|
||||
occ.moveTo(p.to.x, p.to.y, p.facing);
|
||||
actorState.moves++;
|
||||
events.push({ type: 'move', id: p.id, from: p.from, to: p.to, facing: p.facing, tick: this.tickCount });
|
||||
// If the player stepped onto the monster, they die immediately.
|
||||
if (this.monster && p.to.x === this.monster.x && p.to.y === this.monster.y) {
|
||||
this._killPlayer(p.id, 'monster');
|
||||
events.push({ type: 'player_death', id: p.id, killedBy: 'monster', tick: this.tickCount });
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
_tickActionPriority(id, tick = this.tickCount) {
|
||||
const slot = this.playerStates.get(id)?.entrantSlot;
|
||||
const stableSlot = Number.isInteger(slot) ? slot : -1;
|
||||
return crypto.createHash('sha256')
|
||||
.update(`${this.seed}:${tick}:${stableSlot}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
_moveMonster() {
|
||||
if (!this.monster || this.activePlayerCount === 0) return [];
|
||||
|
||||
const events = [];
|
||||
const occs = Array.from(this.occupants.values()).filter(o => {
|
||||
const s = this.playerStates.get(o.id);
|
||||
return s && s.alive && !s.finished;
|
||||
});
|
||||
if (occs.length === 0) return events;
|
||||
|
||||
// If the monster is already standing on a player, kill them immediately.
|
||||
for (const occ of occs) {
|
||||
if (occ.x === this.monster.x && occ.y === this.monster.y) {
|
||||
this._killPlayer(occ.id, 'monster');
|
||||
events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
|
||||
}
|
||||
}
|
||||
// Remove dead players from the active set before choosing a target.
|
||||
const activeOccs = occs.filter(o => {
|
||||
const s = this.playerStates.get(o.id);
|
||||
return s && s.alive && !s.finished;
|
||||
});
|
||||
if (activeOccs.length === 0) return events;
|
||||
|
||||
// Target nearest visible player.
|
||||
let target = null;
|
||||
let bestDist = Infinity;
|
||||
for (const occ of activeOccs) {
|
||||
const dist = this.monster.distanceTo(occ.x, occ.y);
|
||||
if (dist < bestDist && this.monster.hasLineOfSight(occ.x, occ.y, this.dungeon, this._monsterPassable.bind(this))) {
|
||||
bestDist = dist;
|
||||
target = occ;
|
||||
}
|
||||
}
|
||||
|
||||
const configuredSpeed = Number(this.difficultyConfig?.monster?.movesPerPlayerMove);
|
||||
const movesPerTick = Number.isFinite(configuredSpeed) && configuredSpeed >= 0
|
||||
? configuredSpeed
|
||||
: 1.0;
|
||||
this.monsterMoveAccumulator += movesPerTick;
|
||||
|
||||
while (this.monsterMoveAccumulator >= 1) {
|
||||
this.monsterMoveAccumulator -= 1;
|
||||
const moved = this.monster.moveTowardPlayer(
|
||||
target,
|
||||
this.dungeon,
|
||||
this.seededRNG
|
||||
);
|
||||
if (moved) {
|
||||
events.push({ type: 'monster_move', x: this.monster.x, y: this.monster.y, tick: this.tickCount });
|
||||
|
||||
// Check collisions with any player on this monster tile.
|
||||
for (const occ of activeOccs) {
|
||||
if (occ.x === this.monster.x && occ.y === this.monster.y) {
|
||||
this._killPlayer(occ.id, 'monster');
|
||||
events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
_monsterPassable(tile) {
|
||||
return tile === "'1" || tile === "'2" || tile === 0 || tile === '>' || tile === '$M';
|
||||
}
|
||||
|
||||
// The id of a living, in-play occupant standing on (x,y), excluding `excludeId`. Used for PvP
|
||||
// strikes (a corpse or a finished/escaped player is not a valid target).
|
||||
_livingOccupantIdAt(x, y, excludeId) {
|
||||
for (const occ of this.occupants.values()) {
|
||||
if (occ.id === excludeId) continue;
|
||||
if (occ.x === x && occ.y === y) {
|
||||
const s = this.playerStates.get(occ.id);
|
||||
if (s && s.alive && !s.finished) return occ.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Terminal players stay in occupants so spectators and final results can render them, but a
|
||||
// corpse or escaped hero must not become permanent dungeon geometry. This is especially
|
||||
// important at the single exit tile: Co-op and Score Attack require multiple players to pass
|
||||
// through it on different ticks. Live, in-play players remain solid (or attackable in PvP).
|
||||
_isBlockingOccupantAt(x, y, excludeId) {
|
||||
for (const occ of this.occupants.values()) {
|
||||
if (occ.id === excludeId || occ.x !== x || occ.y !== y) continue;
|
||||
const state = this.playerStates.get(occ.id);
|
||||
// Only an explicitly terminal state becomes pass-through. An unexpected occupant
|
||||
// without match state stays solid instead of failing open into an overlap.
|
||||
if (!state || (state.alive && !state.finished)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_checkResolution() {
|
||||
const events = [];
|
||||
const exit = this.dungeon.exit;
|
||||
|
||||
for (const occ of this.occupants.values()) {
|
||||
const state = this.playerStates.get(occ.id);
|
||||
if (!state || !state.alive || state.finished) continue;
|
||||
|
||||
// Monster collision (player moved onto monster tile).
|
||||
if (this.monster && occ.x === this.monster.x && occ.y === this.monster.y) {
|
||||
this._killPlayer(occ.id, 'monster');
|
||||
events.push({ type: 'player_death', id: occ.id, killedBy: 'monster', tick: this.tickCount });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Treasure pickup.
|
||||
if (this.treasure && this.treasure.carrierId === null &&
|
||||
occ.x === this.treasure.x && occ.y === this.treasure.y) {
|
||||
this.treasure.carrierId = occ.id;
|
||||
state.hasTreasure = true;
|
||||
events.push({ type: 'treasure_pickup', id: occ.id, x: occ.x, y: occ.y, tick: this.tickCount });
|
||||
}
|
||||
|
||||
// Exit reached. The ruleset's win condition decides what that means (race: first out
|
||||
// wins & ends; last-alive: you survived but the match continues; score: just finished).
|
||||
if (exit && occ.x === exit[0] && occ.y === exit[1]) {
|
||||
this._finishPlayer(occ.id, true);
|
||||
events.push({ type: 'player_exit', id: occ.id, hasTreasure: state.hasTreasure, tick: this.tickCount });
|
||||
this.winCondition.onExit(this, occ.id);
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
_killPlayer(id, killedBy) {
|
||||
const state = this.playerStates.get(id);
|
||||
const occ = this.occupants.get(id);
|
||||
if (!state || !state.alive) return;
|
||||
state.alive = false;
|
||||
state.killedBy = killedBy;
|
||||
state.deathOrder = ++this._deathCounter; // later deaths rank higher in last-alive
|
||||
|
||||
// Drop treasure at corpse if carrying.
|
||||
if (state.hasTreasure && this.treasure) {
|
||||
this.treasure.carrierId = null;
|
||||
this.treasure.x = occ.x;
|
||||
this.treasure.y = occ.y;
|
||||
state.hasTreasure = false;
|
||||
}
|
||||
|
||||
// The ruleset decides whether this death ends the match (race: all-dead; last-alive: one left).
|
||||
this.winCondition.onDeath(this);
|
||||
}
|
||||
|
||||
_finishPlayer(id, escaped) {
|
||||
const state = this.playerStates.get(id);
|
||||
if (!state || !state.alive || state.finished) return;
|
||||
state.finished = true;
|
||||
state.escaped = escaped;
|
||||
if (this.treasure && this.treasure.carrierId === id) {
|
||||
// Treasure carried out stays with the winner's record but is removed from the map.
|
||||
this.treasure = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition from 'starting' to 'active', recording the start time.
|
||||
*/
|
||||
start() {
|
||||
if (this.status !== 'starting') return false;
|
||||
this.status = 'active';
|
||||
this.startedAt = Date.now();
|
||||
this.events.push({ type: 'match_start', tick: this.tickCount, seedHash: this.seedHash });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force end because of a block deadline or hard ceiling. Determines winner by proximity
|
||||
* to exit among living players, or by progress if no one is alive.
|
||||
* @param {string} reason - 'block_deadline' | 'hard_ceiling' | 'abandoned'
|
||||
*/
|
||||
expire(reason) {
|
||||
if (this.status === 'finished' || this.status === 'cancelled') return;
|
||||
this.status = 'finished';
|
||||
this.endReason = reason;
|
||||
this.endedAt = Date.now();
|
||||
|
||||
const exit = this.dungeon.exit;
|
||||
const ranked = Array.from(this.playerStates.entries()).map(([id, state]) => {
|
||||
const occ = this.occupants.get(id);
|
||||
const dist = exit && occ ? Math.abs(occ.x - exit[0]) + Math.abs(occ.y - exit[1]) : Infinity;
|
||||
return { id, state, occ, dist };
|
||||
});
|
||||
|
||||
// Living closer to exit beats dead; then treasure; then fewer moves; then deterministic.
|
||||
ranked.sort((a, b) => {
|
||||
const aliveA = a.state.alive && !a.state.finished ? 1 : 0;
|
||||
const aliveB = b.state.alive && !b.state.finished ? 1 : 0;
|
||||
if (aliveA !== aliveB) return aliveB - aliveA;
|
||||
if (a.dist !== b.dist) return a.dist - b.dist;
|
||||
const tA = a.state.hasTreasure ? 1 : 0;
|
||||
const tB = b.state.hasTreasure ? 1 : 0;
|
||||
if (tA !== tB) return tB - tA;
|
||||
if (a.state.moves !== b.state.moves) return a.state.moves - b.state.moves;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
if (ranked.length > 0 && (ranked[0].state.alive || ranked[0].state.finished)) {
|
||||
this.winnerId = ranked[0].id;
|
||||
}
|
||||
|
||||
// Assign placements deterministically.
|
||||
for (let i = 0; i < ranked.length; i++) {
|
||||
ranked[i].state.placement = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate final scores and placements. Called once when the match is finished.
|
||||
* @returns {Map} playerStates with score and placement populated
|
||||
*/
|
||||
finalize() {
|
||||
if (this.status !== 'finished') return this.playerStates;
|
||||
|
||||
// The ruleset's win condition provides the final ordering (best-first). FIRST_TO_EXIT
|
||||
// reproduces the classic ranking (escape-winner first, then finished > alive > closer-to-
|
||||
// exit > treasure > fewer-moves); last-alive/high-score order by their own rules.
|
||||
const ranked = this.winCondition.rank(this);
|
||||
|
||||
for (let i = 0; i < ranked.length; i++) {
|
||||
const r = ranked[i];
|
||||
r.state.placement = i + 1;
|
||||
r.state.score = this._calculateScore(r.state, i + 1, r.dist);
|
||||
}
|
||||
|
||||
// Competitive winner consistency across ALL end paths: the payout target (winnerId) and
|
||||
// placement #1 name the same player. Co-op is deliberately collective and therefore has
|
||||
// no individual winnerId; placements remain useful progress/order metadata.
|
||||
const collective = this.ruleset.mode === 'coop'
|
||||
|| this.ruleset.winCondition.type === 'all-escape';
|
||||
this.winnerId = collective ? null : (ranked[0]?.id || null);
|
||||
|
||||
return this.playerStates;
|
||||
}
|
||||
|
||||
_calculateScore(state, placement, distToExit) {
|
||||
// Base solo-style score, then MP placement bonus.
|
||||
let score = 100;
|
||||
if (state.escaped) score += 200;
|
||||
if (state.hasTreasure) score += 200;
|
||||
|
||||
// Speed bonus: fewer moves is better (cap at 300).
|
||||
score += Math.max(0, 300 - Math.max(state.moves - 30, 0) * 3);
|
||||
|
||||
// Proximity bonus for non-escapers (closer to exit is better).
|
||||
if (!state.escaped && Number.isFinite(distToExit)) {
|
||||
score += Math.max(0, 100 - distToExit * 2);
|
||||
}
|
||||
|
||||
// Placement bonus: winner gets 300, 2nd 200, 3rd 100, others 0.
|
||||
const placementBonus = [300, 200, 100][placement - 1] || 0;
|
||||
score += placementBonus;
|
||||
|
||||
return Math.max(0, Math.round(score));
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a renderer-agnostic game state object that the existing render-kit adapter
|
||||
* `RK.sceneFromGameState()` can consume. This lets ASCII/Tiled/Fancy/Iso/3D modes all
|
||||
* draw the same match with no extra renderer work.
|
||||
*/
|
||||
toGameState(viewerId = null) {
|
||||
const visible = [];
|
||||
const explored = [];
|
||||
const lighting = {};
|
||||
|
||||
// For simplicity, the match spectator/client receives the full dungeon (no per-player
|
||||
// FOV in the MVP). Later we can add per-viewer FOV by running LightingAndFov here.
|
||||
for (let y = 0; y < this.rows; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < this.cols; x++) {
|
||||
row.push(this.dungeon.map[y][x]);
|
||||
}
|
||||
visible.push(row);
|
||||
explored.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
matchId: this.id,
|
||||
economy: this.economy,
|
||||
ruleset: this._rulesetSummary(),
|
||||
tick: this.tickCount,
|
||||
status: this.status,
|
||||
visibleTiles: visible,
|
||||
exploredTiles: explored,
|
||||
lighting,
|
||||
dungeonRows: this.rows,
|
||||
dungeonCols: this.cols,
|
||||
players: Array.from(this.occupants.values()).map(o => {
|
||||
const s = this.playerStates.get(o.id);
|
||||
return {
|
||||
id: o.id,
|
||||
x: o.x,
|
||||
y: o.y,
|
||||
name: o.name,
|
||||
avatar: o.avatar,
|
||||
appearance: o.appearance,
|
||||
facing: o.facing,
|
||||
alive: s ? s.alive : true,
|
||||
finished: s ? s.finished : false,
|
||||
escaped: s ? s.escaped : false,
|
||||
hasTreasure: s ? s.hasTreasure : false,
|
||||
placement: s ? s.placement : null,
|
||||
you: o.id === viewerId
|
||||
};
|
||||
}),
|
||||
monster: this.monster ? { x: this.monster.x, y: this.monster.y } : null,
|
||||
treasure: this.treasure ? { x: this.treasure.x, y: this.treasure.y, carrierId: this.treasure.carrierId } : null,
|
||||
exit: this.dungeon.exit,
|
||||
entrance: this.dungeon.entrance,
|
||||
winnerId: this.winnerId,
|
||||
endReason: this.endReason
|
||||
};
|
||||
}
|
||||
|
||||
/** Snapshot for the shared-world layer (includes occupants only). */
|
||||
snapshot() {
|
||||
return {
|
||||
...super.snapshot(),
|
||||
economy: this.economy,
|
||||
variant: this.variant,
|
||||
ruleset: this._rulesetSummary(),
|
||||
tick: this.tickCount,
|
||||
status: this.status,
|
||||
treasure: this.treasure,
|
||||
monster: this.monster ? { x: this.monster.x, y: this.monster.y } : null,
|
||||
winnerId: this.winnerId,
|
||||
endReason: this.endReason
|
||||
};
|
||||
}
|
||||
|
||||
/** Full state for a joining/reconnecting player. */
|
||||
fullState(viewerId = null) {
|
||||
return {
|
||||
...this.snapshot(),
|
||||
cols: this.cols,
|
||||
rows: this.rows,
|
||||
walkable: this.walkable,
|
||||
dungeon: this.dungeon,
|
||||
seedHash: this.seedHash,
|
||||
fairness: this.fairnessProof(this.status === 'finished'),
|
||||
gameState: this.toGameState(viewerId)
|
||||
};
|
||||
}
|
||||
|
||||
fairnessProof(reveal = false) {
|
||||
const derivation = this.seedDerivation || { version: 'server-random-v1' };
|
||||
const revealed = reveal && this.status === 'finished';
|
||||
const futureBlockFreeze = derivation.version === 'future-chain-block-v2';
|
||||
return {
|
||||
scope: futureBlockFreeze
|
||||
? 'future-block seed after durable entrant freeze'
|
||||
: (derivation.version === 'chain-block-v1'
|
||||
? 'chain-derived entrant-locked seed'
|
||||
: 'server commitment only'),
|
||||
version: derivation.version,
|
||||
seedHash: this.seedHash,
|
||||
seed: revealed ? this.seed : null,
|
||||
blockHeight: derivation.blockHeight ?? this.startBlockHeight ?? null,
|
||||
blockHash: derivation.blockHash || null,
|
||||
freezeBlockHeight: derivation.freezeBlockHeight ?? null,
|
||||
targetBlockHeight: derivation.targetBlockHeight ?? null,
|
||||
entropyDelayBlocks: derivation.entropyDelayBlocks ?? null,
|
||||
entropyConfirmations: derivation.entropyConfirmations ?? null,
|
||||
minimumConfirmedTipHeight: derivation.minimumConfirmedTipHeight ?? null,
|
||||
precommitTipHeight: derivation.precommitTipHeight ?? null,
|
||||
precommitVerifiedAt: derivation.precommitVerifiedAt ?? null,
|
||||
freezeCommitment: derivation.freezeCommitment || null,
|
||||
economy: derivation.economy || this.economy,
|
||||
rulesetId: derivation.rulesetId || this.ruleset.id,
|
||||
queueEntryIds: revealed ? (derivation.queueEntryIds || null) : null
|
||||
};
|
||||
}
|
||||
|
||||
_rulesetSummary() {
|
||||
return {
|
||||
id: this.ruleset.id,
|
||||
label: this.ruleset.label,
|
||||
mode: this.ruleset.mode,
|
||||
description: this.ruleset.metadata?.description || '',
|
||||
winCondition: this.ruleset.winCondition.type,
|
||||
pvpCombat: this.ruleset.entities.pvpCombat
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MatchRoom;
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* MatchState: serialization helpers for MatchRoom.
|
||||
*
|
||||
* Produces renderer-agnostic and broadcast-safe state objects. The render kit already
|
||||
* understands the `gameState` shape from `sceneModel.js`, so the primary job here is to
|
||||
* wrap a MatchRoom into that shape and add match-specific metadata.
|
||||
*/
|
||||
|
||||
class MatchState {
|
||||
/**
|
||||
* Build a spectator/client-friendly game-state object from a MatchRoom.
|
||||
* Compatible with `RK.sceneFromGameState()` in `html/js/render/sceneModel.js`.
|
||||
* @param {MatchRoom} room
|
||||
* @param {string} [viewerId] if provided, that player is marked `you`
|
||||
* @returns {object}
|
||||
*/
|
||||
static toGameState(room, viewerId = null) {
|
||||
if (!room) return null;
|
||||
return room.toGameState(viewerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shared-world snapshot used by Socket.IO broadcasts.
|
||||
* @param {MatchRoom} room
|
||||
* @returns {object}
|
||||
*/
|
||||
static snapshot(room) {
|
||||
if (!room) return null;
|
||||
return room.snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full state for a joining or reconnecting player.
|
||||
* @param {MatchRoom} room
|
||||
* @param {string} [viewerId]
|
||||
* @returns {object}
|
||||
*/
|
||||
static fullState(room, viewerId = null) {
|
||||
if (!room) return null;
|
||||
return room.fullState(viewerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a MatchRoom into the persistence shape for `match_events`.
|
||||
* @param {MatchRoom} room
|
||||
* @param {Array} events events from the last tick (or full match)
|
||||
* @returns {Array}
|
||||
*/
|
||||
static toEventRows(room, events = []) {
|
||||
return events.map(ev => ({
|
||||
match_id: room.id,
|
||||
tick: ev.tick ?? room.tickCount,
|
||||
type: ev.type,
|
||||
payload: ev
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a MatchRoom into the persistence shape for `match_entrants` after finalize().
|
||||
* @param {MatchRoom} room
|
||||
* @returns {Array}
|
||||
*/
|
||||
static toEntrantRows(room) {
|
||||
const rows = [];
|
||||
for (const [id, state] of room.playerStates.entries()) {
|
||||
rows.push({
|
||||
match_id: room.id,
|
||||
user_id: state.userId,
|
||||
socket_id: id,
|
||||
placement: state.placement,
|
||||
escaped: state.escaped,
|
||||
has_treasure: state.hasTreasure,
|
||||
killed_by: state.killedBy,
|
||||
score: state.score
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the persistence row for `matches` after the race ends.
|
||||
* @param {MatchRoom} room
|
||||
* @returns {object}
|
||||
*/
|
||||
static toMatchRow(room) {
|
||||
return {
|
||||
id: room.id,
|
||||
status: room.status,
|
||||
economy: room.economy,
|
||||
variant: room.variant,
|
||||
ruleset_id: room.ruleset?.id || 'race',
|
||||
difficulty_preset: room.difficultyPreset,
|
||||
max_players: room.maxPlayers,
|
||||
seed_hash: room.seedHash,
|
||||
seed: room.status === 'finished' ? room.seed : null,
|
||||
dungeon: room.seedDerivation
|
||||
? { ...room.dungeon, match_fairness: room.seedDerivation }
|
||||
: room.dungeon,
|
||||
start_block_height: room.startBlockHeight,
|
||||
started_at: room.startedAt ? new Date(room.startedAt) : null,
|
||||
ended_at: room.endedAt ? new Date(room.endedAt) : null,
|
||||
entry_fee_atomic: room.economy === 'crypto_race' ? room.entryFeeAtomic || 0 : 0,
|
||||
pot_atomic: room.economy === 'crypto_race' ? room.potAtomic || 0 : 0,
|
||||
house_fee_atomic: room.economy === 'crypto_race' ? room.houseFeeAtomic || 0 : 0,
|
||||
house_fee_percent: room.houseFeePercent || 0,
|
||||
winner_user_id: room.winnerId ? room.playerStates.get(room.winnerId)?.userId : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MatchState;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Occupant: a player's avatar inside a real-time Room (Tavern or Multiplayer match).
|
||||
*
|
||||
* The multiplayer analogue of game/player.js, but for a shared world: many Occupants
|
||||
* live in one Room and everyone sees everyone move. Kept small and dependency-free so the
|
||||
* engine can be unit-tested in isolation and reused independently.
|
||||
*/
|
||||
class Occupant {
|
||||
/**
|
||||
* @param {string} id Stable identity (socket id today; Smirk/wallet key later).
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.x]
|
||||
* @param {number} [opts.y]
|
||||
* @param {string} [opts.name] Display name (null = anonymous).
|
||||
* @param {string} [opts.avatar] Avatar id (cosmetic; unlock-gated by Operator Policy).
|
||||
* @param {object} [opts.appearance] Structured cosmetic appearance (base + tint/equipment).
|
||||
* @param {string} [opts.facing] 'up' | 'down' | 'left' | 'right'
|
||||
*/
|
||||
constructor(id, { x = 0, y = 0, name = null, avatar = 'default', appearance = null, facing = 'down' } = {}) {
|
||||
this.id = id;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.name = name;
|
||||
this.avatar = avatar;
|
||||
this.appearance = appearance;
|
||||
this.facing = facing;
|
||||
this.joinedAt = Date.now();
|
||||
this.lastMoveAt = 0;
|
||||
}
|
||||
|
||||
moveTo(x, y, facing = null) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
if (facing) this.facing = facing;
|
||||
this.lastMoveAt = Date.now();
|
||||
}
|
||||
|
||||
/** Broadcast-safe state (everything here is public to the whole room). */
|
||||
getState() {
|
||||
return {
|
||||
id: this.id,
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
name: this.name,
|
||||
avatar: this.avatar,
|
||||
appearance: this.appearance,
|
||||
facing: this.facing
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Occupant;
|
||||
@@ -0,0 +1,215 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const Occupant = require('./Occupant');
|
||||
const { TAVERN_LAYOUT, FLOOR_CHARS } = require('./tavernMap');
|
||||
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Room: a shared, real-time, multi-occupant world. Shared engine for both new modes:
|
||||
*
|
||||
* • Tavern (type 'tavern'): a peaceful room: walk around, chat, spectate.
|
||||
* • Multiplayer (type 'match'): the same engine with a dungeon, monsters, hazards and
|
||||
* combat added (later milestones).
|
||||
*
|
||||
* Design invariants (kept transport-agnostic so the engine is testable and reusable):
|
||||
* • No Socket.IO / DB / Wownerogue coupling here: game-state logic only. A manager layer
|
||||
* owns transport (Socket.IO rooms, broadcasts) and the server-tick timer.
|
||||
* • Server-authoritative movement: the client requests a step; the Room decides.
|
||||
* • Serialisable: snapshot()/fullState() are broadcast-safe (no engine internals).
|
||||
*/
|
||||
class Room {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.id] Room id (defaults to a uuid).
|
||||
* @param {string} [opts.type] 'tavern' | 'match' (informational for now).
|
||||
* @param {string[]} [opts.layout] Map rows (defaults to the tavern).
|
||||
* @param {boolean} [opts.solidOccupants] If true, occupants block each other's tiles
|
||||
* (matches). Tavern leaves this false so players
|
||||
* can't grief by blocking doorways.
|
||||
* @param {number} [opts.maxOccupants]
|
||||
*/
|
||||
constructor({ id = null, type = 'tavern', layout = TAVERN_LAYOUT, solidOccupants = false, maxOccupants = 50, roomData = null, roomUrl = null } = {}) {
|
||||
this.id = id || uuidv4();
|
||||
this.type = type;
|
||||
this.solidOccupants = solidOccupants;
|
||||
this.maxOccupants = maxOccupants;
|
||||
this.createdAt = Date.now();
|
||||
this.tickCount = 0;
|
||||
this.roomUrl = roomUrl; // client-relative URL of the designed room (if any)
|
||||
|
||||
// A designed room (imported .tmx) supplies dims + a walkability grid; otherwise build
|
||||
// the walkable map from the ASCII layout.
|
||||
if (roomData) this._buildFromRoomData(roomData);
|
||||
else this._buildMap(layout);
|
||||
this.occupants = new Map(); // id -> Occupant
|
||||
}
|
||||
|
||||
_buildFromRoomData(rd) {
|
||||
this.roomData = rd;
|
||||
this.layout = null;
|
||||
this.rows = rd.rows;
|
||||
this.cols = rd.cols;
|
||||
this.walkable = rd.walkable;
|
||||
// Spawn on the walkable cells nearest the room centre, so players start together.
|
||||
const cx = this.cols / 2, cy = this.rows / 2, cells = [];
|
||||
for (let y = 0; y < this.rows; y++) {
|
||||
for (let x = 0; x < this.cols; x++) {
|
||||
if (this.walkable[y] && this.walkable[y][x] === true) {
|
||||
cells.push({ x, y, d: (x - cx) * (x - cx) + (y - cy) * (y - cy) });
|
||||
}
|
||||
}
|
||||
}
|
||||
cells.sort((a, b) => a.d - b.d);
|
||||
this.spawns = cells.slice(0, 24).map(c => ({ x: c.x, y: c.y }));
|
||||
if (this.spawns.length === 0) this.spawns = [{ x: 1, y: 1 }];
|
||||
}
|
||||
|
||||
_buildMap(layout) {
|
||||
this.layout = layout;
|
||||
this.rows = layout.length;
|
||||
this.cols = layout.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
this.spawns = [];
|
||||
this.walkable = []; // walkable[y][x] === true means floor
|
||||
for (let y = 0; y < this.rows; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < this.cols; x++) {
|
||||
const ch = layout[y][x] || '#'; // treat short rows as walled
|
||||
row.push(FLOOR_CHARS.has(ch));
|
||||
if (ch === '@') this.spawns.push({ x, y });
|
||||
}
|
||||
this.walkable.push(row);
|
||||
}
|
||||
if (this.spawns.length === 0) {
|
||||
// Fall back to the first walkable tile.
|
||||
for (let y = 0; y < this.rows && this.spawns.length === 0; y++) {
|
||||
for (let x = 0; x < this.cols; x++) {
|
||||
if (this.walkable[y][x]) { this.spawns.push({ x, y }); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isWalkable(x, y) {
|
||||
if (y < 0 || y >= this.rows || x < 0 || x >= this.cols) return false;
|
||||
return this.walkable[y][x] === true;
|
||||
}
|
||||
|
||||
isOccupied(x, y, exceptId = null) {
|
||||
for (const occ of this.occupants.values()) {
|
||||
if (occ.id !== exceptId && occ.x === x && occ.y === y) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
_pickSpawn() {
|
||||
// Prefer an unoccupied spawn tile so joiners don't stack; else any spawn.
|
||||
for (const s of this.spawns) {
|
||||
if (!this.isOccupied(s.x, s.y)) return s;
|
||||
}
|
||||
return this.spawns[0] || { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Occupant|null} the occupant, or null if the room is full.
|
||||
*/
|
||||
addOccupant(id, { name = null, avatar = 'default', appearance = null } = {}) {
|
||||
if (this.occupants.has(id)) return this.occupants.get(id);
|
||||
if (this.occupants.size >= this.maxOccupants) return null;
|
||||
const spawn = this._pickSpawn();
|
||||
const occ = new Occupant(id, { x: spawn.x, y: spawn.y, name, avatar, appearance });
|
||||
this.occupants.set(id, occ);
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`[Room ${String(this.id).slice(0, 8)}] +occupant ${String(id).slice(0, 6)} at (${spawn.x},${spawn.y})`);
|
||||
}
|
||||
return occ;
|
||||
}
|
||||
|
||||
removeOccupant(id) {
|
||||
return this.occupants.delete(id);
|
||||
}
|
||||
|
||||
getOccupant(id) {
|
||||
return this.occupants.get(id) || null;
|
||||
}
|
||||
|
||||
static facingFor(dx, dy) {
|
||||
if (dx === -1) return 'left';
|
||||
if (dx === 1) return 'right';
|
||||
if (dy === -1) return 'up';
|
||||
if (dy === 1) return 'down';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt a one-tile cardinal move for an occupant. Server-authoritative: the client
|
||||
* only requests a direction; here we validate bounds, walls and (optionally) other
|
||||
* occupants, and mutate state only if the step is legal.
|
||||
* @returns {{moved:boolean, reason?:string, occupant?:object}}
|
||||
*/
|
||||
moveOccupant(id, dx, dy) {
|
||||
const occ = this.occupants.get(id);
|
||||
if (!occ) return { moved: false, reason: 'not_in_room' };
|
||||
|
||||
dx = Math.sign(dx || 0);
|
||||
dy = Math.sign(dy || 0);
|
||||
if (Math.abs(dx) + Math.abs(dy) !== 1) return { moved: false, reason: 'invalid_step' };
|
||||
|
||||
const nx = occ.x + dx;
|
||||
const ny = occ.y + dy;
|
||||
const facing = Room.facingFor(dx, dy);
|
||||
|
||||
// Turn to face the attempted direction even when blocked (feels responsive).
|
||||
if (!this.isWalkable(nx, ny)) {
|
||||
if (facing) occ.facing = facing;
|
||||
return { moved: false, reason: 'blocked', occupant: occ.getState() };
|
||||
}
|
||||
if (this.solidOccupants && this.isOccupied(nx, ny, id)) {
|
||||
if (facing) occ.facing = facing;
|
||||
return { moved: false, reason: 'occupied', occupant: occ.getState() };
|
||||
}
|
||||
|
||||
occ.moveTo(nx, ny, facing);
|
||||
return { moved: true, occupant: occ.getState() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the world one server tick. For a Tavern this is currently a heartbeat
|
||||
* (no autonomous entities yet). Multiplayer match rooms will drive monster movement,
|
||||
* environmental hazards and combat resolution here. Returns events for the manager
|
||||
* to broadcast. The hook is included from the start so those systems have a home.
|
||||
* @returns {{tick:number, events:Array}}
|
||||
*/
|
||||
tick() {
|
||||
this.tickCount++;
|
||||
return { tick: this.tickCount, events: [] };
|
||||
}
|
||||
|
||||
/** Occupants-only snapshot: broadcast every tick/update. */
|
||||
snapshot() {
|
||||
return {
|
||||
roomId: this.id,
|
||||
type: this.type,
|
||||
tick: this.tickCount,
|
||||
occupants: Array.from(this.occupants.values()).map(o => o.getState())
|
||||
};
|
||||
}
|
||||
|
||||
/** Full state including the static map: sent once when an occupant joins. */
|
||||
fullState() {
|
||||
return {
|
||||
...this.snapshot(),
|
||||
cols: this.cols,
|
||||
rows: this.rows,
|
||||
layout: this.layout,
|
||||
walkable: this.walkable,
|
||||
solidOccupants: this.solidOccupants,
|
||||
roomUrl: this.roomUrl || null
|
||||
};
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.occupants.size;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Room;
|
||||
@@ -0,0 +1,150 @@
|
||||
const COLOR_AVATARS = ['default', 'green', 'amber', 'red'];
|
||||
const PREMIUM_SKINS = ['monero-knight', 'wownero-rogue', 'cypher-operative'];
|
||||
const MODEL3D_AVATARS = ['kenney-survivor-male', 'kenney-survivor-female'];
|
||||
const CHAR_AVATARS = [
|
||||
'char-villager', 'char-elder', 'char-barbarian', 'char-monk', 'char-ranger',
|
||||
'char-bard', 'char-rogue', 'char-merchant', 'char-wizard', 'char-goblin'
|
||||
];
|
||||
|
||||
const CHAR_TINTS = ['none', 'rose', 'teal', 'moss', 'gold', 'violet', 'ash'];
|
||||
const CHAR_SKIN_TONES = ['natural', 'fair', 'warm', 'umber', 'olive', 'ash'];
|
||||
const CHAR_HAIR_COLORS = ['copper', 'brown', 'black', 'blond', 'silver', 'violet'];
|
||||
const CHAR_EQUIPMENT_SLOTS = ['body', 'head', 'shield', 'weapon'];
|
||||
const CHAR_EQUIPMENT = {
|
||||
body: ['none', 'robe', 'jerkin', 'mail', 'sash'],
|
||||
head: ['none', 'hood', 'helm', 'horns', 'cap'],
|
||||
shield: ['none', 'round', 'kite', 'tower', 'buckler'],
|
||||
weapon: ['none', 'staff', 'sword', 'axe', 'bow']
|
||||
};
|
||||
|
||||
const ALL_AVATARS = [...COLOR_AVATARS, ...PREMIUM_SKINS, ...CHAR_AVATARS, ...MODEL3D_AVATARS];
|
||||
const AVATAR_PACKS = Object.freeze({
|
||||
...PREMIUM_SKINS.reduce((out, id) => {
|
||||
out[id] = 'generated-skins';
|
||||
return out;
|
||||
}, {}),
|
||||
...MODEL3D_AVATARS.reduce((out, id) => {
|
||||
out[id] = 'kenney-3d-characters';
|
||||
return out;
|
||||
}, {})
|
||||
});
|
||||
const DEFAULT_EQUIPMENT = CHAR_EQUIPMENT_SLOTS.reduce((out, slot) => {
|
||||
out[slot] = 'none';
|
||||
return out;
|
||||
}, {});
|
||||
const DEFAULT_COLORS = Object.freeze({
|
||||
base: 'none',
|
||||
skin: 'natural',
|
||||
hair: 'copper',
|
||||
body: 'none',
|
||||
head: 'none',
|
||||
shield: 'none',
|
||||
weapon: 'none'
|
||||
});
|
||||
|
||||
function includes(list, id) {
|
||||
return list.indexOf(id) !== -1;
|
||||
}
|
||||
|
||||
function isValidAvatar(id) {
|
||||
return includes(ALL_AVATARS, id);
|
||||
}
|
||||
|
||||
function isCharAvatar(id) {
|
||||
return includes(CHAR_AVATARS, id);
|
||||
}
|
||||
|
||||
function isModel3dAvatar(id) {
|
||||
return includes(MODEL3D_AVATARS, id);
|
||||
}
|
||||
|
||||
function isPremiumAvatar(id) {
|
||||
return !!AVATAR_PACKS[id];
|
||||
}
|
||||
|
||||
function avatarPack(id) {
|
||||
return AVATAR_PACKS[id] || null;
|
||||
}
|
||||
|
||||
function normalizeEquipment(input = {}) {
|
||||
const out = {};
|
||||
for (const slot of CHAR_EQUIPMENT_SLOTS) {
|
||||
const id = input && typeof input[slot] === 'string' ? input[slot] : 'none';
|
||||
out[slot] = includes(CHAR_EQUIPMENT[slot], id) ? id : 'none';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function validTint(id, fallback = 'none') {
|
||||
return includes(CHAR_TINTS, id) ? id : fallback;
|
||||
}
|
||||
|
||||
function normalizeColors(input = {}, legacyTint = 'none') {
|
||||
const colors = input && typeof input === 'object' ? input : {};
|
||||
const tintFor = slot => validTint(
|
||||
Object.prototype.hasOwnProperty.call(colors, slot) ? colors[slot] : legacyTint
|
||||
);
|
||||
return {
|
||||
base: tintFor('base'),
|
||||
skin: includes(CHAR_SKIN_TONES, colors.skin) ? colors.skin : DEFAULT_COLORS.skin,
|
||||
hair: includes(CHAR_HAIR_COLORS, colors.hair) ? colors.hair : DEFAULT_COLORS.hair,
|
||||
body: tintFor('body'),
|
||||
head: tintFor('head'),
|
||||
shield: tintFor('shield'),
|
||||
weapon: tintFor('weapon')
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAppearance(input = {}) {
|
||||
if (input && input.appearance && !input.avatar) input = input.appearance;
|
||||
if (typeof input === 'string') input = { avatar: input };
|
||||
if (!input || typeof input !== 'object') input = {};
|
||||
|
||||
let avatar = input.avatar || input.id || 'default';
|
||||
if (!isValidAvatar(avatar)) avatar = 'default';
|
||||
|
||||
if (!isCharAvatar(avatar) && !isModel3dAvatar(avatar)) {
|
||||
return { avatar, tint: 'none', equipment: { ...DEFAULT_EQUIPMENT } };
|
||||
}
|
||||
|
||||
const colors = normalizeColors(input.colors, input.tint);
|
||||
if (isModel3dAvatar(avatar)) {
|
||||
return {
|
||||
avatar,
|
||||
tint: colors.base,
|
||||
equipment: { ...DEFAULT_EQUIPMENT },
|
||||
colors
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
avatar,
|
||||
tint: colors.base,
|
||||
equipment: normalizeEquipment(input.equipment),
|
||||
colors
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COLOR_AVATARS,
|
||||
PREMIUM_SKINS,
|
||||
MODEL3D_AVATARS,
|
||||
CHAR_AVATARS,
|
||||
CHAR_TINTS,
|
||||
CHAR_SKIN_TONES,
|
||||
CHAR_HAIR_COLORS,
|
||||
CHAR_EQUIPMENT,
|
||||
CHAR_EQUIPMENT_SLOTS,
|
||||
DEFAULT_EQUIPMENT,
|
||||
DEFAULT_COLORS,
|
||||
avatarIds: () => ALL_AVATARS.slice(),
|
||||
premiumAvatarIds: () => Object.keys(AVATAR_PACKS),
|
||||
avatarPack,
|
||||
isValidAvatar,
|
||||
isCharAvatar,
|
||||
isModel3dAvatar,
|
||||
isPremiumAvatar,
|
||||
normalizeEquipment,
|
||||
normalizeColors,
|
||||
normalizeAppearance
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
const Appearance = require('./appearance');
|
||||
|
||||
// Built-in default catalog. This is the FALLBACK used when the DB `cosmetic_catalog` table
|
||||
// (migration 024) isn't available (fresh/partly-migrated dev DBs, unit tests). In production the
|
||||
// operator-owned DB catalog is loaded by CatalogService and passed into snapshotForUser(); this
|
||||
// object just mirrors the four seed packs so nothing regresses without the table.
|
||||
//
|
||||
// Each entry: { id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }.
|
||||
// tier : 0 = free tier; ordered ladder (1,2,3) for premium subscriptions.
|
||||
// unlockMinCredits : lifetime-spend threshold to unlock, or null (not credit-unlockable).
|
||||
// grantOnly : only obtainable via an explicit grant/purchase.
|
||||
// premium : derived convenience flag, true unless the pack is free (no gate at all).
|
||||
// Cosmetic ladder. Unlock by LIFETIME CREDITS PURCHASED (cumulative: spend more, unlock more; NOT
|
||||
// deducted, so buying credits to play also walks you up the ladder) OR by subscription tier. Each
|
||||
// row is just data, a tilepack OR a char-skin, at any credit rung, so the operator adds more of
|
||||
// either by appending a row (+ assets + registerPack for a client pack). See docs/MONETIZATION.md.
|
||||
//
|
||||
// free original tiles (bare: no furniture, the "ugly tavern") + plain ASCII (in renderModes)
|
||||
// 1 Roguelike Interior (first premium tilepack)
|
||||
// 5 Character skins
|
||||
// 10 Iso technique (first iso pack) 20 another tilepack
|
||||
// 40 second iso pack 50 3D technique
|
||||
const DEFAULT_CATALOG = Object.freeze({
|
||||
// FREE pleb baseline: the game's original bare tiles. Furniture kinds fall back to floor, so
|
||||
// the free tavern is pointedly plain (just floor + walls + players/monsters). By design: it's
|
||||
// the reason to unlock the richer packs.
|
||||
'original': Object.freeze({
|
||||
id: 'original', label: 'Original Tiles', kind: 'render-pack',
|
||||
projection: 'topdown', tier: 0, unlockMinCredits: null, grantOnly: false, premium: false
|
||||
}),
|
||||
'roguelike-interior': Object.freeze({
|
||||
id: 'roguelike-interior', label: 'Kenney Roguelike Interior', kind: 'render-pack',
|
||||
projection: 'topdown', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
|
||||
}),
|
||||
'generated-skins': Object.freeze({
|
||||
id: 'generated-skins', label: 'Premium Character Skins', kind: 'render-pack',
|
||||
projection: 'topdown', tier: 1, unlockMinCredits: 5, grantOnly: false, premium: true
|
||||
}),
|
||||
'iso-dungeon': Object.freeze({
|
||||
id: 'iso-dungeon', label: 'Kenney Isometric Dungeon', kind: 'render-pack',
|
||||
projection: 'iso', tier: 2, unlockMinCredits: 10, grantOnly: false, premium: true
|
||||
}),
|
||||
'roguelike-dungeon': Object.freeze({
|
||||
id: 'roguelike-dungeon', label: 'Roguelike Dungeon (Tiles)', kind: 'render-pack',
|
||||
projection: 'topdown', tier: 2, unlockMinCredits: 20, grantOnly: false, premium: true
|
||||
}),
|
||||
'iso-medieval': Object.freeze({
|
||||
id: 'iso-medieval', label: 'Medieval Town (Isometric)', kind: 'render-pack',
|
||||
projection: 'iso', tier: 3, unlockMinCredits: 40, grantOnly: false, premium: true
|
||||
}),
|
||||
'kenney-3d-characters': Object.freeze({
|
||||
id: 'kenney-3d-characters', label: 'Kenney Animated 3D Characters', kind: 'render-pack',
|
||||
projection: '3d', tier: 3, unlockMinCredits: 50, grantOnly: false, premium: true
|
||||
})
|
||||
});
|
||||
|
||||
// Ordered premium-tier ladder. Buying credits does NOT put you on this ladder (that was the old
|
||||
// bug: any purchase → level 'credits' → every premium pack unlocked). 'credits' maps to tier 0.
|
||||
const TIER_OF = Object.freeze({ free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 });
|
||||
|
||||
// Backwards-compatible alias: productGrants.js and older callers validate pack ids against PACKS.
|
||||
const PACKS = DEFAULT_CATALOG;
|
||||
// Legacy: the set of premium level names (still referenced by a couple of callers).
|
||||
const PREMIUM_LEVELS = new Set(['credits', 'supporter', 'premium', 'operator']);
|
||||
|
||||
function asNumber(v) {
|
||||
const n = Number(v || 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function tierForLevel(level) {
|
||||
const t = TIER_OF[String(level || '').toLowerCase()];
|
||||
return t == null ? 0 : t;
|
||||
}
|
||||
|
||||
function isFreePack(pack) {
|
||||
return asNumber(pack.tier) === 0 && (pack.unlockMinCredits == null) && !pack.grantOnly;
|
||||
}
|
||||
|
||||
function normalizePackGrants(grants = [], catalog = DEFAULT_CATALOG) {
|
||||
const out = {};
|
||||
if (Array.isArray(grants)) {
|
||||
for (const g of grants) {
|
||||
const id = g && (g.pack_id || g.packId || g.id);
|
||||
if (id && catalog[id]) out[id] = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (grants && typeof grants === 'object') {
|
||||
for (const id of Object.keys(grants)) {
|
||||
if (catalog[id] && grants[id]) out[id] = true;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Only a REAL premium tier (supporter/premium/operator) counts as a level; buying credits does not.
|
||||
// The tier is the HIGHEST of the stored premium_level and any active-subscription tier: a live
|
||||
// wowne.ro premium sub sets subscription_tier (e.g. 'premium') and thereby unlocks the cosmetic
|
||||
// packs at/below that tier: one subscription drives both chat perks and tile/customization unlocks.
|
||||
// (The subscription_tier field is populated by the user loader from a subscription check; absent =
|
||||
// unchanged legacy behavior.)
|
||||
function levelForUser(user = {}) {
|
||||
const candidates = [user.premium_level, user.premiumLevel, user.subscription_tier, user.subscriptionTier]
|
||||
.map(v => String(v || '').trim().toLowerCase())
|
||||
.filter(l => tierForLevel(l) > 0);
|
||||
if (!candidates.length) return 'free';
|
||||
return candidates.reduce((a, b) => (tierForLevel(b) > tierForLevel(a) ? b : a));
|
||||
}
|
||||
|
||||
// A pack is unlocked for a user if ANY of: it's free, an explicit grant, the user's lifetime
|
||||
// spend crosses the pack's threshold, or the user's premium tier is >= the pack's tier.
|
||||
function snapshotForUser(user = {}, packGrants = [], catalog = DEFAULT_CATALOG) {
|
||||
catalog = catalog && typeof catalog === 'object' ? catalog : DEFAULT_CATALOG;
|
||||
const totalCreditsPurchased = asNumber(user.total_credits_purchased || user.totalCreditsPurchased);
|
||||
const credits = asNumber(user.credits);
|
||||
const level = levelForUser(user);
|
||||
const userTier = tierForLevel(level);
|
||||
const explicit = normalizePackGrants(packGrants, catalog);
|
||||
|
||||
const packs = {};
|
||||
let anyPremiumUnlocked = false;
|
||||
for (const id of Object.keys(catalog)) {
|
||||
const pack = catalog[id];
|
||||
const tier = asNumber(pack.tier);
|
||||
const minCredits = pack.unlockMinCredits;
|
||||
const free = isFreePack(pack);
|
||||
const unlocked = free
|
||||
|| !!explicit[id]
|
||||
|| (minCredits != null && totalCreditsPurchased >= asNumber(minCredits))
|
||||
|| (tier > 0 && userTier >= tier);
|
||||
packs[id] = unlocked;
|
||||
if (!free && unlocked) anyPremiumUnlocked = true;
|
||||
}
|
||||
|
||||
return {
|
||||
premium: userTier > 0 || anyPremiumUnlocked,
|
||||
level,
|
||||
tier: userTier,
|
||||
credits,
|
||||
totalCreditsPurchased,
|
||||
packs,
|
||||
catalog: catalogSummary(catalog)
|
||||
};
|
||||
}
|
||||
|
||||
// Display-facing catalog the client renders (labels/projection/gating): replaces the hardcoded
|
||||
// client RK.PACKS so the operator owns the catalog from one place (the DB).
|
||||
function catalogSummary(catalog = DEFAULT_CATALOG) {
|
||||
const out = [];
|
||||
for (const id of Object.keys(catalog)) {
|
||||
const p = catalog[id];
|
||||
out.push({
|
||||
id: p.id || id,
|
||||
label: p.label || id,
|
||||
kind: p.kind || 'render-pack',
|
||||
projection: p.projection || null,
|
||||
tier: asNumber(p.tier),
|
||||
premium: !isFreePack(p),
|
||||
unlockMinCredits: p.unlockMinCredits == null ? null : asNumber(p.unlockMinCredits)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Gating reads the user's computed snapshot (which already contains every catalog pack). Unknown
|
||||
// packs default to the built-in catalog's free-check (deny premium, allow free).
|
||||
function canUsePack(entitlements = {}, packId) {
|
||||
if (!packId) return true;
|
||||
// A full snapshot's packs map is authoritative (the three-way rule already ran).
|
||||
if (entitlements.packs && Object.prototype.hasOwnProperty.call(entitlements.packs, packId)) {
|
||||
return !!entitlements.packs[packId];
|
||||
}
|
||||
// No granular packs map (a coarse/legacy entitlement): free packs are always allowed; else
|
||||
// fall back to the coarse premium flag. Production entitlements always carry the packs map,
|
||||
// so this branch never weakens real gating.
|
||||
const def = DEFAULT_CATALOG[packId];
|
||||
if (def && isFreePack(def)) return true;
|
||||
return !!entitlements.premium;
|
||||
}
|
||||
|
||||
function canUseAppearance(entitlements = {}, appearanceOrAvatar = {}) {
|
||||
const appearance = Appearance.normalizeAppearance(appearanceOrAvatar);
|
||||
const packId = Appearance.avatarPack(appearance.avatar);
|
||||
return !packId || canUsePack(entitlements, packId);
|
||||
}
|
||||
|
||||
function normalizeAppearance(input = {}, entitlements = {}) {
|
||||
const appearance = Appearance.normalizeAppearance(input);
|
||||
if (!canUseAppearance(entitlements, appearance)) {
|
||||
return Appearance.normalizeAppearance('default');
|
||||
}
|
||||
return appearance;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CATALOG,
|
||||
PACKS,
|
||||
PREMIUM_LEVELS,
|
||||
TIER_OF,
|
||||
tierForLevel,
|
||||
snapshotForUser,
|
||||
catalogSummary,
|
||||
canUsePack,
|
||||
canUseAppearance,
|
||||
normalizeAppearance,
|
||||
normalizePackGrants
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Default Tavern layout: a hand-built room rendered through the theme atlas. Every row is the same
|
||||
* width (ragged ASCII maps are a classic off-by-one collision bug), and the legend below drives both
|
||||
* the client tiles (sceneModel TAVERN_LEGEND/TILE_OF) and Room walkability.
|
||||
*
|
||||
* Legend:
|
||||
* '#' wall 'W' window '=' bar counter 'k' keg (barrel) 'h' shelf of bottles
|
||||
* 'T' table 'c' chair 'B' barrel 'C' crate 'D' door
|
||||
* '.' floor 'r' rug '@' spawn
|
||||
*
|
||||
* Walkable = FLOOR_CHARS ('.', '@', 'r' rug, 'c' chair, 'D' door). Everything else is solid, so
|
||||
* players weave between the furniture. The bartender stands BEHIND the bar (the row between the top
|
||||
* wall and the counter).
|
||||
*/
|
||||
|
||||
// Characters an Occupant may stand on. Chairs are walkable so standing on one reads as "sitting".
|
||||
const FLOOR_CHARS = new Set(['.', '@', 'r', 'c', 'D']);
|
||||
|
||||
function buildDefaultTavern(cols = 28, rows = 15) {
|
||||
const grid = [];
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const row = new Array(cols);
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const border = x === 0 || y === 0 || x === cols - 1 || y === rows - 1;
|
||||
row[x] = border ? '#' : '.';
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
// set: overwrite anything (incl. walls, for windows/door). put: don't clobber walls.
|
||||
const set = (x, y, ch) => { if (grid[y] && x >= 0 && x < cols) grid[y][x] = ch; };
|
||||
const put = (x, y, ch) => { if (grid[y] && grid[y][x] !== undefined && grid[y][x] !== '#') grid[y][x] = ch; };
|
||||
|
||||
// Windows punched into the top wall and the two side walls.
|
||||
for (let x = 3; x < cols - 3; x += 4) set(x, 0, 'W');
|
||||
for (let y = 4; y < rows - 3; y += 4) { set(0, y, 'W'); set(cols - 1, y, 'W'); }
|
||||
|
||||
// The bar: a counter across the top interior (row 2), a shelf of bottles behind it (row 1),
|
||||
// and a keg (barrel) bracketing each end. Row 1 is the bartender's space, sealed off by the
|
||||
// counter; players can't get behind it.
|
||||
const barY = 2, barX0 = 3, barX1 = cols - 5;
|
||||
for (let x = barX0; x <= barX1; x++) set(x, barY, '=');
|
||||
for (let x = barX0; x <= barX1; x += 2) set(x, barY - 1, 'h'); // bottles behind the bar
|
||||
set(barX0 - 1, barY, 'k'); set(barX1 + 1, barY, 'k'); // kegs bracket the counter
|
||||
|
||||
// Tables (each with a chair below it), spread across the main floor with walkable aisles.
|
||||
const tables = [[5, 6], [12, 6], [19, 6], [5, 10], [12, 10], [19, 10]];
|
||||
for (const [tx, ty] of tables) { put(tx, ty, 'T'); put(tx, ty + 1, 'c'); }
|
||||
|
||||
// (No rug for now: the rug tile renders green, which reads wrong on the wood floor.)
|
||||
|
||||
// Corner decor: barrels and crates.
|
||||
put(1, 1, 'B'); put(cols - 2, 1, 'C'); put(1, rows - 2, 'C'); put(cols - 2, rows - 2, 'B');
|
||||
|
||||
// Fixtures lining the room so it reads lived-in instead of a big empty floor: barrels/crates
|
||||
// (material variety) plus fire fixtures: braziers ('i') throwing flickering light in the
|
||||
// corners and a hearth ('F') anchoring the left wall. All solid, all clear of windows/spawn/
|
||||
// tables/aisles. The renderers paint the animated flame + glow on the fire tiles.
|
||||
const wallDecor = [
|
||||
[1, 6, 'B'], [1, 10, 'C'],
|
||||
[cols - 2, 6, 'C'], [cols - 2, 10, 'B'],
|
||||
[2, rows - 2, 'B'], [cols - 3, rows - 2, 'C'],
|
||||
[barX0 - 1, barY + 1, 'B'], [barX1 + 1, barY + 1, 'C'], // a barrel/crate tucked beside each keg
|
||||
[1, 4, 'i'], [cols - 2, 4, 'i'], // braziers flanking the bar
|
||||
[1, rows - 3, 'i'], [cols - 2, rows - 3, 'i'], // braziers in the lower corners
|
||||
[1, 8, 'F'] // hearth on the left wall
|
||||
];
|
||||
for (const [dx, dy, dch] of wallDecor) put(dx, dy, dch);
|
||||
|
||||
// Door + spawn pad at the bottom-centre (two spawn tiles for deterministic adjacency tests).
|
||||
const mid = Math.floor(cols / 2);
|
||||
set(mid, rows - 1, 'D');
|
||||
put(mid, rows - 2, '@'); put(mid - 1, rows - 2, '@');
|
||||
|
||||
return grid.map(r => r.join(''));
|
||||
}
|
||||
|
||||
const TAVERN_LAYOUT = buildDefaultTavern();
|
||||
|
||||
module.exports = { TAVERN_LAYOUT, FLOOR_CHARS, buildDefaultTavern };
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* AddressManager
|
||||
* Handles detection, pending confirmation, validation, and persistence of payout addresses.
|
||||
* Keeps socketHandlers slimmer by encapsulating this concern.
|
||||
*/
|
||||
const { AppError, normalizeError } = require('../utils/errors');
|
||||
|
||||
// Candidate extraction accepts every Monero nettype; isValidAddress() then restricts XMR to the
|
||||
// configured network (mainnet 4/8, stagenet 5/7, testnet 9/B). Wallet RPC performs the final
|
||||
// checksum + nettype validation before persistence.
|
||||
const ADDRESS_REGEX = /((?:4|8|5|7|9|B)[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})/;
|
||||
const XMR_ADDRESS_REGEX = /^(?:4|8|5|7|9|B)[1-9A-HJ-NP-Za-km-z]{90,110}$/;
|
||||
const WOW_ADDRESS_REGEX = /^(?:(?:Wo|WO|ww|WW)[0-9A-Za-z]{88,112}|W[0-9A-Za-z]{90,112})$/;
|
||||
|
||||
class AddressManager {
|
||||
constructor({ gameModeManager, broadcastManager, io, debugManager, onConfirmed = null }) {
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this.pending = new Map(); // socketId -> address awaiting confirmation
|
||||
this.onConfirmed = onConfirmed; // optional callback(socketId, accepted, address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an unconfirmed address prompt for a departed socket. Required because confirm() is the
|
||||
* only other path that clears `pending`, and its keys are volatile socket ids: a player who
|
||||
* triggers detection then leaves without answering would leak an entry.
|
||||
*/
|
||||
forgetSocket(socketId) {
|
||||
this.pending.delete(socketId);
|
||||
}
|
||||
|
||||
detectInText(text) {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
const match = text.match(ADDRESS_REGEX);
|
||||
if (!match) return null;
|
||||
const candidate = match[1];
|
||||
return this.isValidAddress(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
isValidAddress(address) {
|
||||
const value = typeof address === 'string' ? address.trim() : '';
|
||||
if (!value) return false;
|
||||
// Upper bound accommodates integrated addresses.
|
||||
if (value.length < 80 || value.length > 120) return false;
|
||||
|
||||
const currency = String(this.gameModeManager?.cryptoType || '').toUpperCase();
|
||||
if (currency === 'XMR') {
|
||||
if (!XMR_ADDRESS_REGEX.test(value)) return false;
|
||||
const network = String(this.gameModeManager?.network || 'mainnet').toLowerCase();
|
||||
const prefixes = network === 'stagenet' ? new Set(['5', '7'])
|
||||
: (network === 'testnet' ? new Set(['9', 'B']) : new Set(['4', '8']));
|
||||
return prefixes.has(value[0]);
|
||||
}
|
||||
if (currency === 'WOW') return WOW_ADDRESS_REGEX.test(value);
|
||||
return ADDRESS_REGEX.test(value);
|
||||
}
|
||||
|
||||
async _validateWithWallet(address) {
|
||||
if (!this.gameModeManager || typeof this.gameModeManager.validatePayoutAddress !== 'function') {
|
||||
return true; // injected managers without wallet access are syntax-checked only
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await this.gameModeManager.validatePayoutAddress(address);
|
||||
} catch (error) {
|
||||
throw new AppError('Payout address validation failed', {
|
||||
safeMessage: 'Could not verify that address with the configured wallet network. Try again.'
|
||||
});
|
||||
}
|
||||
if (!result || result.valid !== true) {
|
||||
throw new AppError('Payout address is invalid for the configured wallet network', {
|
||||
safeMessage: 'That address is not valid for this server\'s configured network.'
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleDetection(socketId, address) {
|
||||
try {
|
||||
if (this.gameModeManager) {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socketId);
|
||||
if (userRow && userRow.payout_address === address) {
|
||||
const shortAddr = address.slice(0, 10) + '…' + address.slice(-6);
|
||||
this.io.to(socketId).emit('address_confirmed', { address, message: `Payout address already set to ${shortAddr}. You can update it via the address manager.` });
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address already on file. Use the address button/command to replace it.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.warn('Address detection user lookup failed:', e.message);
|
||||
}
|
||||
this.pending.set(socketId, address);
|
||||
const shortAddr = address.slice(0, 10) + '…' + address.slice(-6);
|
||||
this.io.to(socketId).emit('address_detected', { address: shortAddr, fullAddress: address, message: `Detected payout address: ${shortAddr}\nType 'confirm' to save or 'cancel' to discard.` });
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address detected. Type confirm or cancel.');
|
||||
}
|
||||
|
||||
async saveAddress(socketId, address, { autoConfirm = true } = {}) {
|
||||
const trimmed = typeof address === 'string' ? address.trim() : '';
|
||||
if (!trimmed) {
|
||||
throw new AppError('No payout address supplied', {
|
||||
safeMessage: 'Please enter a payout address.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.isValidAddress(trimmed)) {
|
||||
throw new AppError('Invalid payout address format', {
|
||||
safeMessage: 'That does not look like a valid XMR/WOW address.'
|
||||
});
|
||||
}
|
||||
|
||||
this.pending.set(socketId, trimmed);
|
||||
|
||||
if (autoConfirm) {
|
||||
const saved = await this.confirm(socketId, true);
|
||||
if (!saved) return false;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async confirm(socketId, accept) {
|
||||
const pending = this.pending.get(socketId);
|
||||
if (!pending) {
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'warning', 'No address pending. Paste an address first.');
|
||||
return false;
|
||||
}
|
||||
if (!accept) {
|
||||
this.pending.delete(socketId);
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'info', 'Address entry cancelled.');
|
||||
this.io.to(socketId).emit('address_confirmed', { cancelled: true, message: 'Address entry cancelled.' });
|
||||
if (this.onConfirmed) {
|
||||
try { this.onConfirmed(socketId, false, pending); } catch(_) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (this.gameModeManager) {
|
||||
try {
|
||||
await this._validateWithWallet(pending);
|
||||
const saved = await this.gameModeManager.setUserPayoutAddress(socketId, pending);
|
||||
if (saved !== true) throw new Error('Payout address was not persisted');
|
||||
this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address saved.' });
|
||||
} catch (e) {
|
||||
const normalized = normalizeError?.(e, 'Failed to save address') || e;
|
||||
this.broadcastManager?.sendStatusUpdate(socketId, 'error', normalized.safeMessage || 'Failed to save address. Try again.');
|
||||
this.io.to(socketId).emit('address_update_error', {
|
||||
message: normalized.safeMessage || 'Failed to save address. Try again.'
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.pending.delete(socketId);
|
||||
}
|
||||
} else {
|
||||
this.pending.delete(socketId);
|
||||
this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address accepted (session only).' });
|
||||
}
|
||||
if (this.onConfirmed) {
|
||||
try { this.onConfirmed(socketId, true, pending); } catch(_) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
requiresPayoutAddress() {
|
||||
if (!this.gameModeManager) return false; // free mode has no payout address requirement
|
||||
const mode = this.gameModeManager.gameMode;
|
||||
if (typeof this.gameModeManager.requiresPayoutAddressForMode === 'function') {
|
||||
return this.gameModeManager.requiresPayoutAddressForMode(mode);
|
||||
}
|
||||
return (mode === 'PAID_SINGLE') || (mode === 'PAID_CREDITS' && this.gameModeManager.creditsPayoutEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AddressManager;
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Broadcast Manager Module
|
||||
* Handles all socket.io event broadcasting for the Wownerogue game server:
|
||||
* global emits to every client and targeted emits to a single player socket.
|
||||
*/
|
||||
|
||||
class BroadcastManager {
|
||||
constructor(io, debugManager = null) {
|
||||
this.io = io;
|
||||
this.debugManager = debugManager;
|
||||
this._lastBroadcastBlock = null;
|
||||
this._lastUserCount = -1;
|
||||
}
|
||||
|
||||
setDebugManager(debugManager) {
|
||||
this.debugManager = debugManager;
|
||||
}
|
||||
|
||||
// ====== GLOBAL BROADCASTS (All Clients) ======
|
||||
|
||||
/**
|
||||
* Broadcast connected user count to all clients
|
||||
*/
|
||||
broadcastUserCount() {
|
||||
// io.sockets.sockets is the authoritative set; io.engine.clientsCount is the fallback for
|
||||
// adapters that do not expose it.
|
||||
let count = 0;
|
||||
if (this.io.sockets && this.io.sockets.sockets) {
|
||||
count = this.io.sockets.sockets.size || 0;
|
||||
} else if (this.io.engine) {
|
||||
count = this.io.engine.clientsCount || 0;
|
||||
}
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`👥 Broadcasting user count: ${count} (last was ${this._lastUserCount})`);
|
||||
}
|
||||
|
||||
// Emitted unconditionally, including on an unchanged count, so clients that just connected
|
||||
// receive a value.
|
||||
this._lastUserCount = count;
|
||||
this.io.emit('user_count', { count });
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast block height to all connected clients
|
||||
* @param {number} blockHeight - Current block height
|
||||
* @param {{hash?: string, difficulty?: number, txPoolSize?: number}|null} [tip] - Cosmetic chain
|
||||
* tip for ambient UI. Public chain data only; omitted entirely when unavailable so clients
|
||||
* keep whatever they last had rather than flickering to a blank readout.
|
||||
*/
|
||||
broadcastBlockHeight(blockHeight, tip) {
|
||||
// Only log when the height actually changes to avoid per-client spam
|
||||
if (blockHeight !== this._lastBroadcastBlock) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📡 Broadcasting block height ${blockHeight} to all clients`);
|
||||
}
|
||||
this._lastBroadcastBlock = blockHeight;
|
||||
}
|
||||
this.io.emit('blockheight', this.blockHeightPayload(blockHeight, tip));
|
||||
}
|
||||
|
||||
/** Shared shape for the `blockheight` event so the per-socket emit on connect matches. */
|
||||
blockHeightPayload(blockHeight, tip) {
|
||||
const payload = { blockHeight };
|
||||
if (tip && tip.hash) payload.hash = tip.hash;
|
||||
if (tip && Number.isFinite(tip.difficulty)) payload.difficulty = tip.difficulty;
|
||||
if (tip && Number.isFinite(tip.txPoolSize)) payload.txPoolSize = tip.txPoolSize;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast chat message to all connected clients
|
||||
* @param {string} username - Username of the sender
|
||||
* @param {string} message - Chat message content
|
||||
* @param {number} timestamp - Message timestamp
|
||||
* @param {string} publicId - Short, non-sensitive public id of the sender. Never the full
|
||||
* socket.id, which must not leave the server.
|
||||
*/
|
||||
broadcastChatMessage(username, message, timestamp, publicId) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`💬 Broadcasting chat message from ${publicId}: "${message}"`);
|
||||
}
|
||||
|
||||
this.io.emit('chat_broadcast', {
|
||||
username: username,
|
||||
message: message,
|
||||
timestamp: timestamp,
|
||||
publicId: publicId
|
||||
});
|
||||
}
|
||||
|
||||
// ====== PLAYER-SPECIFIC BROADCASTS ======
|
||||
|
||||
/**
|
||||
* Send status update to a specific player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {string} type - Type of status (info, error, warning, connection, help, etc.)
|
||||
* @param {string} message - The status message
|
||||
*/
|
||||
sendStatusUpdate(socketId, type, message) {
|
||||
this.io.to(socketId).emit('status_update', {
|
||||
type: type,
|
||||
message: message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome message to a newly connected client
|
||||
* @param {string} clientId - The client ID
|
||||
*/
|
||||
sendWelcome(clientId) {
|
||||
this.io.to(clientId).emit('welcome', clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send game state update to the player's socket
|
||||
* @param {string} playerSocketId - The socket ID of the player
|
||||
* @param {object} gameState - The game state to broadcast
|
||||
*/
|
||||
sendGameUpdate(playerSocketId, gameState) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📡 sendGameUpdate to ${playerSocketId}:`);
|
||||
console.log(` - Lighting data: ${!!gameState.lighting} (${gameState.lighting ? Object.keys(gameState.lighting).length : 0} rows)`);
|
||||
console.log(` - Torch data: ${!!gameState.torches} (${gameState.torches ? gameState.torches.length : 0} torches)`);
|
||||
}
|
||||
|
||||
this.io.to(playerSocketId).emit('game_update', gameState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send game start event to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {object} gameState - Initial game state
|
||||
*/
|
||||
sendGameStart(socketId, gameState) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`🎮 SENDING GAME_START to ${socketId}`);
|
||||
}
|
||||
this.io.to(socketId).emit('game_start', gameState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send game over event to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {object} gameOverData - Game over information
|
||||
*/
|
||||
sendGameOver(socketId, gameOverData) {
|
||||
this.io.to(socketId).emit('game_over', gameOverData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send waiting status to player (for queue system)
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {object} waitingData - Waiting status information
|
||||
*/
|
||||
sendWaitingStatus(socketId, waitingData) {
|
||||
this.io.to(socketId).emit('waiting_status', waitingData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send queue cancelled event to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
*/
|
||||
sendQueueCancelled(socketId) {
|
||||
this.io.to(socketId).emit('queue_cancelled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send simple message to player
|
||||
* @param {string} socketId - The socket ID of the player
|
||||
* @param {string} message - The message content
|
||||
*/
|
||||
sendMessage(socketId, message) {
|
||||
this.io.to(socketId).emit('message', message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send socket registration confirmation
|
||||
* @param {string} socketId - The socket ID
|
||||
* @param {object} registrationData - Registration confirmation data
|
||||
*/
|
||||
sendSocketRegistered(socketId, registrationData) {
|
||||
this.io.to(socketId).emit('socket_registered', registrationData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send debug pong response
|
||||
* @param {string} socketId - The socket ID
|
||||
* @param {object} pongData - Debug pong data
|
||||
*/
|
||||
sendDebugPong(socketId, pongData) {
|
||||
this.io.to(socketId).emit('debug_pong', pongData);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = BroadcastManager;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* ChatProvider: abstraction over chat message delivery and history.
|
||||
*
|
||||
* The seam that lets the chat backend be swapped (e.g. for a Nostr channel) without changing
|
||||
* callers. Application concerns (commands, moderation, rate limiting, XSS escaping) stay in
|
||||
* the caller (ChatHandler / TavernManager); a provider only delivers and (optionally) stores.
|
||||
*
|
||||
* Scopes:
|
||||
* 'global' → the whole server (the main lobby chat).
|
||||
* 'tavern:<id>' → a single Socket.IO room (tavern chat), ephemeral by default.
|
||||
*/
|
||||
class ChatProvider {
|
||||
// eslint-disable-next-line no-empty-function
|
||||
async initialize() {}
|
||||
|
||||
/**
|
||||
* Deliver (and optionally persist) a chat message.
|
||||
* @param {object} msg
|
||||
* @param {string} msg.scope 'global' or a room channel.
|
||||
* @param {string} msg.username Display name / short id.
|
||||
* @param {string} msg.text Message text (caller has already sanitized/escaped it).
|
||||
* @param {number} [msg.ts] Timestamp (ms).
|
||||
* @param {string} [msg.socketId]
|
||||
* @param {number|null} [msg.userId]
|
||||
*/
|
||||
async publish(msg) { // eslint-disable-line no-unused-vars
|
||||
throw new Error('ChatProvider.publish not implemented');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent messages for a scope, oldest → newest.
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.scope]
|
||||
* @param {number} [opts.limit]
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async getHistory({ scope = 'global', limit = 50 } = {}) { // eslint-disable-line no-unused-vars
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a message whose nostr event was signed by the CLIENT (per-player identity, Phase 2).
|
||||
* The base/local provider has no relay, so it just delivers the message in-game: the signed
|
||||
* `event` is ignored. NostrChatProvider overrides this to ALSO publish the pre-signed event.
|
||||
* @param {object} req { event, scope, username, text, ts, socketId, userId }
|
||||
*/
|
||||
async relaySignedEvent({ event, ...msg } = {}) { // eslint-disable-line no-unused-vars
|
||||
return this.publish({ scope: 'global', ...msg });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty-function
|
||||
async shutdown() {}
|
||||
}
|
||||
|
||||
module.exports = ChatProvider;
|
||||
@@ -0,0 +1,155 @@
|
||||
const ChatProvider = require('./ChatProvider');
|
||||
const { escapeChatText } = require('../../utils/escapeChat');
|
||||
|
||||
/**
|
||||
* NostrChatProvider: global cross-server chat over nostr, layered on the existing ChatProvider seam.
|
||||
*
|
||||
* It DECORATES a local provider (SocketChatProvider): every message is still delivered in-game +
|
||||
* persisted exactly as before, and on top of that a global message is fanned out to nostr relays
|
||||
* (relay.smirk.cash) so other game servers/modes see it. Messages arriving from the relay are
|
||||
* delivered locally-only (never re-published), so there are no echo loops.
|
||||
*
|
||||
* publish(local msg) → local.publish (in-game + history) + sign & relay.publish (global fan-out)
|
||||
* relay event (remote)→ local.publish only (in-game + history), text/username escaped
|
||||
*
|
||||
* Topology: client → server → relay. The server stays the moderation authority (bans, rate-limit,
|
||||
* history in Postgres via the local provider); the relay is transport + fan-out. Signing with a
|
||||
* registered Smirk npub (the bridge key, or later each player's own) is what the relay's
|
||||
* inbox-outbox policy requires to publish, so global chat is a concrete reason to sign in with Smirk.
|
||||
*
|
||||
* Behavior-preserving: with no transport/signer (nostr disabled) this is exactly the local provider.
|
||||
*/
|
||||
class NostrChatProvider extends ChatProvider {
|
||||
constructor({
|
||||
local,
|
||||
transport = null,
|
||||
signer = null,
|
||||
scope = 'global',
|
||||
channelTag = 'wowngeon-global',
|
||||
kind = 1,
|
||||
serverOrigin = null,
|
||||
debugManager = null,
|
||||
maxRemotePerMin = 120,
|
||||
now = () => Date.now()
|
||||
} = {}) {
|
||||
super();
|
||||
if (!local) throw new Error('NostrChatProvider requires a local provider');
|
||||
this.local = local;
|
||||
this.transport = transport;
|
||||
this.signer = signer;
|
||||
this.scope = scope;
|
||||
this.channelTag = channelTag;
|
||||
this.kind = kind;
|
||||
this.serverOrigin = serverOrigin;
|
||||
this.debugManager = debugManager;
|
||||
this.maxRemotePerMin = maxRemotePerMin;
|
||||
this.now = now;
|
||||
|
||||
this._seen = new Set(); // event ids we've delivered/published, for echo dedupe
|
||||
this._seenOrder = []; // bounded FIFO of the same ids
|
||||
this._remoteTimes = []; // recent remote-delivery timestamps (rate limit)
|
||||
}
|
||||
|
||||
_globalEnabled() {
|
||||
return this.scope === 'global' && !!this.transport;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.local.initialize();
|
||||
if (!this._globalEnabled()) return;
|
||||
const since = Math.floor(this.now() / 1000);
|
||||
// Only future messages: no history flood on connect (local history covers the past).
|
||||
this.transport.subscribe([{ kinds: [this.kind], '#t': [this.channelTag], since }], (ev) => this._onRemote(ev));
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[nostr] chat subscribed: tag=${this.channelTag} kind=${this.kind} publish=${this.signer ? 'on' : 'read-only'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(msg = {}) {
|
||||
// 1) Always deliver + persist locally, exactly as before.
|
||||
await this.local.publish(msg);
|
||||
|
||||
// 2) Fan out global messages to nostr (skip room-scoped and skip messages we received
|
||||
// from the relay, which arrive here only via local delivery, never through publish).
|
||||
if (msg.remote || msg.scope !== 'global' || !this._globalEnabled() || !this.signer) return;
|
||||
try {
|
||||
const template = {
|
||||
kind: this.kind,
|
||||
created_at: Math.floor((msg.ts || this.now()) / 1000),
|
||||
tags: [['t', this.channelTag], ['n', String(msg.username || '').slice(0, 32)]],
|
||||
content: String(msg.text == null ? '' : msg.text)
|
||||
};
|
||||
if (this.serverOrigin) template.tags.push(['origin', String(this.serverOrigin)]);
|
||||
const signed = this.signer.sign(template);
|
||||
if (signed && signed.id) this._markSeen(signed.id); // our own echo will be ignored
|
||||
await this.transport.publish(signed);
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] fan-out failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// A message arriving from the relay: deliver locally only (no re-publish → no loop).
|
||||
_onRemote(event) {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
if (typeof event.content !== 'string' || !Array.isArray(event.tags)) return;
|
||||
// Ours (echo) or already seen → drop.
|
||||
if (this.signer && event.pubkey === this.signer.pubkey) return;
|
||||
if (event.id && !this._markSeen(event.id)) return;
|
||||
// Rate-limit remote delivery so a hostile relay can't flood in-game clients.
|
||||
if (!this._allowRemote()) return;
|
||||
|
||||
const nTag = event.tags.find(t => Array.isArray(t) && t[0] === 'n');
|
||||
const rawName = (nTag && nTag[1]) || (typeof event.pubkey === 'string' ? event.pubkey.slice(0, 8) : 'nostr');
|
||||
const username = escapeChatText(rawName, 32) || 'nostr';
|
||||
const text = escapeChatText(event.content, 200);
|
||||
const ts = Number.isFinite(event.created_at) ? event.created_at * 1000 : this.now();
|
||||
|
||||
// remote:true prevents re-fan-out; local.publish delivers to in-game clients + persists.
|
||||
this.local.publish({ scope: 'global', username, text, ts, userId: null, remote: true })
|
||||
.catch(err => console.error('[nostr] local deliver of remote failed:', err.message));
|
||||
}
|
||||
|
||||
_allowRemote() {
|
||||
const cutoff = this.now() - 60000;
|
||||
this._remoteTimes = this._remoteTimes.filter(t => t >= cutoff);
|
||||
if (this._remoteTimes.length >= this.maxRemotePerMin) return false;
|
||||
this._remoteTimes.push(this.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if the id is new (and records it); false if already seen. Bounded FIFO.
|
||||
_markSeen(id) {
|
||||
if (this._seen.has(id)) return false;
|
||||
this._seen.add(id);
|
||||
this._seenOrder.push(id);
|
||||
if (this._seenOrder.length > 1000) {
|
||||
const old = this._seenOrder.shift();
|
||||
this._seen.delete(old);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Relay a CLIENT-signed event (per-player identity, Phase 2). Delivers the message in-game
|
||||
// (escaped text, exactly like a normal global message) and publishes the pre-signed event to
|
||||
// the relays WITHOUT re-signing, so it carries the player's OWN npub, not the bridge. The
|
||||
// signed id is marked seen so the relay round-trip doesn't re-deliver it.
|
||||
async relaySignedEvent({ event, username, text, ts, socketId, userId } = {}) {
|
||||
await this.local.publish({ scope: 'global', username, text, ts, socketId, userId });
|
||||
if (this._globalEnabled() && event && event.id) {
|
||||
this._markSeen(event.id);
|
||||
try { await this.transport.publish(event); }
|
||||
catch (err) { if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] signed relay failed:', err.message); }
|
||||
}
|
||||
}
|
||||
|
||||
async getHistory(opts) {
|
||||
return this.local.getHistory(opts);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
try { if (this.transport) this.transport.close(); } catch (_) {}
|
||||
await this.local.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NostrChatProvider;
|
||||
@@ -0,0 +1,65 @@
|
||||
const ChatProvider = require('./ChatProvider');
|
||||
|
||||
/**
|
||||
* SocketChatProvider: the default ChatProvider, backed by Socket.IO for delivery and (for
|
||||
* global chat) the Postgres-backed ChatHistoryManager for persistence.
|
||||
*
|
||||
* Behaviour matches the pre-seam chat exactly:
|
||||
* • global scope → persist via the history manager + broadcast to everyone
|
||||
* (broadcastManager.broadcastChatMessage, i.e. io.emit 'chat_broadcast').
|
||||
* • room scope → deliver only to that Socket.IO room, ephemeral (no history).
|
||||
*
|
||||
* A history manager may be injected (so ChatHandler shares its existing instance) or omitted
|
||||
* (tavern chat, which is ephemeral). When omitted, this provider persists nothing.
|
||||
*/
|
||||
class SocketChatProvider extends ChatProvider {
|
||||
constructor({ io, broadcastManager = null, debugManager = null, historyManager = null } = {}) {
|
||||
super();
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.history = historyManager; // null → ephemeral (no persistence)
|
||||
this._ownsHistory = false; // true only if this provider created the manager
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this._ownsHistory && this.history) await this.history.initialize();
|
||||
}
|
||||
|
||||
async publish(msg = {}) {
|
||||
const { scope = 'global', username, text, ts = Date.now(), socketId = null, userId = null } = msg;
|
||||
|
||||
// Defense in depth (S1): never broadcast the raw full socket.id to other clients; it is
|
||||
// a hijackable handle. Derive a short, non-sensitive public id for display/attribution.
|
||||
const publicId = userId != null ? String(userId) : (socketId ? String(socketId).substring(0, 6) : null);
|
||||
|
||||
if (scope === 'global') {
|
||||
if (this.history) {
|
||||
// Fire-and-forget persistence, exactly as before (must not block delivery).
|
||||
this.history.saveMessage({ socketId, username, message: text, type: 'chat', userId })
|
||||
.catch(err => console.error('Failed to save chat message:', err.message));
|
||||
}
|
||||
if (this.broadcastManager) {
|
||||
this.broadcastManager.broadcastChatMessage(username, text, ts, publicId);
|
||||
} else {
|
||||
this.io.emit('chat_broadcast', { username, message: text, timestamp: ts, publicId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Room-scoped (e.g. tavern): delivered only to occupants of that room. The `scope`
|
||||
// field lets a room client filter these from any global broadcasts it also receives.
|
||||
this.io.to(scope).emit('chat_broadcast', { username, message: text, timestamp: ts, publicId, scope });
|
||||
}
|
||||
|
||||
async getHistory({ scope = 'global', limit = 50 } = {}) {
|
||||
if (scope === 'global' && this.history) return this.history.getRecentMessages(limit);
|
||||
return [];
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
if (this._ownsHistory && this.history) this.history.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SocketChatProvider;
|
||||
@@ -0,0 +1,46 @@
|
||||
const NostrChatProvider = require('./NostrChatProvider');
|
||||
const NostrTransport = require('./nostr/NostrTransport');
|
||||
const { createBridgeSigner } = require('./nostr/bridgeSigner');
|
||||
|
||||
function parseBool(v) {
|
||||
if (v == null) return false;
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(v).trim().toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chat provider for the given local (in-game) provider. Single opt-in seam for Pillar 5.
|
||||
*
|
||||
* Behavior-preserving: with NOSTR_CHAT_ENABLED unset (today) the plain local provider is returned
|
||||
* unchanged. Enable to layer global cross-server chat over nostr:
|
||||
* NOSTR_CHAT_ENABLED=true
|
||||
* NOSTR_CHAT_SCOPE=global|local (local == plain local chat; global fans out over relays)
|
||||
* NOSTR_RELAYS=wss://relay.smirk.cash[,...]
|
||||
* NOSTR_CHAT_TAG=wowngeon-global (the shared channel topic)
|
||||
* NOSTR_CHAT_KIND=1
|
||||
* NOSTR_BRIDGE_SK=<nsec|hex> (server bridge key; must be a registered Smirk npub to publish.
|
||||
* Omit → receive-only global chat.)
|
||||
*/
|
||||
function buildChatProvider({ local, env = process.env, debugManager = null } = {}) {
|
||||
if (!local) throw new Error('buildChatProvider requires a local provider');
|
||||
if (!parseBool(env.NOSTR_CHAT_ENABLED)) return local;
|
||||
|
||||
const scope = (env.NOSTR_CHAT_SCOPE || 'global').toLowerCase();
|
||||
if (scope !== 'global') return local; // local-only is exactly the plain provider
|
||||
|
||||
const relays = (env.NOSTR_RELAYS || 'wss://relay.smirk.cash').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const transport = new NostrTransport({ relays, debugManager });
|
||||
const signer = createBridgeSigner(env.NOSTR_BRIDGE_SK);
|
||||
|
||||
return new NostrChatProvider({
|
||||
local,
|
||||
transport,
|
||||
signer,
|
||||
scope: 'global',
|
||||
channelTag: env.NOSTR_CHAT_TAG || 'wowngeon-global',
|
||||
kind: parseInt(env.NOSTR_CHAT_KIND || '1', 10) || 1,
|
||||
serverOrigin: env.NOSTR_SERVER_ORIGIN || env.SERVER_ID || null,
|
||||
debugManager
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { buildChatProvider, NostrChatProvider, NostrTransport };
|
||||
@@ -0,0 +1,65 @@
|
||||
const { loadNostrTools } = require('../../../utils/nostrLoader');
|
||||
|
||||
/**
|
||||
* Thin transport over nostr-tools SimplePool: connect to relays, publish signed events, subscribe
|
||||
* to filters. Isolated behind this class so NostrChatProvider can be unit-tested with a fake
|
||||
* transport (no network). Degrades to a warn-and-noop if nostr-tools/ws can't load, so enabling
|
||||
* chat can never crash the server.
|
||||
*
|
||||
* Reading (REQ/subscribe) is open on relay.smirk.cash; only publishing is policy-gated.
|
||||
*/
|
||||
class NostrTransport {
|
||||
constructor({ relays = ['wss://relay.smirk.cash'], debugManager = null } = {}) {
|
||||
this.relays = Array.isArray(relays) ? relays.filter(Boolean) : [];
|
||||
this.debugManager = debugManager;
|
||||
this.pool = null;
|
||||
this._sub = null;
|
||||
this._ok = false;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.pool || !this.relays.length) return this._ok;
|
||||
try {
|
||||
const tools = loadNostrTools();
|
||||
if (typeof tools.useWebSocketImplementation === 'function') {
|
||||
try { tools.useWebSocketImplementation(require('ws')); } catch (_) { /* browser/global WS */ }
|
||||
}
|
||||
this.pool = new tools.SimplePool();
|
||||
this._ok = true;
|
||||
} catch (err) {
|
||||
console.error('[nostr] transport disabled (nostr-tools/ws unavailable):', err.message);
|
||||
this._ok = false;
|
||||
}
|
||||
return this._ok;
|
||||
}
|
||||
|
||||
async publish(signedEvent) {
|
||||
if (!this.connect()) return;
|
||||
try {
|
||||
await Promise.any(this.pool.publish(this.relays, signedEvent));
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.error('[nostr] publish failed:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(filters, onEvent) {
|
||||
if (!this.connect()) return;
|
||||
try {
|
||||
this._sub = this.pool.subscribeMany(this.relays, filters, {
|
||||
onevent: (ev) => { try { onEvent(ev); } catch (e) { console.error('[nostr] onEvent error:', e.message); } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[nostr] subscribe failed:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
try { if (this._sub && typeof this._sub.close === 'function') this._sub.close(); } catch (_) {}
|
||||
try { if (this.pool && typeof this.pool.close === 'function') this.pool.close(this.relays); } catch (_) {}
|
||||
this._sub = null;
|
||||
this.pool = null;
|
||||
this._ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NostrTransport;
|
||||
@@ -0,0 +1,61 @@
|
||||
const { loadNostrTools } = require('../../../utils/nostrLoader');
|
||||
|
||||
/**
|
||||
* A signer produces signed nostr events for outbound chat. Contract:
|
||||
* { pubkey: <64-hex x-only>, sign(template) -> signedEvent } (template: {kind, created_at, tags, content})
|
||||
*
|
||||
* createBridgeSigner builds a SERVER bridge signer from a secret key (hex or nsec). This is the
|
||||
* drop-in model: the game server signs cross-server chat with one key. For global chat to be
|
||||
* ACCEPTED by relay.smirk.cash (default `inbox-outbox` policy → only registered npubs may publish),
|
||||
* this bridge pubkey must be registered as a Smirk npub (an ops step, like the BTCPay API key).
|
||||
* A future upgrade signs per-message with each player's OWN Smirk npub (window.smirk.signNostrEvent)
|
||||
* so chat identity == wallet identity cryptographically; the provider stays signer-agnostic, so
|
||||
* that path only swaps the signer.
|
||||
*
|
||||
* Returns null when no key is configured → outbound nostr disabled (the provider still receives).
|
||||
*/
|
||||
function createBridgeSigner(secret) {
|
||||
if (!secret) return null;
|
||||
let tools, sk, pubkey;
|
||||
try {
|
||||
tools = loadNostrTools();
|
||||
sk = normalizeSecret(secret, tools);
|
||||
pubkey = tools.getPublicKey(sk);
|
||||
} catch (err) {
|
||||
console.error('[nostr] bridge signer disabled, bad NOSTR_BRIDGE_SK:', err.message);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
pubkey,
|
||||
sign(template) {
|
||||
// finalizeEvent fills pubkey/id/sig and stamps created_at if absent.
|
||||
return tools.finalizeEvent(
|
||||
{
|
||||
kind: template.kind,
|
||||
created_at: template.created_at || Math.floor(Date.now() / 1000),
|
||||
tags: template.tags || [],
|
||||
content: template.content || ''
|
||||
},
|
||||
sk
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Accept an nsec bech32 or a 64-hex string; return the Uint8Array secret nostr-tools expects.
|
||||
function normalizeSecret(secret, tools) {
|
||||
const s = String(secret).trim();
|
||||
if (s.startsWith('nsec1')) {
|
||||
const dec = tools.nip19.decode(s);
|
||||
if (dec.type !== 'nsec') throw new Error('not an nsec');
|
||||
return dec.data; // Uint8Array
|
||||
}
|
||||
if (/^[0-9a-fA-F]{64}$/.test(s)) {
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) bytes[i] = parseInt(s.substr(i * 2, 2), 16);
|
||||
return bytes;
|
||||
}
|
||||
throw new Error('expected nsec or 64-hex secret key');
|
||||
}
|
||||
|
||||
module.exports = { createBridgeSigner };
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Chat Handler Module
|
||||
* Handles chat messages, commands, and chat-related rate limiting
|
||||
*/
|
||||
|
||||
const ChatHistoryManager = require('./chatHistoryManager');
|
||||
const SocketChatProvider = require('./chat/SocketChatProvider');
|
||||
const { buildChatProvider } = require('./chat');
|
||||
const { clientIp, stableId } = require('./rateLimitContext');
|
||||
const { verifyChatEvent } = require('../utils/verifyChatEvent');
|
||||
const { escapeChatText } = require('../utils/escapeChat');
|
||||
|
||||
class ChatHandler {
|
||||
constructor({ io, broadcastManager, debugManager, addressManager, paymentHandlers, queueManager, gameModeManager, rateLimiter, db }) {
|
||||
this.io = io;
|
||||
this.broadcastManager = broadcastManager;
|
||||
this.debugManager = debugManager;
|
||||
this.addressManager = addressManager;
|
||||
this.paymentHandlers = paymentHandlers;
|
||||
this.queueManager = queueManager;
|
||||
this.gameModeManager = gameModeManager;
|
||||
this.rateLimiter = rateLimiter;
|
||||
|
||||
// Initialize chat history manager for persistent message storage
|
||||
this.chatHistory = new ChatHistoryManager({
|
||||
db: db || (gameModeManager?.db),
|
||||
debugManager: debugManager,
|
||||
maxHistoryMessages: 50
|
||||
});
|
||||
|
||||
// Chat delivery + history go through a ChatProvider seam so the backend is swappable.
|
||||
// The local provider shares the ChatHistoryManager instance above, so ban checks and stats
|
||||
// that read this.chatHistory keep working and lifecycle stays owned here. buildChatProvider
|
||||
// wraps it with a NostrChatProvider for global cross-server chat when NOSTR_CHAT_ENABLED is
|
||||
// set; otherwise it returns this local provider unchanged.
|
||||
const localChatProvider = new SocketChatProvider({
|
||||
io: this.io,
|
||||
broadcastManager: this.broadcastManager,
|
||||
debugManager: this.debugManager,
|
||||
historyManager: this.chatHistory
|
||||
});
|
||||
this.chatProvider = buildChatProvider({ local: localChatProvider, debugManager: this.debugManager });
|
||||
|
||||
// Memory leak prevention - cleanup old timestamps
|
||||
this._chatLastSent = new Map();
|
||||
this.chatCleanupInterval = setInterval(() => this.cleanupChatTimestamps(), 300000); // 5 minutes
|
||||
|
||||
this._awaitingAddressFor = new Set(); // Track users waiting for address confirmation
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the chat handler (must be called after construction)
|
||||
*/
|
||||
async initialize() {
|
||||
await this.chatHistory.initialize();
|
||||
// Lets a NostrChatProvider connect + subscribe to the relay. No-op for the plain local
|
||||
// provider (history is already initialized above and injected, not owned by the provider).
|
||||
if (this.chatProvider && typeof this.chatProvider.initialize === 'function') {
|
||||
await this.chatProvider.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send chat history to a newly connected user
|
||||
* @param {string} socketId - Socket ID of the new user
|
||||
* @param {number} messageCount - Number of messages to send (default 50)
|
||||
*/
|
||||
async sendChatHistoryToUser(socketId, messageCount = 50) {
|
||||
try {
|
||||
const messages = await this.chatProvider.getHistory({ scope: 'global', limit: messageCount });
|
||||
if (messages.length > 0) {
|
||||
this.io.to(socketId).emit('chat_history', { messages });
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📜 Sent ${messages.length} chat history messages to ${socketId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send chat history:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming chat messages with rate limiting
|
||||
*/
|
||||
async handleChatMessage(socket, msg, additionalHandlers = {}) {
|
||||
try {
|
||||
// Rate limiting keys on stable id + IP so reconnecting can't reset the counter.
|
||||
const rlId = stableId(socket, this.gameModeManager?.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'chat:message', rlIp);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Please slow down! You can send ${rateLimitResult.remaining} more messages after ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log('Message received:', msg);
|
||||
}
|
||||
|
||||
const command = msg.toLowerCase();
|
||||
|
||||
// A pasted XMR/WOW payout address takes priority over command and chat handling.
|
||||
const detected = this.addressManager.detectInText(msg);
|
||||
if (detected) {
|
||||
await this._handleAddressDetection(socket, detected);
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = await this._handleGameCommand(socket, command, additionalHandlers);
|
||||
if (handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a command, broadcast as chat message
|
||||
await this._handleChatBroadcast(socket, msg);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Chat handler error:', error);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Message processing failed. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a client-signed global chat event. The client signs with the player's own Smirk npub
|
||||
* (window.smirk.signNostrEvent) and sends the finished event; the server verifies it is
|
||||
* authentic and signed by this session's authenticated npub (users.smirk_public_key from NIP-98
|
||||
* login), moderates, then relays it: the provider publishes the pre-signed event to nostr under
|
||||
* the player's npub, not the bridge. Serves lobby and tavern, both of which render global scope.
|
||||
*/
|
||||
async handleSignedChatMessage(socket, payload) {
|
||||
try {
|
||||
const rlId = stableId(socket, this.gameModeManager?.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const rl = await this.rateLimiter.checkLimit(rlId, 'chat:message', rlIp);
|
||||
if (!rl.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Please slow down before your next message.');
|
||||
return;
|
||||
}
|
||||
await this.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp);
|
||||
|
||||
const event = payload && payload.event;
|
||||
if (!event || typeof event !== 'object') return;
|
||||
|
||||
// The session's authenticated npub is the ONLY key this player may post under.
|
||||
const db = this.gameModeManager?.db;
|
||||
const session = this.gameModeManager?.sessionManager?.sessions?.get(socket.id);
|
||||
const userId = session ? session.id : null;
|
||||
if (!db || !userId) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.');
|
||||
return;
|
||||
}
|
||||
const row = (await db.query('SELECT smirk_public_key, display_name, username FROM users WHERE id = $1', [userId])).rows[0];
|
||||
const npub = row && row.smirk_public_key;
|
||||
if (!npub) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Sign in with Smirk to post as yourself.');
|
||||
return;
|
||||
}
|
||||
|
||||
const verdict = verifyChatEvent(event, {
|
||||
expectedPubkey: npub,
|
||||
channelTag: process.env.NOSTR_CHAT_TAG || 'wowngeon-global',
|
||||
maxLen: 280
|
||||
});
|
||||
if (!verdict.ok) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.warn('[chat] signed event rejected:', verdict.reason);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Your signed message could not be verified.');
|
||||
return;
|
||||
}
|
||||
|
||||
const username = row.display_name || row.username || String(npub).slice(0, 8);
|
||||
const text = escapeChatText(verdict.content, 200);
|
||||
|
||||
// Delivers in-game (escaped) + relays the client-signed event to nostr under the player's npub.
|
||||
await this.chatProvider.relaySignedEvent({
|
||||
event, scope: 'global', username, text, ts: Date.now(), socketId: socket.id, userId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Signed chat handler error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat statistics
|
||||
*/
|
||||
async getStats() {
|
||||
const historyStats = await this.chatHistory.getStats();
|
||||
return {
|
||||
chatTimestamps: this._chatLastSent.size,
|
||||
awaitingAddress: this._awaitingAddressFor.size,
|
||||
chatHistory: historyStats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown handler - cleanup resources
|
||||
*/
|
||||
shutdown() {
|
||||
if (this.chatCleanupInterval) {
|
||||
clearInterval(this.chatCleanupInterval);
|
||||
this.chatCleanupInterval = null;
|
||||
}
|
||||
if (this.chatHistory) {
|
||||
this.chatHistory.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear address confirmation state for a user
|
||||
*/
|
||||
clearAddressConfirmation(socketId) {
|
||||
this._awaitingAddressFor.delete(socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is awaiting address confirmation
|
||||
*/
|
||||
isAwaitingAddressConfirmation(socketId) {
|
||||
return this._awaitingAddressFor.has(socketId);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
async promptAddress(socket) {
|
||||
await this._promptAddress(socket);
|
||||
}
|
||||
|
||||
async _handleAddressDetection(socket, detected) {
|
||||
// Rate limiting keys on stable id + IP.
|
||||
const rlId = stableId(socket, this.gameModeManager?.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'address:set', rlIp);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Address changes are rate limited. Try again in ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.rateLimiter.recordAttempt(rlId, 'address:set', rlIp);
|
||||
this.addressManager.handleDetection(socket.id, detected);
|
||||
}
|
||||
|
||||
async _handleGameCommand(socket, command, additionalHandlers) {
|
||||
switch (command) {
|
||||
case 'hello':
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'help',
|
||||
'Welcome! Type "enter" to join the queue for the next block, or use the START button for immediate entry. ' +
|
||||
'Paste your XMR/WOW address in chat to set your payout address.');
|
||||
return true;
|
||||
|
||||
case 'enter':
|
||||
if (additionalHandlers.handleGameQueue) {
|
||||
await additionalHandlers.handleGameQueue(socket);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'cancel':
|
||||
if (this.addressManager.pending.has(socket.id)) {
|
||||
this.addressManager.confirm(socket.id, false);
|
||||
return true;
|
||||
}
|
||||
if (additionalHandlers.handleCancelEntry) {
|
||||
await additionalHandlers.handleCancelEntry(socket);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'confirm':
|
||||
if (this.addressManager.pending.has(socket.id)) {
|
||||
this.addressManager.confirm(socket.id, true);
|
||||
return true;
|
||||
}
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Nothing pending confirmation.');
|
||||
return true;
|
||||
|
||||
case 'address':
|
||||
case 'payout':
|
||||
await this._promptAddress(socket);
|
||||
return true;
|
||||
|
||||
case 'payment':
|
||||
case 'pay':
|
||||
if (this.gameModeManager) {
|
||||
await this.paymentHandlers.createAndShowPaymentRequest(socket);
|
||||
} else {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Server is in FREE mode - no payment required.');
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'stats':
|
||||
if (additionalHandlers.handleStatsRequest) {
|
||||
await additionalHandlers.handleStatsRequest(socket);
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
// command is already lowercased by the caller, so the prefix match is case-insensitive.
|
||||
if (command.startsWith('/nick ') || command === '/nick') {
|
||||
await this._handleNickCommand(socket, command);
|
||||
return true;
|
||||
}
|
||||
return false; // Command not handled
|
||||
}
|
||||
}
|
||||
|
||||
async _handleNickCommand(socket, command) {
|
||||
const name = command.slice(6).trim();
|
||||
if (!name) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info',
|
||||
'Usage: /nick YourName (max 20 chars, letters/numbers/underscores)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: 1-20 chars, alphanumeric + underscores + spaces
|
||||
if (!/^[a-zA-Z0-9_ ]{1,20}$/.test(name)) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
'Invalid name. Use letters, numbers, underscores, or spaces (max 20 chars).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.gameModeManager?.db) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Display names not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.gameModeManager.db.query(
|
||||
'UPDATE users SET display_name = $1 WHERE socket_id = $2',
|
||||
[name.trim(), socket.id]
|
||||
);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info',
|
||||
'Display name set to: ' + name.trim());
|
||||
} catch (err) {
|
||||
console.error('Failed to set display name:', err.message);
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to set display name.');
|
||||
}
|
||||
}
|
||||
|
||||
async _handleChatBroadcast(socket, msg) {
|
||||
if (typeof msg !== 'string') return;
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
// Message length limit (reject, don't truncate)
|
||||
const MAX_MESSAGE_LENGTH = 200;
|
||||
if (trimmed.length > MAX_MESSAGE_LENGTH) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Message too long (${trimmed.length}/${MAX_MESSAGE_LENGTH} characters). Please shorten it.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user info for ban check and user_id
|
||||
let userId = null;
|
||||
if (this.gameModeManager) {
|
||||
try {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
|
||||
userId = userRow?.id || null;
|
||||
|
||||
if (userId && await this.chatHistory.isUserChatBanned(userId)) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
|
||||
'You have been banned from chat.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue without user lookup - allow message
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('User lookup for chat failed:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional rate limit for broadcast messages (more restrictive)
|
||||
const now = Date.now();
|
||||
const last = this._chatLastSent.get(socket.id) || 0;
|
||||
const BROADCAST_COOLDOWN = 2000; // 2 seconds between broadcasts
|
||||
|
||||
if (now - last < BROADCAST_COOLDOWN) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Please wait ${Math.ceil((BROADCAST_COOLDOWN - (now - last)) / 1000)} seconds before sending another message.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._chatLastSent.set(socket.id, now);
|
||||
|
||||
// Complete HTML entity escaping to prevent XSS
|
||||
// Note: length already validated above, slice is just a safety net
|
||||
const safe = trimmed
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.slice(0, MAX_MESSAGE_LENGTH);
|
||||
const username = socket.id.substring(0, 6);
|
||||
|
||||
// The provider broadcasts and saves to history (fire-and-forget).
|
||||
await this.chatProvider.publish({
|
||||
scope: 'global',
|
||||
username: username,
|
||||
text: safe,
|
||||
ts: now,
|
||||
socketId: socket.id,
|
||||
userId: userId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old chat timestamps to prevent memory leaks
|
||||
*/
|
||||
cleanupChatTimestamps() {
|
||||
const now = Date.now();
|
||||
const TIMESTAMP_EXPIRY = 600000; // 10 minutes
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [socketId, timestamp] of this._chatLastSent.entries()) {
|
||||
if (now - timestamp > TIMESTAMP_EXPIRY) {
|
||||
this._chatLastSent.delete(socketId);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING && cleanedCount > 0) {
|
||||
console.log(`🧹 ChatHandler cleanup: removed ${cleanedCount} old timestamps`);
|
||||
}
|
||||
}
|
||||
|
||||
async _promptAddress(socket) {
|
||||
let existing = null;
|
||||
try {
|
||||
if (this.gameModeManager) {
|
||||
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
|
||||
existing = userRow?.payout_address || null;
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn('Address prompt lookup failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit('address_prompt', {
|
||||
existingAddress: existing,
|
||||
canUpdate: true
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Update your payout address using the address manager.');
|
||||
} else {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'Add your payout address to receive rewards.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ChatHandler;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user