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
+11 -1
View File
@@ -75,11 +75,12 @@
- [x] **Address Validation**: `validateAddress()` called before every payout attempt
- [x] **Transaction Status Check**: `checkTransactionStatus()` verifies blockchain before retry
## Smirk Wallet Integration (Jan 2026)
## Smirk Wallet Integration (Jan-Feb 2026)
- [x] **Database Schema**: `smirk_public_key` column, `smirk_challenges` table (migration 009)
- [x] **Auth Endpoints**: `/api/auth/smirk/challenge`, `/verify`, `/status`
- [x] **Frontend Module**: `smirkAuth.js` with extension detection and login flow
- [x] **Address Modal**: Shows Smirk connect button or install link
- [x] **Native Payments**: `requestPayment()` integration — Smirk-connected users get auto-prompted via extension instead of manual address/QR. Falls back to normal flow on rejection.
## Admin Dashboard (Jan 2026)
- [x] **Alert Service**: Email alerts via Resend for low balance, disconnects, failed payouts
@@ -87,6 +88,15 @@
- [x] **Admin UI**: Full dashboard at `/admin.html` with charts and tables
- [x] **Payout Retry**: Manual retry button in admin dashboard
## Scoring & Leaderboard (Feb 2026)
- [x] **Scoring System**: New formula (max 800 pts) — base escape + treasure bonus + speed bonus + efficiency bonus
- [x] **Score Persistence**: `score` column on games table, `high_score` on users (migration 014)
- [x] **Leaderboard API**: `GET /api/leaderboard?period=all|week|month` with display name support
- [x] **Leaderboard UI**: Modal with rank, name, best score, wins, games. Period tabs (All/Week/Month)
- [x] **Display Names**: `/nick` chat command stores `display_name` on users table
- [x] **Real-time Updates**: `leaderboard_update` broadcast on game wins, auto-refreshes open modal
- [x] **Difficulty Tuning**: Casino preset hardened — 70x35 dungeon, 1.5x monster speed, 97% chase, 22-tile vision
## Payout Reliability (Feb 2026)
- [x] **Fire-and-Forget INSERT Fix**: `_insertGameRecord` now awaited, prevents race conditions ✅
- [x] **Double Game-Over Prevention**: `activeGames.delete()` moved before any `await` in `handleGameOver` ✅
+2
View File
@@ -71,6 +71,7 @@
<script src="js/ui/audioAlerts.js"></script>
<script src="js/ui/addressModal.js"></script>
<script src="js/ui/transactionHistory.js"></script>
<script src="js/ui/leaderboard.js"></script>
<script src="js/ui/helpModal.js"></script>
<script src="js/network/socketHandlers.js"></script>
<script src="js/network/smirkAuth.js"></script>
@@ -108,6 +109,7 @@
</label>
<button id="helpButton" style="margin-left:auto; background:#2d2d00; color:#ffd700; border:1px solid #ffd700; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="How to play and payment information">❓ Help</button>
<button id="historyButton" style="background:#1a1a4d; color:#c4b5fd; border:1px solid #8b5cf6; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="View your payment and payout history">📜 History</button>
<button id="leaderboardButton" style="background:#4a2800; color:#fbbf24; border:1px solid #f59e0b; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="View high scores">🏆 Scores</button>
<button id="manageAddressButton" style="background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
<span id="sessionWarning" style="display:none; color:#f59e0b; font-size:10px; margin-left:6px;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
</div>
+66 -9
View File
@@ -670,7 +670,67 @@ const SocketHandlers = {
onPaymentCreated: function(data) {
console.log('Payment created:', data);
if (typeof AudioAlerts !== 'undefined') { AudioAlerts.playRequestCoin(); }
// Try Smirk native payment if user is connected via Smirk
if (typeof SmirkAuth !== 'undefined' && SmirkAuth._isLinked && SmirkAuth.isAvailable() &&
typeof window.smirk !== 'undefined' && window.smirk.requestPayment) {
SocketHandlers._trySmirkPayment(data);
return;
}
// Normal flow: show payment modal + chat message + QR
SocketHandlers._showPaymentFlow(data);
},
/**
* Attempt payment via Smirk wallet extension.
* Falls back to normal address/QR flow on rejection or error.
*/
_trySmirkPayment: async function(data) {
$('#messages').append($('<li class="status">').text('Opening Smirk wallet for payment...'));
UI.scrollChat();
try {
await window.smirk.requestPayment({
address: data.address,
amount: String(data.amount),
asset: (SocketHandlers._cryptoType || 'WOW').toLowerCase(),
description: data.paymentType === 'credits_package'
? 'Buy ' + (data.package ? data.package.credits : '') + ' credits'
: 'Single game entry'
});
// User confirmed in Smirk — TX submitted, server monitoring handles the rest
$('#messages').append($('<li class="status" style="color:#4ade80;">').text(
'Payment sent via Smirk! Waiting for confirmation...'
));
UI.scrollChat();
// Show payment UI in "waiting for confirmation" state
if (typeof PaymentUI !== 'undefined') {
PaymentUI.showPaymentRequest(data);
$('#payment-status').html('<span style="color:#4ade80;">Payment sent via Smirk — awaiting confirmation...</span>');
}
SocketHandlers._setBannerStatus('Pay', '#0af');
if (typeof Game !== 'undefined' && Game._paymentRequested) Game._paymentRequested();
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) Game.drawWaitingScreen();
} catch (err) {
console.log('Smirk payment declined/failed, falling back to manual:', err);
$('#messages').append($('<li class="status">').text(
'Smirk payment cancelled. Use the address below.'
));
UI.scrollChat();
// Fall back to normal address/QR flow
SocketHandlers._showPaymentFlow(data);
}
},
/**
* Show normal payment flow: modal + chat message + QR code
*/
_showPaymentFlow: function(data) {
if (typeof PaymentUI !== 'undefined') {
PaymentUI.showPaymentRequest(data);
}
@@ -685,11 +745,10 @@ const SocketHandlers = {
const shortAddr = data.address.substring(0, 10) + '…' + data.address.slice(-6);
parts.push('Address: <span class="pay-address-full" style="cursor:pointer;" title="Click to toggle full address">' + shortAddr + '</span>' +
' <button class="copy-pay-address" data-address="' + data.address + '" style="margin-left:4px;padding:1px 4px;font-size:11px;cursor:pointer;">Copy</button>');
// (Removed inline QR image to avoid duplication; dedicated pinned sidebar QR is used instead.)
const $li = $('<li class="payment-info" style="white-space:normal;">').html(parts.join('<br>'));
$('#messages').append($li);
// Attach copy handler (delegated inside this LI to avoid duplicates)
// Attach copy handler
const fullAddress = data.address;
$li.on('click', '.copy-pay-address', function(e) {
e.preventDefault();
@@ -699,8 +758,7 @@ const SocketHandlers = {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(addr);
} else {
// Fallback
const ta = document.createElement('textarea');
var ta = document.createElement('textarea');
ta.value = addr; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
@@ -716,20 +774,19 @@ const SocketHandlers = {
});
// Toggle short/full address on span click
let showingFull = false;
var showingFull = false;
$li.on('click', '.pay-address-full', function() {
showingFull = !showingFull;
$(this).text(showingFull ? fullAddress : shortAddr);
});
UI.scrollChat();
setTimeout(function() {
$('#chatInput').focus();
}, 100);
SocketHandlers._setBannerStatus('Pay', '#0af');
if (typeof Game !== 'undefined' && Game._paymentRequested) Game._paymentRequested();
// Show waiting screen (which will now say "Awaiting payment")
if (typeof Game !== 'undefined' && Game.drawWaitingScreen) {
Game.drawWaitingScreen();
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Leaderboard Panel
* Displays high scores with period filtering
*/
const Leaderboard = {
_isLoading: false,
_currentPeriod: 'all',
init: function() {
$('#leaderboardButton').on('click', function() {
Leaderboard.show();
});
// Listen for real-time leaderboard updates
if (window.socket) {
window.socket.on('leaderboard_update', function(data) {
// Refresh if modal is visible
if ($('#leaderboard-panel').is(':visible')) {
Leaderboard._fetchAndRender();
}
});
}
},
show: function() {
this._ensurePanel();
$('#leaderboard-panel').show();
this._fetchAndRender();
},
_ensurePanel: function() {
if ($('#leaderboard-panel').length) return;
var panel = $(
'<div id="leaderboard-panel" style="display:none; position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:2100; background:rgba(10,10,20,0.97); border:2px solid #f59e0b; padding:20px; color:#e0e0e0; min-width:380px; max-width:500px; max-height:80vh; overflow-y:auto; box-shadow:0 0 20px rgba(245,158,11,0.3); border-radius:6px;">' +
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">' +
'<strong style="color:#fbbf24; font-size:1.1em;">🏆 Leaderboard</strong>' +
'<button id="close-leaderboard" style="background:#500; color:#f00; border:1px solid #f00; padding:2px 8px; cursor:pointer; font-size:14px;">✕</button>' +
'</div>' +
'<div id="leaderboard-tabs" style="display:flex; gap:4px; margin-bottom:12px;">' +
'<button class="lb-tab" data-period="all" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">All Time</button>' +
'<button class="lb-tab" data-period="month" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">Month</button>' +
'<button class="lb-tab" data-period="week" style="flex:1; padding:6px; font-size:11px; cursor:pointer; border:1px solid #555; border-radius:3px;">Week</button>' +
'</div>' +
'<div id="leaderboard-content" style="font-size:12px;">Loading...</div>' +
'<div style="margin-top:10px; font-size:10px; color:#666; text-align:center;">Set your name: type /nick YourName in chat</div>' +
'</div>'
);
$('body').append(panel);
$('#close-leaderboard').on('click', function() {
$('#leaderboard-panel').hide();
});
var self = this;
$(document).on('click', '.lb-tab', function() {
self._currentPeriod = $(this).data('period');
self._updateTabs();
self._fetchAndRender();
});
this._updateTabs();
},
_updateTabs: function() {
var current = this._currentPeriod;
$('.lb-tab').each(function() {
if ($(this).data('period') === current) {
$(this).css({ background: '#92400e', color: '#fff', borderColor: '#f59e0b' });
} else {
$(this).css({ background: '#1a1a2e', color: '#aaa', borderColor: '#555' });
}
});
},
_fetchAndRender: function() {
if (this._isLoading) return;
this._isLoading = true;
$('#leaderboard-content').html('<div style="text-align:center; color:#888;">Loading...</div>');
var self = this;
$.getJSON('/api/leaderboard?period=' + this._currentPeriod + '&limit=20')
.done(function(data) {
self._render(data.leaderboard || []);
})
.fail(function() {
$('#leaderboard-content').html('<div style="text-align:center; color:#f66;">Failed to load leaderboard</div>');
})
.always(function() {
self._isLoading = false;
});
},
_render: function(entries) {
if (!entries.length) {
$('#leaderboard-content').html('<div style="text-align:center; color:#888; padding:20px;">No scores yet. Play a game to be first!</div>');
return;
}
var html = '<table style="width:100%; border-collapse:collapse;">';
html += '<thead><tr style="border-bottom:1px solid #444; color:#fbbf24; font-size:11px;">';
html += '<th style="text-align:left; padding:4px;">#</th>';
html += '<th style="text-align:left; padding:4px;">Player</th>';
html += '<th style="text-align:right; padding:4px;">Best</th>';
html += '<th style="text-align:right; padding:4px;">Wins</th>';
html += '<th style="text-align:right; padding:4px;">Games</th>';
html += '</tr></thead><tbody>';
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 += '<tr style="border-bottom:1px solid #222; background:' + bgColor + ';">';
html += '<td style="padding:6px 4px; color:' + rankColor + '; font-weight:bold;">' + rankIcon + '</td>';
html += '<td style="padding:6px 4px; color:#e0e0e0; max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + Leaderboard._escapeHtml(e.name) + '</td>';
html += '<td style="padding:6px 4px; text-align:right; color:#4ade80; font-weight:bold;">' + (e.best_score || 0) + '</td>';
html += '<td style="padding:6px 4px; text-align:right; color:#60a5fa;">' + (e.wins || 0) + '</td>';
html += '<td style="padding:6px 4px; text-align:right; color:#888;">' + (e.games_played || 0) + '</td>';
html += '</tr>';
}
html += '</tbody></table>';
$('#leaderboard-content').html(html);
},
_escapeHtml: function(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
};
// Initialize when DOM is ready
$(document).ready(function() {
Leaderboard.init();
});
+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();