add smirk payments and leaderboard and diff tuning

This commit is contained in:
jwinterm
2026-02-20 13:54:36 -05:00
parent 27660d6948
commit a61851b0f7
10 changed files with 434 additions and 41 deletions
+25 -1
View File
@@ -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
# =============================================================================
+40 -16
View File
@@ -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
View File
@@ -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);
+34
View File
@@ -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;
+38
View File
@@ -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();