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
+};