mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
add stagenet warning and increase diff of game and also add modes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user