diff --git a/docs/TODO.md b/docs/TODO.md index 8a1df53..2aeff6d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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` ✅ diff --git a/html/index.html b/html/index.html index 146c4c1..77d0ee9 100644 --- a/html/index.html +++ b/html/index.html @@ -71,6 +71,7 @@ + @@ -108,6 +109,7 @@ + diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js index ddf1e50..339c1b5 100644 --- a/html/js/network/socketHandlers.js +++ b/html/js/network/socketHandlers.js @@ -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($('
  • ').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($('
  • ').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('Payment sent via Smirk — awaiting confirmation...'); + } + 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($('
  • ').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: ' + shortAddr + '' + ' '); - // (Removed inline QR image to avoid duplication; dedicated pinned sidebar QR is used instead.) const $li = $('
  • ').html(parts.join('
    ')); $('#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(); } diff --git a/html/js/ui/leaderboard.js b/html/js/ui/leaderboard.js new file mode 100644 index 0000000..dc4f825 --- /dev/null +++ b/html/js/ui/leaderboard.js @@ -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 = $( + '' + ); + + $('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('
    Loading...
    '); + + var self = this; + $.getJSON('/api/leaderboard?period=' + this._currentPeriod + '&limit=20') + .done(function(data) { + self._render(data.leaderboard || []); + }) + .fail(function() { + $('#leaderboard-content').html('
    Failed to load leaderboard
    '); + }) + .always(function() { + self._isLoading = false; + }); + }, + + _render: function(entries) { + if (!entries.length) { + $('#leaderboard-content').html('
    No scores yet. Play a game to be first!
    '); + return; + } + + var html = ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + 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 += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + + html += '
    #PlayerBestWinsGames
    ' + rankIcon + '' + Leaderboard._escapeHtml(e.name) + '' + (e.best_score || 0) + '' + (e.wins || 0) + '' + (e.games_played || 0) + '
    '; + $('#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();