mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-28 02:28:34 +01:00
add smirk payments and leaderboard and diff tuning
This commit is contained in:
+25
-1
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+61
-14
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user