add stagenet warning and increase diff of game and also add modes

This commit is contained in:
jwinterm
2025-12-21 15:10:40 -05:00
parent aa419689c8
commit fbf5bd5b17
10 changed files with 638 additions and 21 deletions
+33
View File
@@ -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
+8
View File
@@ -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).
+27
View File
@@ -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 = $('<div id="networkWarning"></div>');
// Insert at top of container
$('.container').prepend($warning);
}
const networkUpper = (network || 'stagenet').toUpperCase();
$warning.html(`
<div style="background:#ff6600; color:#000; padding:10px; text-align:center; font-weight:bold; font-size:14px; border-bottom:2px solid #ff0000;">
⚠️ ${networkUpper} MODE ⚠️<br>
<span style="font-size:12px; font-weight:normal;">${message}</span>
</div>
`).show();
},
_hideNetworkWarning: function() {
$('#networkWarning').hide();
},
onPaymentCreated: function(data) {
console.log('Payment created:', data);
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
+22
View File
@@ -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)
# -----------------------------------------------------------------------------
+196
View File
@@ -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
};
+26 -11
View File
@@ -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 = {}) {
+81 -5
View File
@@ -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;
+18
View File
@@ -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,
+22 -5
View File
@@ -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) {
+205
View File
@@ -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
};