mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
add smirk payments and leaderboard and diff tuning
This commit is contained in:
+11
-1
@@ -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` ✅
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
$(document).ready(function() {
|
||||
Leaderboard.init();
|
||||
});
|
||||
+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