From fbf5bd5b1723837ee5d71336d1e2f04677eacbdd Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 21 Dec 2025 15:10:40 -0500 Subject: [PATCH] add stagenet warning and increase diff of game and also add modes --- README.md | 33 +++++ TODO.md | 8 ++ html/js/network/socketHandlers.js | 27 ++++ src/.env.example | 22 ++++ src/game/difficultyConfig.js | 196 ++++++++++++++++++++++++++++ src/game/dungeon.js | 37 ++++-- src/game/game.js | 86 ++++++++++++- src/game/gameModeManager.js | 18 +++ src/game/monster.js | 27 +++- src/game/provablyFair.js | 205 ++++++++++++++++++++++++++++++ 10 files changed, 638 insertions(+), 21 deletions(-) create mode 100644 src/game/difficultyConfig.js create mode 100644 src/game/provablyFair.js diff --git a/README.md b/README.md index f4ec881..ca04cfe 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,35 @@ Wownerogue is a browser-based roguelike that synchronizes dungeon runs with Mone - Multiplayer dungeon crawler rendered in the browser - Three game modes with configurable pricing and payouts - Optional crypto payments with automatic subaddress management +- **Provably fair** gaming with pre-game hash commitments +- Configurable difficulty with house edge tuning - Centralized error handling, rate limiting, and memory management - Jest coverage for wallet RPC interactions, payment flow, security checks, and game helpers +## Difficulty System + +Dungeon difficulty is configurable via presets that control dungeon size, monster behavior, and placement: + +| Preset | Dungeon Size | Monster Aggression | Target House Win Rate | +|--------|--------------|--------------------|-----------------------| +| `easy` | 30×15 | Low (60%) | 30% | +| `normal` | 45×22 | Medium (80%) | 55% | +| `hard` | 55×28 | High (90%) | 65% | +| `casino` | 60×30 | Very High (95%) | 70% | + +Set `DIFFICULTY_PRESET=casino` in `.env` for paid games. The default automatically selects `casino` for paid modes and `normal` for free play. + +## Provably Fair Gaming + +Every game uses cryptographic verification to prove fairness: + +1. **Pre-game**: Server generates a random seed and shows its SHA-256 hash to the player +2. **During game**: The seed deterministically generates dungeon layout, positions, etc. +3. **Post-game**: Server reveals the seed so players can verify hash(seed) = pre-game commitment +4. **Verification**: Players can regenerate the dungeon using the seed to confirm fairness + +This prevents the server from generating unfair dungeons or changing outcomes after seeing payments. + ## Game Modes The unified payment config exposes two monetized modes. They map to the legacy identifiers still exposed over the socket and REST APIs. @@ -128,6 +154,13 @@ The default Socket.IO client served from `/html` listens on port 3000. Configure Key environment variables (see `.env.example` for the full list): ```bash +# Network selection (Monero only - Wownero only has mainnet) +MONERO_NETWORK=mainnet # mainnet, stagenet, or testnet +# ⚠️ STAGENET: Stagenet XMR has no value! UI shows warning when not mainnet. + +# Difficulty preset +DIFFICULTY_PRESET=casino # easy, normal, hard, casino (auto-selects for paid modes) + # Unified payments PAYMENTS_ENABLED=true PAYMENT_MODES=direct,credits diff --git a/TODO.md b/TODO.md index 063737d..ed3746a 100644 --- a/TODO.md +++ b/TODO.md @@ -7,16 +7,23 @@ - [x] **Address Persistence**: Verify that payout addresses persist across server restarts via session tokens. ✅ - [x] **Mixed Mode Logic**: Per-user mode determination based on credits balance ✅ - [x] **Credits Package Bug**: Credits correctly parsed from package info on confirmation ✅ +- [x] **Stagenet Support**: Added MONERO_NETWORK config with UI warnings for non-mainnet ✅ +- [x] **Difficulty Tuning**: Configurable presets (easy/normal/hard/casino) for house edge control ✅ +- [x] **Provably Fair**: SHA-256 commitment scheme for verifiable game generation ✅ ## Improvements / Refactoring - [ ] **Error Handling**: Extend `normalizeError` usage to remaining network modules for consistent error reporting. - [ ] **Configuration Persistence**: Implement persistence for payment configuration updates to support hot reloads. - [ ] **Monitoring Dashboard**: Build a lightweight dashboard for live metrics (active games, queue length, rate limits). - [ ] **Frontend Cleanup**: Remove any remaining dead code or unused assets from the `html/` directory. +- [ ] **Provably Fair UI**: Add frontend display of game hash commitment and post-game verification link. +- [ ] **Verification Endpoint**: Add `/verify/:gameId` REST endpoint for public game verification. ## Documentation - [x] **API Documentation**: README updated with accurate API surface including health check and Socket.IO events ✅ - [x] **Session Persistence**: README documents session token behavior and localStorage requirements ✅ +- [x] **Difficulty System**: README documents difficulty presets and house edge tuning ✅ +- [x] **Provably Fair**: README documents cryptographic verification system ✅ - [ ] **Deployment Guide**: Create a detailed deployment guide including reverse proxy setup (Nginx/Caddy). ## Pre-Beta Checklist @@ -25,6 +32,7 @@ - [x] REST endpoints implemented: `/api/user/:socketId/credits`, `/api/user/:socketId/mode`, `/api/user/:socketId/address` ✅ - [x] Payout eligibility shown per payment mode in UI ✅ - [x] Session persistence tooltip and localStorage warning added ✅ +- [x] Stagenet/testnet warning banner for Monero non-mainnet ✅ - [ ] **TLS/HTTPS**: Configure reverse proxy (Nginx/Caddy) with SSL certificates before exposing to real users. - [ ] **Database Backups**: Implement automated backup strategy for PostgreSQL. - [ ] **Manual Integration Test**: Run full payment flow with real `wownero-wallet-rpc` (not mocked). diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index 0e7c19a..5652eed 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -343,6 +343,13 @@ const SocketHandlers = { UI.updateGameTitle(data.cryptoType); } + // Display testnet/stagenet warning if applicable + if (data.testnetWarning) { + SocketHandlers._showNetworkWarning(data.testnetWarning, data.network); + } else { + SocketHandlers._hideNetworkWarning(); + } + if (typeof PaymentUI !== 'undefined') { PaymentUI.updateConfig(data); // Show shop button if payments enabled @@ -354,6 +361,26 @@ const SocketHandlers = { } }, + _showNetworkWarning: function(message, network) { + let $warning = $('#networkWarning'); + if (!$warning.length) { + $warning = $('
'); + // Insert at top of container + $('.container').prepend($warning); + } + const networkUpper = (network || 'stagenet').toUpperCase(); + $warning.html(` +
+ ⚠️ ${networkUpper} MODE ⚠️
+ ${message} +
+ `).show(); + }, + + _hideNetworkWarning: function() { + $('#networkWarning').hide(); + }, + onPaymentCreated: function(data) { console.log('Payment created:', data); if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); } diff --git a/src/.env.example b/src/.env.example index b3de3ce..9f3a57b 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,5 +1,27 @@ # Wownerogue Server Configuration +# ----------------------------------------------------------------------------- +# Network Selection (IMPORTANT for Monero users) +# ----------------------------------------------------------------------------- +# For Wownero: Only mainnet exists, this setting is ignored +# For Monero: Choose mainnet, stagenet, or testnet +# ⚠️ STAGENET WARNING: Stagenet XMR has no real value! Only for testing. +MONERO_NETWORK=mainnet +# MONERO_NETWORK=stagenet +# MONERO_NETWORK=testnet + +# ----------------------------------------------------------------------------- +# Difficulty Settings +# ----------------------------------------------------------------------------- +# Presets: easy, normal, hard, casino +# - easy: Small dungeon, slow monster, high player win rate (~70%) +# - normal: Balanced difficulty (~45% player win rate) +# - hard: Large dungeon, aggressive monster (~35% player win rate) +# - casino: Maximum house edge (~30% player win rate) - DEFAULT for paid modes +# Leave unset to auto-select based on PAYMENTS_ENABLED +# DIFFICULTY_PRESET=casino +# DIFFICULTY_PRESET=normal + # ----------------------------------------------------------------------------- # Unified Payment System (recommended configuration) # ----------------------------------------------------------------------------- diff --git a/src/game/difficultyConfig.js b/src/game/difficultyConfig.js new file mode 100644 index 0000000..9a79ed1 --- /dev/null +++ b/src/game/difficultyConfig.js @@ -0,0 +1,196 @@ +/** + * 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: + * - Wownero (WOW): ~2 minutes (120 seconds) + * - Monero (XMR): ~2 minutes (120 seconds) + * - Bitcoin (for reference): ~10 minutes + * + * Note: Previously docs said WOW was 5 min but it's actually 2 min like XMR. + * We keep the config flexible for future adjustments. + */ + +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 + respawnOnDeath: false + }, + treasure: { + roomPositionRatio: 0.5, // Middle room + distanceFromExitRatio: 0.3 // Close to exit + }, + targetHouseWinRate: 0.3 // 30% house wins (player-friendly) + }, + + // Normal - balanced for Wownero (2 min blocks) + 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 + 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 + 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: 60, + height: 30, + roomWidthRange: [3, 5], + roomHeightRange: [3, 4], + corridorLengthRange: [5, 12], + dugPercentage: 0.12 // Very tight + }, + monster: { + startDistanceFromPlayer: 0.35, // Close to player + movesPerPlayerMove: 1.0, + chaseAggressiveness: 0.95, // Nearly always chases + respawnOnDeath: false + }, + treasure: { + roomPositionRatio: 0.25, // Early room + distanceFromExitRatio: 0.8 // 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' }; +} + +/** + * Merge difficulty config with custom overrides + */ +function getDifficultyConfig(cryptoType = 'WOW', customOverrides = {}) { + const preset = getDifficultyPreset(cryptoType, customOverrides.preset); + + return { + ...preset, + dungeon: { ...preset.dungeon, ...customOverrides.dungeon }, + monster: { ...preset.monster, ...customOverrides.monster }, + treasure: { ...preset.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, + getDifficultyPreset, + getDifficultyConfig, + getMonsterSpawnRoomIndex, + getTreasureRoomIndex +}; diff --git a/src/game/dungeon.js b/src/game/dungeon.js index fbeeb1b..536f409 100644 --- a/src/game/dungeon.js +++ b/src/game/dungeon.js @@ -1,4 +1,5 @@ const ROT = require('./rot.js'); +const { getDifficultyConfig, getMonsterSpawnRoomIndex, getTreasureRoomIndex } = require('./difficultyConfig'); // Environment-based console logging control const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; @@ -6,20 +7,21 @@ const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV // ======================================== // DUNGEON CONFIGURATION SECTION // ======================================== -// Edit these values to customize dungeon generation +// 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 - DEFAULT_WIDTH: 40, - DEFAULT_HEIGHT: 20, + // 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 - ROOM_WIDTH_RANGE: [3, 9], - ROOM_HEIGHT_RANGE: [3, 7], - CORRIDOR_LENGTH_RANGE: [2, 6], + // 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 @@ -38,9 +40,22 @@ const DUNGEON_CONFIGS = { * Dungeon generation utilities */ class DungeonGenerator { - // Get the configuration object (allows external access) - static getConfig() { - return { ...DUNGEON_CONFIGS }; + // 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 + }; } static generate(width, height, options = {}) { diff --git a/src/game/game.js b/src/game/game.js index c98cad9..620373c 100644 --- a/src/game/game.js +++ b/src/game/game.js @@ -4,6 +4,8 @@ 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 } = require('./provablyFair'); // Environment-based console logging control const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; @@ -22,8 +24,12 @@ class Game { this.players = {}; this.monsters = {}; this.dungeon = null; - this.moveCount = 0; - this.startedAt = Date.now(); + this.moveCount = 0; + this.startedAt = Date.now(); + + // Provably fair: Generate game proof before anything else + this.gameProof = createGameProof(this.id); + this.seededRNG = createSeededRNG(this.gameProof.seed); // Get default configuration from DungeonGenerator const dungeonConfig = DungeonGenerator.getConfig(); @@ -89,11 +95,21 @@ class Game { this.width = this.gameConfig.width; this.height = this.gameConfig.height; + // Get difficulty settings + const difficultyConfig = getDifficultyConfig(process.env.CRYPTO_TYPE || 'WOW'); + this.difficultyConfig = difficultyConfig; + + if (CONSOLE_LOGGING) { + console.log(`[Game.initializeGame] Using difficulty preset: ${difficultyConfig.presetName}`); + console.log(`[Game.initializeGame] Target house win rate: ${difficultyConfig.targetHouseWinRate * 100}%`); + } + // Initialize game state this.gameState = 'waiting'; this.startBlock = null; this.player = new Player(); this.monster = new Monster(); + this.monsterMoveAccumulator = 0; // For fractional monster moves this.fee = 0; // Generate dungeon with the provided dimensions and config @@ -104,11 +120,19 @@ class Game { this.player.moveTo(this.dungeon.entrance[0], this.dungeon.entrance[1]); } - // Place monster far from entrance + // Place monster based on difficulty config (closer = harder) if (this.dungeon.rooms && this.dungeon.rooms.length > 2) { - const monsterRoom = this.dungeon.rooms[Math.floor(this.dungeon.rooms.length * 0.7)]; + const monsterRoomIndex = getMonsterSpawnRoomIndex( + this.dungeon.rooms, + difficultyConfig.monster.startDistanceFromPlayer + ); + const monsterRoom = this.dungeon.rooms[monsterRoomIndex]; const center = monsterRoom.getCenter(); this.monster.moveTo(center[0], center[1]); + + if (CONSOLE_LOGGING) { + console.log(`[Game.initializeGame] Monster spawned in room ${monsterRoomIndex}/${this.dungeon.rooms.length - 1} (distance ratio: ${difficultyConfig.monster.startDistanceFromPlayer})`); + } } // Initialize lighting and FOV @@ -183,7 +207,25 @@ class Game { return { status: 'idle' }; } - this.monster.moveTowardPlayer(this.player, this.dungeon); + // Get monster movement rate from difficulty config + const movesPerPlayerMove = this.difficultyConfig?.monster?.movesPerPlayerMove ?? 1.0; + const chaseAggressiveness = this.difficultyConfig?.monster?.chaseAggressiveness ?? 0.8; + + // Accumulate fractional moves + this.monsterMoveAccumulator = (this.monsterMoveAccumulator || 0) + movesPerPlayerMove; + + // Only move if accumulator >= 1 + while (this.monsterMoveAccumulator >= 1) { + this.monsterMoveAccumulator -= 1; + + // Apply chase aggressiveness (chance to move randomly instead of chasing) + if (Math.random() < chaseAggressiveness) { + this.monster.moveTowardPlayer(this.player, this.dungeon); + } else { + // Random move - still try to move, just not toward player + this.monster.moveTowardPlayer(null, this.dungeon); + } + } if (this.monster.hasCaughtPlayer(this.player)) { this.gameState = 'lost'; @@ -247,6 +289,40 @@ class Game { } 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.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; \ No newline at end of file diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index c218994..9a98111 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -25,6 +25,12 @@ class GameModeManager { this.cryptoType = process.env.CRYPTO_TYPE || 'XMR'; this.currencyDecimals = this.inferCurrencyDecimals(this.cryptoType); + + // Network configuration (mainnet/stagenet/testnet) - only applies to Monero + // Wownero only has mainnet, so this is ignored for WOW + this.network = (process.env.MONERO_NETWORK || 'mainnet').toLowerCase(); + this.isTestNetwork = this.network === 'stagenet' || this.network === 'testnet'; + this.singleGamePrice = DEFAULT_SINGLE_GAME_PRICE; this.creditsPackagePrice = DEFAULT_CREDITS_PACKAGE_PRICE; this.creditsPayoutEnabled = false; @@ -979,15 +985,27 @@ class GameModeManager { * Get game mode info for frontend */ getGameModeInfo() { + // Determine if we should show a testnet warning + // Only for XMR on stagenet/testnet - WOW only has mainnet + const showTestnetWarning = this.cryptoType === 'XMR' && this.isTestNetwork; + const testnetWarning = showTestnetWarning + ? `⚠️ STAGENET MODE: This server is using ${this.network} XMR. Do NOT send real mainnet XMR! Only ${this.network} XMR will be accepted.` + : null; + return { mode: this.gameMode, cryptoType: this.cryptoType, + network: this.network, + isTestNetwork: this.isTestNetwork, + testnetWarning: testnetWarning, singleGamePrice: this.singleGamePrice, creditsPackagePrice: this.creditsPackagePrice, creditsPerGame: this.creditsPerGameCost, paymentsEnabled: this.paymentsEnabled, directModeEnabled: this.directModeEnabled, creditsModeEnabled: this.creditsModeEnabled, + directPayoutsEnabled: this.directPayoutMultipliers.escape > 0, + creditsPayoutsEnabled: this.creditsPayoutEnabled, features: { paymentRequired: this.paymentsEnabled, creditsSystem: this.creditsModeEnabled, diff --git a/src/game/monster.js b/src/game/monster.js index e347cbd..1f1e19c 100644 --- a/src/game/monster.js +++ b/src/game/monster.js @@ -20,17 +20,34 @@ class Monster { return this.x === x && this.y === y; } - // Simple AI to move toward player + // Simple AI to move toward player (or randomly if player is null) moveTowardPlayer(player, dungeon) { - if (!player || !dungeon || !dungeon.map) return; + if (!dungeon || !dungeon.map) return; - const dx = Math.sign(player.x - this.x); - const dy = Math.sign(player.y - this.y); - // Check if a tile is passable (floor) const isPassable = (tile) => { return tile === "'1" || tile === "'2" || tile === 0; // Support both new and legacy floor types }; + + // If no player target, move randomly + if (!player) { + const dirs = [[0,1],[1,0],[0,-1],[-1,0]]; + const shuffledDirs = ROT.RNG.shuffle(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; + } + } + return; + } + + const dx = Math.sign(player.x - this.x); + const dy = Math.sign(player.y - this.y); // Try horizontal move first if (dx !== 0) { diff --git a/src/game/provablyFair.js b/src/game/provablyFair.js new file mode 100644 index 0000000..c733180 --- /dev/null +++ b/src/game/provablyFair.js @@ -0,0 +1,205 @@ +/** + * Provably Fair Gaming System + * + * This module implements cryptographic verification for fair game 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 prevents the server from: + * - Generating impossible dungeons after seeing player's payment + * - Changing dungeon mid-game + * - Claiming different game outcomes + */ + +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'); +} + +/** + * 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; +} + +/** + * 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; + + return 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 between 0 and 1 + const num = hash.readUInt32BE(0); + return num / 0xFFFFFFFF; + }; +} + +/** + * 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 + * @returns {object} Proof object with seed, commitment, and creation time + */ +function createGameProof(gameId) { + const seed = generateSeed(); + const commitment = hashSeed(seed); + const timestamp = Date.now(); + + return { + gameId, + seed, // Keep secret until game ends + 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, + 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 = {}) { + return { + gameId: proof.gameId, + seed: proof.seed, + commitment: proof.commitment, + 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 was fair:", + `1. Compute SHA-256 of the seed: ${proof.seed}`, + `2. Verify it matches the commitment: ${proof.commitment}`, + "3. Use the seed to regenerate the dungeon layout", + "4. Confirm the dungeon matches what you played" + ] + }; +} + +/** + * 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, + verifySeed, + createSeededRNG, + seededRandomInt, + seededShuffle, + createGameProof, + getPreGameCommitment, + getPostGameReveal, + verifyGame +};