';
+
+ for (var i = 0; i < entries.length; i++) {
+ var e = entries[i];
+ var rankColor = i === 0 ? '#fbbf24' : (i === 1 ? '#c0c0c0' : (i === 2 ? '#cd7f32' : '#888'));
+ var rankIcon = i === 0 ? '🥇' : (i === 1 ? '🥈' : (i === 2 ? '🥉' : (i + 1)));
+ var bgColor = i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent';
+
+ html += '
';
+ html += '
' + rankIcon + '
';
+ html += '
' + Leaderboard._escapeHtml(e.name) + '
';
+ html += '
' + (e.best_score || 0) + '
';
+ html += '
' + (e.wins || 0) + '
';
+ html += '
' + (e.games_played || 0) + '
';
+ html += '
';
+ }
+
+ html += '
';
+ $('#leaderboard-content').html(html);
+ },
+
+ _escapeHtml: function(str) {
+ if (!str) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+};
+
+// Initialize when DOM is ready
+$(document).ready(function() {
+ Leaderboard.init();
+});
diff --git a/src/.env.example b/src/.env.example
index e4505d4..84bcdb5 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -82,11 +82,35 @@ DB_PASSWORD=your_secure_password_here
# easy | 30x15 | Slow | ~70% | Free play, demos
# normal | 45x22 | Medium | ~45% | Balanced gameplay
# hard | 55x28 | Fast | ~35% | Challenge mode
-# casino | 60x30 | Very Fast | ~30% | Paid modes (default)
+# casino | 70x35 | Fast | ~30% | Paid modes (default)
#
# If unset, defaults to 'casino' for paid modes, 'normal' for free mode.
# DIFFICULTY_PRESET=casino
+# Fine-tune individual difficulty parameters (override preset defaults).
+# Only set these if you want to deviate from the chosen preset.
+#
+# Dungeon layout:
+# DUNGEON_WIDTH=70 # Map width in tiles
+# DUNGEON_HEIGHT=35 # Map height in tiles
+# DUNGEON_DUG_PERCENTAGE=0.10 # % of map that's open space (lower = tighter)
+# DUNGEON_ROOM_WIDTH_MIN=3 # Room width range (min)
+# DUNGEON_ROOM_WIDTH_MAX=4 # Room width range (max)
+# DUNGEON_ROOM_HEIGHT_MIN=3 # Room height range (min)
+# DUNGEON_ROOM_HEIGHT_MAX=4 # Room height range (max)
+# DUNGEON_CORRIDOR_MIN=6 # Corridor length range (min)
+# DUNGEON_CORRIDOR_MAX=15 # Corridor length range (max)
+#
+# Monster behavior:
+# MONSTER_SPEED=1.0 # Moves per player move (1.0 = same speed, 0.5 = half)
+# MONSTER_CHASE=0.97 # Chase probability 0.0-1.0 (rest is random wander)
+# MONSTER_VISION=22 # Vision range in tiles
+# MONSTER_DISTANCE=0.30 # Starting distance ratio 0.0-1.0 (lower = closer)
+#
+# Treasure placement:
+# TREASURE_ROOM_POSITION=0.20 # Room position ratio 0.0-1.0 (lower = earlier room)
+# TREASURE_EXIT_DISTANCE=0.85 # Distance from exit ratio 0.0-1.0 (higher = farther)
+
# =============================================================================
# SECTION 4: PAYMENT SYSTEM OVERVIEW
# =============================================================================
diff --git a/src/game/difficultyConfig.js b/src/game/difficultyConfig.js
index f58b8a8..802a858 100644
--- a/src/game/difficultyConfig.js
+++ b/src/game/difficultyConfig.js
@@ -98,23 +98,23 @@ const DIFFICULTY_PRESETS = {
// Casino - high house edge for real money
casino: {
dungeon: {
- width: 60,
- height: 30,
- roomWidthRange: [3, 5],
+ width: 70, // Large dungeon (was 60)
+ height: 35, // (was 30)
+ roomWidthRange: [3, 4], // Small rooms (was [3, 5])
roomHeightRange: [3, 4],
- corridorLengthRange: [5, 12],
- dugPercentage: 0.12 // Very tight
+ corridorLengthRange: [6, 15], // Long corridors (was [5, 12])
+ dugPercentage: 0.10 // Very tight (was 0.12)
},
monster: {
- startDistanceFromPlayer: 0.35, // Close to player
- movesPerPlayerMove: 1.0,
- chaseAggressiveness: 0.95, // Nearly always chases
- visionRange: 18, // Excellent vision
+ startDistanceFromPlayer: 0.30, // Close to player (was 0.35)
+ movesPerPlayerMove: 1.0, // Monster matches player speed
+ chaseAggressiveness: 0.97, // Nearly always chases (was 0.95)
+ visionRange: 22, // Excellent vision (was 18)
respawnOnDeath: false
},
treasure: {
- roomPositionRatio: 0.25, // Early room
- distanceFromExitRatio: 0.8 // Very far from exit
+ roomPositionRatio: 0.20, // Early room (was 0.25)
+ distanceFromExitRatio: 0.85 // Very far from exit (was 0.8)
},
targetHouseWinRate: 0.70 // 70% house wins
}
@@ -145,16 +145,40 @@ function getDifficultyPreset(cryptoType = 'WOW', overridePreset = null) {
}
/**
- * Merge difficulty config with custom overrides
+ * Merge difficulty config with custom overrides.
+ * Environment variables override preset values when set.
*/
function getDifficultyConfig(cryptoType = 'WOW', customOverrides = {}) {
const preset = getDifficultyPreset(cryptoType, customOverrides.preset);
-
+
+ // Apply env var overrides (only when explicitly set)
+ const envOverrides = {
+ dungeon: {},
+ monster: {},
+ treasure: {}
+ };
+
+ const env = process.env;
+ if (env.DUNGEON_WIDTH) envOverrides.dungeon.width = parseInt(env.DUNGEON_WIDTH);
+ if (env.DUNGEON_HEIGHT) envOverrides.dungeon.height = parseInt(env.DUNGEON_HEIGHT);
+ if (env.DUNGEON_DUG_PERCENTAGE) envOverrides.dungeon.dugPercentage = parseFloat(env.DUNGEON_DUG_PERCENTAGE);
+ if (env.DUNGEON_ROOM_WIDTH_MIN) envOverrides.dungeon.roomWidthRange = [parseInt(env.DUNGEON_ROOM_WIDTH_MIN), parseInt(env.DUNGEON_ROOM_WIDTH_MAX || env.DUNGEON_ROOM_WIDTH_MIN)];
+ if (env.DUNGEON_ROOM_HEIGHT_MIN) envOverrides.dungeon.roomHeightRange = [parseInt(env.DUNGEON_ROOM_HEIGHT_MIN), parseInt(env.DUNGEON_ROOM_HEIGHT_MAX || env.DUNGEON_ROOM_HEIGHT_MIN)];
+ if (env.DUNGEON_CORRIDOR_MIN) envOverrides.dungeon.corridorLengthRange = [parseInt(env.DUNGEON_CORRIDOR_MIN), parseInt(env.DUNGEON_CORRIDOR_MAX || env.DUNGEON_CORRIDOR_MIN)];
+
+ if (env.MONSTER_SPEED) envOverrides.monster.movesPerPlayerMove = parseFloat(env.MONSTER_SPEED);
+ if (env.MONSTER_CHASE) envOverrides.monster.chaseAggressiveness = parseFloat(env.MONSTER_CHASE);
+ if (env.MONSTER_VISION) envOverrides.monster.visionRange = parseInt(env.MONSTER_VISION);
+ if (env.MONSTER_DISTANCE) envOverrides.monster.startDistanceFromPlayer = parseFloat(env.MONSTER_DISTANCE);
+
+ if (env.TREASURE_ROOM_POSITION) envOverrides.treasure.roomPositionRatio = parseFloat(env.TREASURE_ROOM_POSITION);
+ if (env.TREASURE_EXIT_DISTANCE) envOverrides.treasure.distanceFromExitRatio = parseFloat(env.TREASURE_EXIT_DISTANCE);
+
return {
...preset,
- dungeon: { ...preset.dungeon, ...customOverrides.dungeon },
- monster: { ...preset.monster, ...customOverrides.monster },
- treasure: { ...preset.treasure, ...customOverrides.treasure }
+ dungeon: { ...preset.dungeon, ...envOverrides.dungeon, ...customOverrides.dungeon },
+ monster: { ...preset.monster, ...envOverrides.monster, ...customOverrides.monster },
+ treasure: { ...preset.treasure, ...envOverrides.treasure, ...customOverrides.treasure }
};
}
diff --git a/src/game/gameManager.js b/src/game/gameManager.js
index 49f4930..e458518 100644
--- a/src/game/gameManager.js
+++ b/src/game/gameManager.js
@@ -133,8 +133,35 @@ class GameManager {
});
}
+ // Broadcast leaderboard update if score > 0
+ if (finalScore > 0) {
+ try {
+ let displayName = null;
+ if (this.gameModeManager?.db) {
+ const userRow = await this.gameModeManager.db.query(
+ `SELECT COALESCE(display_name,
+ CASE WHEN payout_address IS NOT NULL
+ THEN LEFT(payout_address, 4) || '...' || RIGHT(payout_address, 4)
+ ELSE 'Anon#' || id
+ END) as name
+ FROM users WHERE socket_id = $1`, [socketId]);
+ displayName = userRow.rows[0]?.name || 'Unknown';
+ }
+ this.io.emit('leaderboard_update', {
+ name: displayName || 'Unknown',
+ score: finalScore,
+ treasure: game.player.hasTreasure || false
+ });
+ } catch (lbErr) {
+ // Non-critical, don't block game over
+ if (this.debugManager.CONSOLE_LOGGING) {
+ console.warn('Leaderboard broadcast failed:', lbErr.message);
+ }
+ }
+ }
+
if (this.debugManager.CONSOLE_LOGGING) {
- console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason})`);
+ console.log(`🎮 Game ${game.id} ended for ${socketId}: ${status} (${reason}), score: ${finalScore}`);
}
} catch (error) {
@@ -238,19 +265,29 @@ class GameManager {
_calculateScore(game, status, reason) {
if (!game) return 0;
const moves = game.moveCount || 0;
- let score = 0;
+ const duration = game.startedAt
+ ? Math.max(1, Math.round((Date.now() - game.startedAt) / 1000))
+ : 120;
+
if (status === 'won' && reason === 'escaped') {
- score += 100; // base win
+ let score = 100; // base escape bonus
+
+ // Treasure bonus
if (game.player?.hasTreasure) {
- score += 100; // treasure bonus
+ score += 200;
}
- const moveBonus = Math.max(0, 100 - Math.max(moves - 40, 0) * 2);
- score += moveBonus;
+
+ // Speed bonus: max 300, loses 5 points per second after 20s
+ score += Math.max(0, 300 - Math.max(duration - 20, 0) * 5);
+
+ // Efficiency bonus: max 200, loses 3 points per move after 30 moves
+ score += Math.max(0, 200 - Math.max(moves - 30, 0) * 3);
+
+ return Math.round(score);
} else if (game.player?.hasTreasure) {
- // Reached treasure but did not escape
- score += 50;
+ return 50; // found treasure but died
}
- return Math.max(0, Math.round(score));
+ return 0;
}
/**
@@ -290,15 +327,25 @@ class GameManager {
if (this.gameModeManager && this.gameModeManager.db) {
const db = this.gameModeManager.db;
const outcome = reason === 'escaped' ? 'escaped' : (reason === 'monster' ? 'caught_by_monster' : reason);
-
+ const score = this._calculateScore(game, status, reason);
+
try {
await db.query(`
- UPDATE games SET status = $1, outcome = $2, treasure_found = $3, moves_made = $4, duration_seconds = $5, completed_at = NOW()
+ UPDATE games SET status = $1, outcome = $2, treasure_found = $3, moves_made = $4,
+ duration_seconds = $5, score = $8, completed_at = NOW()
WHERE dungeon_seed = $6 AND socket_id = $7
- `, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId]);
-
+ `, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId, score]);
+
+ // Update user's high score if this is a new personal best
+ if (score > 0) {
+ await db.query(`
+ UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1)
+ WHERE socket_id = $2
+ `, [score, socketId]);
+ }
+
if (this.debugManager.CONSOLE_LOGGING) {
- console.log(`✅ Updated game record for ${socketId}: ${status} (${outcome})`);
+ console.log(`✅ Updated game record for ${socketId}: ${status} (${outcome}), score: ${score}`);
}
} catch (err) {
console.error('Game completion update failed:', err.message);
diff --git a/src/index.js b/src/index.js
index b9d82ce..3946379 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1519,6 +1519,40 @@ app.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, res
});
}));
+// =============================================================================
+// LEADERBOARD
+// =============================================================================
+app.get('/api/leaderboard', asyncHandler(async (req, res) => {
+ const period = req.query.period || 'all';
+ const limit = Math.min(parseInt(req.query.limit) || 20, 50);
+
+ let timeFilter = '';
+ if (period === 'week') timeFilter = "AND g.completed_at > NOW() - INTERVAL '7 days'";
+ else if (period === 'month') timeFilter = "AND g.completed_at > NOW() - INTERVAL '30 days'";
+
+ const result = await db.query(`
+ SELECT
+ u.id,
+ COALESCE(u.display_name,
+ CASE WHEN u.payout_address IS NOT NULL
+ THEN LEFT(u.payout_address, 4) || '...' || RIGHT(u.payout_address, 4)
+ ELSE 'Anon#' || u.id
+ END
+ ) as name,
+ MAX(g.score) as best_score,
+ COUNT(*) FILTER (WHERE g.status = 'won') as wins,
+ COUNT(*) as games_played
+ FROM games g
+ JOIN users u ON g.user_id = u.id
+ WHERE g.status IN ('won', 'lost') AND g.score > 0 ${timeFilter}
+ GROUP BY u.id, u.display_name, u.payout_address
+ ORDER BY best_score DESC
+ LIMIT $1
+ `, [limit]);
+
+ res.json({ leaderboard: result.rows, period });
+}));
+
app.get('/api/game-modes', (req, res) => {
const config = paymentConfigManager.getConfig();
const decimals = Number(config.currency?.decimals ?? 12);
diff --git a/src/migrations/014_score_and_leaderboard.sql b/src/migrations/014_score_and_leaderboard.sql
new file mode 100644
index 0000000..417cf1e
--- /dev/null
+++ b/src/migrations/014_score_and_leaderboard.sql
@@ -0,0 +1,15 @@
+-- Migration 014: Score persistence and leaderboard support
+-- Adds score column to games, high_score and display_name to users
+
+-- Add score to games table (persists calculated score at game end)
+ALTER TABLE games ADD COLUMN IF NOT EXISTS score INTEGER DEFAULT 0;
+
+-- Add high_score to users for quick leaderboard lookups
+ALTER TABLE users ADD COLUMN IF NOT EXISTS high_score INTEGER DEFAULT 0;
+
+-- Add display_name for leaderboard identity (set via /nick command)
+ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name VARCHAR(50);
+
+-- Indexes for leaderboard queries
+CREATE INDEX IF NOT EXISTS idx_games_score ON games(score DESC) WHERE status = 'won';
+CREATE INDEX IF NOT EXISTS idx_users_high_score ON users(high_score DESC) WHERE high_score > 0;
diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js
index 6d2c8d1..a58be5b 100644
--- a/src/network/chatHandler.js
+++ b/src/network/chatHandler.js
@@ -214,10 +214,48 @@ class ChatHandler {
return true;
default:
+ // Check for /nick command (case-sensitive prefix)
+ if (command.startsWith('/nick ') || command === '/nick') {
+ await this._handleNickCommand(socket, command);
+ return true;
+ }
return false; // Command not handled
}
}
+ async _handleNickCommand(socket, command) {
+ const name = command.slice(6).trim();
+ if (!name) {
+ this.broadcastManager.sendStatusUpdate(socket.id, 'info',
+ 'Usage: /nick YourName (max 20 chars, letters/numbers/underscores)');
+ return;
+ }
+
+ // Validate: 1-20 chars, alphanumeric + underscores + spaces
+ if (!/^[a-zA-Z0-9_ ]{1,20}$/.test(name)) {
+ this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
+ 'Invalid name. Use letters, numbers, underscores, or spaces (max 20 chars).');
+ return;
+ }
+
+ if (!this.gameModeManager?.db) {
+ this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Display names not available.');
+ return;
+ }
+
+ try {
+ await this.gameModeManager.db.query(
+ 'UPDATE users SET display_name = $1 WHERE socket_id = $2',
+ [name.trim(), socket.id]
+ );
+ this.broadcastManager.sendStatusUpdate(socket.id, 'info',
+ 'Display name set to: ' + name.trim());
+ } catch (err) {
+ console.error('Failed to set display name:', err.message);
+ this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Failed to set display name.');
+ }
+ }
+
async _handleChatBroadcast(socket, msg) {
if (typeof msg !== 'string') return;
const trimmed = msg.trim();