From 8552887b6cc71d591ceb968bb97ef017e994b2a6 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 17 Jul 2025 20:06:32 +0000 Subject: [PATCH] Add new IRC games and enhance bot functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Rock Paper Scissors game with PvP and bot modes - Fixed syntax errors and improved game mechanics - PvP moves now require PM for secrecy - Add Word Scramble game with difficulty levels - Multiple word categories and persistent scoring - Enhance duck hunt with better statistics tracking - Separate points vs duck count tracking - Fixed migration logic issues - Add core rate limiting system (5 commands/30s) - Admin whitelist for megasconed - Automatic cleanup and unblocking - Improve reload functionality for hot-reloading plugins - Add channel-specific commands (\!stopducks/\!startducks) ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- botmain.js | 178 +++++++- plugins/basic.js | 70 +++ plugins/duck_hunt_plugin.js | 172 +++++++- plugins/duck_hunt_scores.json | 4 +- plugins/rock_paper_scissors_plugin.js | 594 ++++++++++++++++++++++++++ plugins/rps_scores.json | 54 +++ plugins/scramble_scores.json | 28 ++ plugins/slots_scores.json | 4 +- plugins/word_scramble_plugin.js | 462 ++++++++++++++++++++ 9 files changed, 1536 insertions(+), 30 deletions(-) create mode 100644 plugins/rock_paper_scissors_plugin.js create mode 100644 plugins/rps_scores.json create mode 100644 plugins/scramble_scores.json create mode 100644 plugins/word_scramble_plugin.js diff --git a/botmain.js b/botmain.js index 8b26f70..275cf00 100644 --- a/botmain.js +++ b/botmain.js @@ -32,8 +32,20 @@ class IRCBot { this.commands = new Map(); this.connected = false; + // Rate limiting system + this.rateLimits = new Map(); // nick -> { commands: [], blocked: false, blockExpiry: null } + this.rateLimitConfig = { + maxCommands: 5, // Max commands per window + windowMs: 30000, // 30 second window + blockDurationMs: 60000, // 1 minute block + cleanupInterval: 300000, // 5 minutes cleanup + adminWhitelist: ['megasconed'] // Users exempt from rate limiting + }; + console.log('๐Ÿ”ง Bot configuration:', this.config); + console.log('๐Ÿšฆ Rate limiting enabled:', this.rateLimitConfig); this.loadPlugins(); + this.startRateLimitCleanup(); } connect() { @@ -129,12 +141,17 @@ class IRCBot { const commandLine = msg.message.substring(this.config.commandPrefix.length); const [commandName, ...args] = commandLine.split(' '); - // Handle built-in admin commands first + // Handle built-in admin commands first (keeping legacy support) if (commandName === 'reloadplugins' && msg.nick === 'megasconed') { - this.say(replyTo, '๐Ÿ”„ Reloading all plugins...'); + this.say(replyTo, '๐Ÿ”„ Reloading all plugins... (use !reload command next time)'); const pluginCount = this.plugins.size; - this.reloadAllPlugins(); - this.say(replyTo, `โœ… Reloaded ${pluginCount} plugins. New commands: ${Array.from(this.commands.keys()).join(', ')}`); + try { + this.reloadAllPlugins(); + this.say(replyTo, `โœ… Reloaded ${pluginCount} plugins. New commands: ${Array.from(this.commands.keys()).join(', ')}`); + } catch (error) { + console.error('โŒ Error during plugin reload:', error); + this.say(replyTo, 'โŒ Error during plugin reload - check console for details.'); + } return; } @@ -163,6 +180,14 @@ class IRCBot { } executeCommand(commandName, context) { + // Check rate limit BEFORE executing command + if (this.isRateLimited(context.nick, context.replyTo)) { + return; // User is rate limited, ignore command + } + + // Track this command + this.trackCommand(context.nick); + const command = this.commands.get(commandName); if (command) { try { @@ -335,32 +360,64 @@ module.exports = { } reloadPlugin(filename) { + console.log(`๐Ÿ”„ Reloading plugin: ${filename}`); + const plugin = this.plugins.get(filename); if (plugin) { // Unregister old commands if (plugin.commands) { plugin.commands.forEach(command => { this.commands.delete(command.name); + console.log(`๐Ÿ—‘๏ธ Unregistered command: ${command.name}`); }); } // Call cleanup if available if (typeof plugin.cleanup === 'function') { - plugin.cleanup(this); + try { + plugin.cleanup(this); + console.log(`๐Ÿงน Cleaned up plugin: ${filename}`); + } catch (error) { + console.error(`โŒ Error cleaning up plugin ${filename}:`, error); + } } this.plugins.delete(filename); } + // Clear require cache for this specific plugin + const pluginPath = path.resolve(this.config.pluginsDir, filename); + if (require.cache[pluginPath]) { + delete require.cache[pluginPath]; + console.log(`๐Ÿ—‘๏ธ Cleared cache for: ${filename}`); + } + // Load the plugin again this.loadPlugin(filename); } reloadAllPlugins() { + console.log('๐Ÿ”„ Starting plugin reload process...'); + // Clear all plugins and commands this.plugins.forEach((plugin, filename) => { if (typeof plugin.cleanup === 'function') { - plugin.cleanup(this); + try { + plugin.cleanup(this); + console.log(`๐Ÿงน Cleaned up plugin: ${filename}`); + } catch (error) { + console.error(`โŒ Error cleaning up plugin ${filename}:`, error); + } + } + }); + + // Clear require cache for all plugin files + console.log('๐Ÿ—‘๏ธ Clearing require cache...'); + const pluginDir = path.resolve(this.config.pluginsDir); + Object.keys(require.cache).forEach(filepath => { + if (filepath.startsWith(pluginDir)) { + delete require.cache[filepath]; + console.log(`๐Ÿ—‘๏ธ Cleared cache for: ${path.basename(filepath)}`); } }); @@ -368,7 +425,9 @@ module.exports = { this.commands.clear(); // Reload all plugins + console.log('๐Ÿ”„ Reloading all plugins...'); this.loadPlugins(); + console.log('โœ… Plugin reload complete'); } // Helper methods for plugins @@ -404,6 +463,113 @@ module.exports = { } }); } + + // Rate limiting methods + isRateLimited(nick, replyTo) { + // Check if user is whitelisted (admin) + if (this.rateLimitConfig.adminWhitelist.includes(nick)) { + return false; + } + + const now = Date.now(); + const userLimits = this.rateLimits.get(nick); + + if (!userLimits) { + // First time user, no limits + return false; + } + + // Check if user is currently blocked + if (userLimits.blocked && now < userLimits.blockExpiry) { + const remainingMs = userLimits.blockExpiry - now; + const remainingSeconds = Math.ceil(remainingMs / 1000); + console.log(`๐Ÿšซ Rate limited user ${nick} tried command, ${remainingSeconds}s remaining`); + return true; + } + + // If block expired, unblock user + if (userLimits.blocked && now >= userLimits.blockExpiry) { + userLimits.blocked = false; + userLimits.blockExpiry = null; + userLimits.commands = []; + console.log(`โœ… Rate limit expired for ${nick}`); + return false; + } + + // Count recent commands in sliding window + const windowStart = now - this.rateLimitConfig.windowMs; + const recentCommands = userLimits.commands.filter(timestamp => timestamp > windowStart); + + // If user exceeds limit, block them + if (recentCommands.length >= this.rateLimitConfig.maxCommands) { + userLimits.blocked = true; + userLimits.blockExpiry = now + this.rateLimitConfig.blockDurationMs; + + const blockMinutes = Math.ceil(this.rateLimitConfig.blockDurationMs / 60000); + this.say(replyTo, `โš ๏ธ ${nick}: Rate limit exceeded! Please wait ${blockMinutes} minute(s) before using commands again.`); + console.log(`๐Ÿšซ Rate limited ${nick} for ${blockMinutes} minute(s)`); + return true; + } + + return false; + } + + trackCommand(nick) { + // Don't track admin commands + if (this.rateLimitConfig.adminWhitelist.includes(nick)) { + return; + } + + const now = Date.now(); + + if (!this.rateLimits.has(nick)) { + this.rateLimits.set(nick, { + commands: [], + blocked: false, + blockExpiry: null + }); + } + + const userLimits = this.rateLimits.get(nick); + userLimits.commands.push(now); + + // Clean up old command timestamps (sliding window) + const windowStart = now - this.rateLimitConfig.windowMs; + userLimits.commands = userLimits.commands.filter(timestamp => timestamp > windowStart); + } + + startRateLimitCleanup() { + // Clean up old rate limit entries every 5 minutes + setInterval(() => { + const now = Date.now(); + const cutoffTime = now - (this.rateLimitConfig.windowMs * 2); // Keep data for 2x window time + + let cleanedUsers = 0; + for (const [nick, limits] of this.rateLimits.entries()) { + // Remove users who haven't used commands recently and aren't blocked + if (!limits.blocked && limits.commands.length === 0) { + this.rateLimits.delete(nick); + cleanedUsers++; + } else if (!limits.blocked) { + // Clean up old command timestamps + const oldLength = limits.commands.length; + limits.commands = limits.commands.filter(timestamp => timestamp > cutoffTime); + + // If no recent commands, remove user + if (limits.commands.length === 0) { + this.rateLimits.delete(nick); + cleanedUsers++; + } + } + } + + if (cleanedUsers > 0) { + console.log(`๐Ÿงน Cleaned up ${cleanedUsers} old rate limit entries`); + } + }, this.rateLimitConfig.cleanupInterval); + + console.log(`๐Ÿงน Rate limit cleanup scheduled every ${this.rateLimitConfig.cleanupInterval / 1000}s`); + } } // Create and start bot diff --git a/plugins/basic.js b/plugins/basic.js index bbcda09..11d109c 100644 --- a/plugins/basic.js +++ b/plugins/basic.js @@ -33,6 +33,76 @@ module.exports = { const now = new Date().toLocaleString(); bot.say(context.replyTo, `Current time: ${now}`); } + }, + + { + name: 'ratelimit', + description: 'Check your rate limit status', + execute(context, bot) { + // Only allow in #bakedbeans or for admins + if (context.channel !== '#bakedbeans' && !bot.rateLimitConfig.adminWhitelist.includes(context.nick)) { + bot.say(context.replyTo, '๐Ÿšซ This command can only be used in #bakedbeans!'); + return; + } + + const userLimits = bot.rateLimits.get(context.nick); + const config = bot.rateLimitConfig; + + if (config.adminWhitelist.includes(context.nick)) { + bot.say(context.replyTo, `โœ… ${context.nick}: You are whitelisted (no rate limits)`); + return; + } + + if (!userLimits) { + bot.say(context.replyTo, `โœ… ${context.nick}: No rate limit data (you're clean!)`); + return; + } + + const now = Date.now(); + + if (userLimits.blocked && now < userLimits.blockExpiry) { + const remainingMs = userLimits.blockExpiry - now; + const remainingSeconds = Math.ceil(remainingMs / 1000); + bot.say(context.replyTo, `๐Ÿšซ ${context.nick}: Rate limited for ${remainingSeconds} more seconds`); + return; + } + + const windowStart = now - config.windowMs; + const recentCommands = userLimits.commands.filter(timestamp => timestamp > windowStart); + const remaining = config.maxCommands - recentCommands.length; + + bot.say(context.replyTo, `๐Ÿ“Š ${context.nick}: ${recentCommands.length}/${config.maxCommands} commands used in last ${config.windowMs/1000}s (${remaining} remaining)`); + } + }, + + { + name: 'reload', + description: 'Reload all plugins (admin only)', + execute(context, bot) { + // Only allow for admin + if (context.nick !== 'megasconed') { + bot.say(context.replyTo, '๐Ÿšซ Only admins can reload plugins!'); + return; + } + + // Only allow in #bakedbeans + if (context.channel !== '#bakedbeans') { + bot.say(context.replyTo, '๐Ÿšซ Plugin reload can only be done in #bakedbeans!'); + return; + } + + bot.say(context.replyTo, '๐Ÿ”„ Reloading all plugins...'); + const pluginCount = bot.plugins.size; + + try { + bot.reloadAllPlugins(); + const newCommandCount = bot.commands.size; + bot.say(context.replyTo, `โœ… Reloaded ${pluginCount} plugins successfully! Now have ${newCommandCount} commands available.`); + } catch (error) { + console.error('โŒ Error during plugin reload:', error); + bot.say(context.replyTo, 'โŒ Error during plugin reload - check console for details.'); + } + } } ] }; \ No newline at end of file diff --git a/plugins/duck_hunt_plugin.js b/plugins/duck_hunt_plugin.js index 4af637f..44217bf 100644 --- a/plugins/duck_hunt_plugin.js +++ b/plugins/duck_hunt_plugin.js @@ -70,22 +70,43 @@ module.exports = { name: 'duckscore', description: 'Check your duck hunting score', execute(context, bot) { - const score = module.exports.gameState.scores.get(context.nick) || 0; - bot.say(context.replyTo, `๐ŸŽฏ ${context.nick} has shot ${score} ducks!`); + const playerData = module.exports.gameState.scores.get(context.nick); + if (!playerData) { + bot.say(context.replyTo, `๐ŸŽฏ ${context.nick} hasn't shot any ducks yet!`); + return; + } + + const points = module.exports.getTotalPoints(playerData); + const ducks = module.exports.getTotalDucks(playerData); + const isMigrated = playerData.migrated; + + if (isMigrated && ducks === 0) { + bot.say(context.replyTo, `๐ŸŽฏ ${context.nick}: ${points} points (duck count tracked from next shot)`); + } else { + bot.say(context.replyTo, `๐ŸŽฏ ${context.nick}: ${points} points from ${ducks} ducks!`); + } } }, { name: 'topducks', - description: 'Show top duck hunters', + description: 'Show top duck hunters by points', execute(context, bot) { module.exports.showLeaderboard(context, bot); } }, + { + name: 'duckcount', + description: 'Show top duck hunters by quantity', + execute(context, bot) { + module.exports.showDuckCount(context, bot); + } + }, + { name: 'duckstats', - description: 'Show duck hunting statistics', + description: 'Show duck hunting statistics and most shot birds', execute(context, bot) { module.exports.showStats(context, bot); } @@ -293,10 +314,9 @@ module.exports = { // Remove duck from active ducks this.gameState.activeDucks.delete(channel); - // Award points + // Award points and track duck type const duck = activeDuck.duck; - const currentScore = this.gameState.scores.get(context.nick) || 0; - this.gameState.scores.set(context.nick, currentScore + duck.points); + this.updatePlayerScore(context.nick, duck.name, duck.points); // Save scores to file after each duck shot this.saveScores(); @@ -383,10 +403,9 @@ module.exports = { // Remove duck from active ducks this.gameState.activeDucks.delete(channel); - // Award points (same as hunting) + // Award points and track duck type (same as hunting) const duck = activeDuck.duck; - const currentScore = this.gameState.scores.get(context.nick) || 0; - this.gameState.scores.set(context.nick, currentScore + duck.points); + this.updatePlayerScore(context.nick, duck.name, duck.points); // Save scores to file after each duck fed this.saveScores(); @@ -450,7 +469,7 @@ module.exports = { showLeaderboard(context, bot) { const scores = Array.from(this.gameState.scores.entries()) - .sort((a, b) => b[1] - a[1]) + .sort((a, b) => this.getTotalPoints(b[1]) - this.getTotalPoints(a[1])) .slice(0, 10); if (scores.length === 0) { @@ -458,22 +477,70 @@ module.exports = { return; } - bot.say(context.replyTo, '๐Ÿ† TOP DUCK HUNTERS ๐Ÿ†'); + bot.say(context.replyTo, '๐Ÿ† TOP DUCK HUNTERS (by points) ๐Ÿ†'); scores.forEach((score, index) => { const medal = index === 0 ? '๐Ÿฅ‡' : index === 1 ? '๐Ÿฅˆ' : index === 2 ? '๐Ÿฅ‰' : '๐ŸŽฏ'; - bot.say(context.replyTo, `${medal} ${index + 1}. ${score[0]}: ${score[1]} ducks`); + const points = this.getTotalPoints(score[1]); + bot.say(context.replyTo, `${medal} ${index + 1}. ${score[0]}: ${points} points`); }); }, + showDuckCount(context, bot) { + // Filter out migrated users with 0 duck count for this leaderboard + const scores = Array.from(this.gameState.scores.entries()) + .filter(([nick, data]) => { + const ducks = this.getTotalDucks(data); + return ducks > 0 || !data.migrated; // Include non-migrated users even with 0 ducks + }) + .sort((a, b) => this.getTotalDucks(b[1]) - this.getTotalDucks(a[1])) + .slice(0, 10); + + if (scores.length === 0) { + bot.say(context.replyTo, '๐Ÿฆ† No one has shot any tracked ducks yet! Get hunting!'); + bot.say(context.replyTo, '๐Ÿ“ Note: Duck counts are tracked from new shots only (not migrated data)'); + return; + } + + bot.say(context.replyTo, '๐Ÿฆ† TOP DUCK COUNTERS (by quantity) ๐Ÿฆ†'); + scores.forEach((score, index) => { + const medal = index === 0 ? '๐Ÿฅ‡' : index === 1 ? '๐Ÿฅˆ' : index === 2 ? '๐Ÿฅ‰' : '๐ŸŽฏ'; + const ducks = this.getTotalDucks(score[1]); + bot.say(context.replyTo, `${medal} ${index + 1}. ${score[0]}: ${ducks} ducks`); + }); + }, + showStats(context, bot) { - const totalDucks = Array.from(this.gameState.scores.values()).reduce((a, b) => a + b, 0); + const totalDucks = Array.from(this.gameState.scores.values()).reduce((a, b) => a + this.getTotalDucks(b), 0); + const totalPoints = Array.from(this.gameState.scores.values()).reduce((a, b) => a + this.getTotalPoints(b), 0); const totalHunters = this.gameState.scores.size; const activeDucks = this.gameState.activeDucks.size; - bot.say(context.replyTo, `๐Ÿฆ† DUCK HUNT STATS ๐Ÿฆ†`); + // Calculate most shot duck types + const duckTypeStats = {}; + this.gameState.scores.forEach(playerData => { + if (typeof playerData === 'object' && playerData.duckTypes) { + Object.entries(playerData.duckTypes).forEach(([duckType, count]) => { + duckTypeStats[duckType] = (duckTypeStats[duckType] || 0) + count; + }); + } + }); + + const sortedDuckTypes = Object.entries(duckTypeStats) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + + bot.say(context.replyTo, `๐Ÿฆ† DUCK HUNT STATISTICS ๐Ÿฆ†`); bot.say(context.replyTo, `๐ŸŽฏ Total ducks shot: ${totalDucks}`); + bot.say(context.replyTo, `๐Ÿ† Total points earned: ${totalPoints}`); bot.say(context.replyTo, `๐Ÿน Active hunters: ${totalHunters}`); bot.say(context.replyTo, `๐Ÿฆ† Ducks currently in channels: ${activeDucks}`); + + if (sortedDuckTypes.length > 0) { + bot.say(context.replyTo, `๐Ÿ“Š Most hunted: ${sortedDuckTypes[0][0]} (${sortedDuckTypes[0][1]} times)`); + if (sortedDuckTypes.length > 1) { + bot.say(context.replyTo, `๐Ÿฅˆ Second most: ${sortedDuckTypes[1][0]} (${sortedDuckTypes[1][1]} times)`); + } + } }, startDuckSpawning(bot) { @@ -561,16 +628,40 @@ module.exports = { const data = fs.readFileSync(this.scoresFile, 'utf8'); const scoresObject = JSON.parse(data); - // Convert back to Map - this.gameState.scores = new Map(Object.entries(scoresObject)); + // Convert back to Map and migrate old format if needed + this.gameState.scores = new Map(); + let migrationNeeded = false; + + for (const [nick, scoreData] of Object.entries(scoresObject)) { + // Check if this is old format (just a number) or new format (object) + if (typeof scoreData === 'number') { + // Old format: migrate to new structure + this.gameState.scores.set(nick, { + totalPoints: scoreData, + totalDucks: 0, // We don't know the real duck count, start fresh + duckTypes: {}, + migrated: true // Flag to indicate this is migrated data + }); + migrationNeeded = true; + } else { + // New format: use as-is + this.gameState.scores.set(nick, scoreData); + } + } + console.log(`๐Ÿฆ† Loaded ${this.gameState.scores.size} duck hunter scores from ${this.scoresFile}`); + if (migrationNeeded) { + console.log('๐Ÿ“ˆ Migrated old score format to new detailed tracking'); + this.saveScores(); // Save migrated data + } + // Log top 3 hunters if any exist if (this.gameState.scores.size > 0) { const topHunters = Array.from(this.gameState.scores.entries()) - .sort((a, b) => b[1] - a[1]) + .sort((a, b) => this.getTotalPoints(b[1]) - this.getTotalPoints(a[1])) .slice(0, 3); - console.log('๐Ÿ† Top duck hunters:', topHunters.map(([name, score]) => `${name}(${score})`).join(', ')); + console.log('๐Ÿ† Top duck hunters:', topHunters.map(([name, data]) => `${name}(${this.getTotalPoints(data)}pts)`).join(', ')); } } else { console.log(`๐Ÿฆ† No existing duck hunt scores file found, starting fresh`); @@ -593,5 +684,48 @@ module.exports = { } catch (error) { console.error(`โŒ Error saving duck hunt scores:`, error); } + }, + + // Helper functions for new scoring system + getTotalPoints(playerData) { + if (typeof playerData === 'number') { + return playerData; // Old format compatibility + } + return playerData.totalPoints || 0; + }, + + getTotalDucks(playerData) { + if (typeof playerData === 'number') { + return playerData; // Old format estimate + } + return playerData.totalDucks || 0; + }, + + initializePlayerData(nick) { + if (!this.gameState.scores.has(nick)) { + this.gameState.scores.set(nick, { + totalPoints: 0, + totalDucks: 0, + duckTypes: {}, + migrated: false // New players have accurate data from the start + }); + } + }, + + updatePlayerScore(nick, duckName, points) { + this.initializePlayerData(nick); + const playerData = this.gameState.scores.get(nick); + + // Update totals + playerData.totalPoints += points; + playerData.totalDucks += 1; + + // Update duck type count + if (!playerData.duckTypes[duckName]) { + playerData.duckTypes[duckName] = 0; + } + playerData.duckTypes[duckName] += 1; + + this.gameState.scores.set(nick, playerData); } }; \ No newline at end of file diff --git a/plugins/duck_hunt_scores.json b/plugins/duck_hunt_scores.json index f76a2d3..9e26dfe 100644 --- a/plugins/duck_hunt_scores.json +++ b/plugins/duck_hunt_scores.json @@ -1,3 +1 @@ -{ - "Monqui": 5 -} \ No newline at end of file +{} \ No newline at end of file diff --git a/plugins/rock_paper_scissors_plugin.js b/plugins/rock_paper_scissors_plugin.js new file mode 100644 index 0000000..bcc4905 --- /dev/null +++ b/plugins/rock_paper_scissors_plugin.js @@ -0,0 +1,594 @@ +// plugins/rock_paper_scissors_plugin.js - Rock Paper Scissors Game Plugin +const fs = require('fs'); +const path = require('path'); + +module.exports = { + gameState: { + activeGames: new Map(), // channel -> game data + challenges: new Map(), // channel -> challenge data + scores: new Map() // nick -> score data + }, + + // Game choices and their relationships + choices: { + 'rock': { + emoji: '๐Ÿชจ', + beats: 'scissors', + losesTo: 'paper' + }, + 'paper': { + emoji: '๐Ÿ“„', + beats: 'rock', + losesTo: 'scissors' + }, + 'scissors': { + emoji: 'โœ‚๏ธ', + beats: 'paper', + losesTo: 'rock' + } + }, + + // Aliases for user input + aliases: { + 'r': 'rock', + 'p': 'paper', + 's': 'scissors', + 'stone': 'rock', + 'scissor': 'scissors' + }, + + init(bot) { + console.log('Rock Paper Scissors plugin initialized - Ready to rumble! ๐Ÿชจ๐Ÿ“„โœ‚๏ธ'); + + // Set up score file path + this.scoresFile = path.join(__dirname, 'rps_scores.json'); + + // Load existing scores + this.loadScores(); + }, + + cleanup(bot) { + console.log('Rock Paper Scissors plugin cleaned up'); + + // Save scores before cleanup + this.saveScores(); + + // Clear all active games and timers + this.gameState.activeGames.forEach(game => { + if (game.timer) { + clearTimeout(game.timer); + } + }); + this.gameState.activeGames.clear(); + + // Clear all challenges and timers + this.gameState.challenges.forEach(challenge => { + if (challenge.timer) { + clearTimeout(challenge.timer); + } + }); + this.gameState.challenges.clear(); + }, + + commands: [ + { + name: 'rps', + description: 'Play Rock Paper Scissors (!rps vs bot OR !rps @player)', + execute(context, bot) { + module.exports.startGame(context, bot); + } + }, + + { + name: 'rock', + description: 'Play rock in active RPS game', + execute(context, bot) { + module.exports.playMove(context, bot, 'rock'); + } + }, + + { + name: 'paper', + description: 'Play paper in active RPS game', + execute(context, bot) { + module.exports.playMove(context, bot, 'paper'); + } + }, + + { + name: 'scissors', + description: 'Play scissors in active RPS game', + execute(context, bot) { + module.exports.playMove(context, bot, 'scissors'); + } + }, + + { + name: 'accept', + description: 'Accept a Rock Paper Scissors challenge', + execute(context, bot) { + module.exports.acceptChallenge(context, bot); + } + }, + + { + name: 'rpsstats', + description: 'Show your Rock Paper Scissors statistics', + execute(context, bot) { + module.exports.showPlayerStats(context, bot); + } + }, + + { + name: 'toprps', + description: 'Show top Rock Paper Scissors players', + execute(context, bot) { + module.exports.showLeaderboard(context, bot); + } + }, + + { + name: 'rpsscore', + description: 'Show your current RPS score', + execute(context, bot) { + module.exports.showScore(context, bot); + } + } + ], + + startGame(context, bot) { + const channel = context.channel; + if (!channel) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ Rock Paper Scissors can only be played in channels!'); + return; + } + + // Check if there's already an active game + if (this.gameState.activeGames.has(channel)) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ There\'s already an active RPS game in this channel!'); + return; + } + + // Check if there's already a challenge + if (this.gameState.challenges.has(channel)) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ There\'s already a pending challenge in this channel!'); + return; + } + + const args = context.args; + + // Check if challenging another player + if (args.length > 0) { + const target = args[0].replace('@', ''); + + if (target === context.nick) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ You can\'t challenge yourself! Use !rps to play vs the bot.'); + return; + } + + if (target === bot.config.nick) { + // Play against bot + this.startBotGame(context, bot, channel); + return; + } + + // Challenge another player + this.createChallenge(context, bot, channel, target); + return; + } + + // Default: play against bot + this.startBotGame(context, bot, channel); + }, + + startBotGame(context, bot, channel) { + const gameData = { + type: 'bot', + player: context.nick, + startTime: Date.now(), + playerMove: null, + botMove: null + }; + + // Set 30 second timer + gameData.timer = setTimeout(() => { + this.timeoutGame(channel, bot, gameData); + }, 30000); + + this.gameState.activeGames.set(channel, gameData); + + bot.say(channel, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${context.nick} vs ${bot.config.nick}! Use !rock, !paper, or !scissors (30s limit)`); + }, + + createChallenge(context, bot, channel, target) { + const challengeData = { + challenger: context.nick, + target: target, + startTime: Date.now() + }; + + // Set 60 second timer for challenge acceptance + challengeData.timer = setTimeout(() => { + this.timeoutChallenge(channel, bot, challengeData); + }, 60000); + + this.gameState.challenges.set(channel, challengeData); + + bot.say(channel, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${context.nick} challenges ${target} to Rock Paper Scissors! Use !accept to accept (60s limit)`); + }, + + acceptChallenge(context, bot) { + const channel = context.channel; + if (!channel) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ Challenges can only be accepted in channels!'); + return; + } + + const challenge = this.gameState.challenges.get(channel); + if (!challenge) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ No active challenge to accept!'); + return; + } + + if (context.nick !== challenge.target) { + bot.say(context.replyTo, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ This challenge is for ${challenge.target}, not you!`); + return; + } + + // Clear challenge timer + clearTimeout(challenge.timer); + this.gameState.challenges.delete(channel); + + // Start player vs player game + this.startPlayerGame(bot, channel, challenge.challenger, challenge.target); + }, + + startPlayerGame(bot, channel, player1, player2) { + const gameData = { + type: 'player', + player1: player1, + player2: player2, + startTime: Date.now(), + moves: new Map(), // nick -> move + revealed: false + }; + + // Set 45 second timer + gameData.timer = setTimeout(() => { + this.timeoutGame(channel, bot, gameData); + }, 45000); + + this.gameState.activeGames.set(channel, gameData); + + bot.say(channel, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${player1} vs ${player2}! Both players PM the bot your moves: /msg ${bot.config.nick} !rock, !paper, or !scissors (45s limit)`); + }, + + playMove(context, bot, move) { + const channel = context.channel; + + // If in a channel (bot vs player games) + if (channel) { + const game = this.gameState.activeGames.get(channel); + if (!game) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ No active RPS game to play in!'); + return; + } + + if (game.type === 'bot') { + this.handleBotMove(context, bot, channel, game, move); + } else { + this.handlePlayerMove(context, bot, channel, game, move); + } + return; + } + + // If in PM (player vs player games) + // Find the game this player is in + let foundGame = null; + let foundChannel = null; + + for (const [channelName, game] of this.gameState.activeGames) { + if (game.type === 'player' && (game.player1 === context.nick || game.player2 === context.nick)) { + foundGame = game; + foundChannel = channelName; + break; + } + } + + if (!foundGame) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ You\'re not in an active player vs player game!'); + return; + } + + this.handlePlayerMove(context, bot, foundChannel, foundGame, move); + }, + + handleBotMove(context, bot, channel, game, move) { + if (context.nick !== game.player) { + bot.say(context.replyTo, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ This game is for ${game.player}!`); + return; + } + + if (game.playerMove) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ You already made your move!'); + return; + } + + // Player made their move + game.playerMove = move; + + // Bot makes random move + const botChoices = ['rock', 'paper', 'scissors']; + game.botMove = botChoices[Math.floor(Math.random() * botChoices.length)]; + + // Clear timer + clearTimeout(game.timer); + + // Determine winner and show result + this.resolveGame(channel, bot, game); + }, + + handlePlayerMove(context, bot, channel, game, move) { + if (context.nick !== game.player1 && context.nick !== game.player2) { + bot.say(context.replyTo, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ This game is for ${game.player1} and ${game.player2}!`); + return; + } + + // For player vs player games, moves must be made via PM + if (context.channel) { + bot.say(context.replyTo, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${context.nick}: Send your move via PM to keep it secret! Use /msg ${bot.config.nick} !rock, !paper, or !scissors`); + return; + } + + if (game.moves.has(context.nick)) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ You already made your move!'); + return; + } + + // Record move + game.moves.set(context.nick, move); + + // Send confirmation via private message + bot.say(context.nick, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ You played ${this.choices[move].emoji} ${move}!`); + + // Check if both players have moved + if (game.moves.size === 2) { + // Clear timer + clearTimeout(game.timer); + + // Resolve game + this.resolveGame(channel, bot, game); + } else { + // Wait for other player + const waitingFor = game.player1 === context.nick ? game.player2 : game.player1; + bot.say(channel, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ Waiting for ${waitingFor} to make their move via PM...`); + } + }, + + resolveGame(channel, bot, game) { + this.gameState.activeGames.delete(channel); + + if (game.type === 'bot') { + this.resolveBotGame(channel, bot, game); + } else { + this.resolvePlayerGame(channel, bot, game); + } + }, + + resolveBotGame(channel, bot, game) { + const playerMove = game.playerMove; + const botMove = game.botMove; + + const playerChoice = this.choices[playerMove]; + const botChoice = this.choices[botMove]; + + // Show moves + bot.say(channel, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${game.player}: ${playerChoice.emoji} | ${bot.config.nick}: ${botChoice.emoji}`); + + // Determine result + let result; + if (playerMove === botMove) { + result = 'draw'; + bot.say(channel, `๐Ÿค It's a draw! Both played ${playerMove}!`); + } else if (playerChoice.beats === botMove) { + result = 'win'; + bot.say(channel, `๐ŸŽ‰ ${game.player} wins! ${playerMove} beats ${botMove}!`); + } else { + result = 'loss'; + bot.say(channel, `๐Ÿค– ${bot.config.nick} wins! ${botMove} beats ${playerMove}!`); + } + + // Update scores + this.updatePlayerScore(game.player, result, 'bot'); + this.saveScores(); + }, + + resolvePlayerGame(channel, bot, game) { + const player1Move = game.moves.get(game.player1); + const player2Move = game.moves.get(game.player2); + + const player1Choice = this.choices[player1Move]; + const player2Choice = this.choices[player2Move]; + + // Show moves + bot.say(channel, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${game.player1}: ${player1Choice.emoji} | ${game.player2}: ${player2Choice.emoji}`); + + // Determine result + if (player1Move === player2Move) { + bot.say(channel, `๐Ÿค It's a draw! Both played ${player1Move}!`); + this.updatePlayerScore(game.player1, 'draw', 'player'); + this.updatePlayerScore(game.player2, 'draw', 'player'); + } else if (player1Choice.beats === player2Move) { + bot.say(channel, `๐ŸŽ‰ ${game.player1} wins! ${player1Move} beats ${player2Move}!`); + this.updatePlayerScore(game.player1, 'win', 'player'); + this.updatePlayerScore(game.player2, 'loss', 'player'); + } else { + bot.say(channel, `๐ŸŽ‰ ${game.player2} wins! ${player2Move} beats ${player1Move}!`); + this.updatePlayerScore(game.player2, 'win', 'player'); + this.updatePlayerScore(game.player1, 'loss', 'player'); + } + + this.saveScores(); + }, + + timeoutGame(channel, bot, game) { + this.gameState.activeGames.delete(channel); + + if (game.type === 'bot') { + bot.say(channel, `โฐ ${game.player} took too long to make a move! Game cancelled.`); + } else { + const missingPlayers = []; + if (!game.moves.has(game.player1)) missingPlayers.push(game.player1); + if (!game.moves.has(game.player2)) missingPlayers.push(game.player2); + + bot.say(channel, `โฐ Game timed out! ${missingPlayers.join(' and ')} didn't make their moves.`); + } + }, + + timeoutChallenge(channel, bot, challenge) { + this.gameState.challenges.delete(channel); + bot.say(channel, `โฐ Challenge timed out! ${challenge.target} didn't accept the challenge.`); + }, + + // Player score management + initializePlayerData(nick) { + if (!this.gameState.scores.has(nick)) { + this.gameState.scores.set(nick, { + totalPoints: 0, + totalGames: 0, + wins: 0, + losses: 0, + draws: 0, + winStreak: 0, + longestWinStreak: 0, + vsBot: { + wins: 0, + losses: 0, + draws: 0 + }, + vsPlayer: { + wins: 0, + losses: 0, + draws: 0 + }, + favoriteMove: null, + moveStats: { + rock: 0, + paper: 0, + scissors: 0 + } + }); + } + }, + + updatePlayerScore(nick, result, gameType) { + this.initializePlayerData(nick); + const playerData = this.gameState.scores.get(nick); + + // Update totals + playerData.totalGames += 1; + playerData[result + 's'] += 1; + + // Update game type stats + playerData[gameType === 'bot' ? 'vsBot' : 'vsPlayer'][result + 's'] += 1; + + // Update points and streaks + if (result === 'win') { + playerData.totalPoints += 3; + playerData.winStreak += 1; + if (playerData.winStreak > playerData.longestWinStreak) { + playerData.longestWinStreak = playerData.winStreak; + } + } else if (result === 'draw') { + playerData.totalPoints += 1; + playerData.winStreak = 0; + } else { + playerData.winStreak = 0; + } + + this.gameState.scores.set(nick, playerData); + }, + + showScore(context, bot) { + const playerData = this.gameState.scores.get(context.nick); + if (!playerData) { + bot.say(context.replyTo, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${context.nick} hasn't played any RPS games yet!`); + return; + } + + const winRate = playerData.totalGames > 0 ? (playerData.wins / playerData.totalGames * 100).toFixed(1) : 0; + bot.say(context.replyTo, `๐ŸŽฏ ${context.nick}: ${playerData.totalPoints} points | ${playerData.wins}W-${playerData.losses}L-${playerData.draws}D (${winRate}% win rate)`); + }, + + showPlayerStats(context, bot) { + const playerData = this.gameState.scores.get(context.nick); + if (!playerData) { + bot.say(context.replyTo, `๐Ÿชจ๐Ÿ“„โœ‚๏ธ ${context.nick} hasn't played any RPS games yet!`); + return; + } + + const winRate = playerData.totalGames > 0 ? (playerData.wins / playerData.totalGames * 100).toFixed(1) : 0; + + bot.say(context.replyTo, `๐Ÿ“Š ${context.nick}'s RPS Stats:`); + bot.say(context.replyTo, `๐ŸŽฏ Points: ${playerData.totalPoints} | Games: ${playerData.totalGames} | Win Rate: ${winRate}%`); + bot.say(context.replyTo, `๐Ÿ† Record: ${playerData.wins}W-${playerData.losses}L-${playerData.draws}D | Streak: ${playerData.winStreak} (best: ${playerData.longestWinStreak})`); + bot.say(context.replyTo, `๐Ÿค– vs Bot: ${playerData.vsBot.wins}W-${playerData.vsBot.losses}L-${playerData.vsBot.draws}D | ๐Ÿ‘ฅ vs Players: ${playerData.vsPlayer.wins}W-${playerData.vsPlayer.losses}L-${playerData.vsPlayer.draws}D`); + }, + + showLeaderboard(context, bot) { + const scores = Array.from(this.gameState.scores.entries()) + .sort((a, b) => b[1].totalPoints - a[1].totalPoints) + .slice(0, 10); + + if (scores.length === 0) { + bot.say(context.replyTo, '๐Ÿชจ๐Ÿ“„โœ‚๏ธ No one has played RPS yet!'); + return; + } + + bot.say(context.replyTo, '๐Ÿ† TOP RPS PLAYERS ๐Ÿ†'); + scores.forEach(([nick, data], index) => { + const medal = index === 0 ? '๐Ÿฅ‡' : index === 1 ? '๐Ÿฅˆ' : index === 2 ? '๐Ÿฅ‰' : '๐ŸŽฏ'; + const winRate = data.totalGames > 0 ? (data.wins / data.totalGames * 100).toFixed(1) : 0; + bot.say(context.replyTo, `${medal} ${index + 1}. ${nick}: ${data.totalPoints}pts (${data.wins}W-${data.losses}L-${data.draws}D, ${winRate}%)`); + }); + }, + + // Score persistence + loadScores() { + try { + if (fs.existsSync(this.scoresFile)) { + const data = fs.readFileSync(this.scoresFile, 'utf8'); + const scoresObject = JSON.parse(data); + + // Convert back to Map + this.gameState.scores = new Map(Object.entries(scoresObject)); + console.log(`๐Ÿชจ๐Ÿ“„โœ‚๏ธ Loaded ${this.gameState.scores.size} RPS scores from ${this.scoresFile}`); + + // Log top 3 players if any exist + if (this.gameState.scores.size > 0) { + const topPlayers = Array.from(this.gameState.scores.entries()) + .sort((a, b) => b[1].totalPoints - a[1].totalPoints) + .slice(0, 3); + console.log('๐Ÿ† Top RPS players:', topPlayers.map(([name, data]) => `${name}(${data.totalPoints}pts)`).join(', ')); + } + } else { + console.log(`๐Ÿชจ๐Ÿ“„โœ‚๏ธ No existing RPS scores file found, starting fresh`); + } + } catch (error) { + console.error(`โŒ Error loading RPS scores:`, error); + this.gameState.scores = new Map(); + } + }, + + saveScores() { + try { + const scoresObject = Object.fromEntries(this.gameState.scores); + const data = JSON.stringify(scoresObject, null, 2); + + fs.writeFileSync(this.scoresFile, data, 'utf8'); + console.log(`๐Ÿ’พ Saved ${this.gameState.scores.size} RPS scores to ${this.scoresFile}`); + } catch (error) { + console.error(`โŒ Error saving RPS scores:`, error); + } + } +}; \ No newline at end of file diff --git a/plugins/rps_scores.json b/plugins/rps_scores.json new file mode 100644 index 0000000..6bc8163 --- /dev/null +++ b/plugins/rps_scores.json @@ -0,0 +1,54 @@ +{ + "megasconed": { + "totalPoints": 1, + "totalGames": 3, + "wins": 0, + "losses": 0, + "draws": 1, + "winStreak": 0, + "longestWinStreak": 0, + "vsBot": { + "wins": 0, + "losses": 0, + "draws": 1 + }, + "vsPlayer": { + "wins": 0, + "losses": 0, + "draws": 0, + "losss": 1 + }, + "favoriteMove": null, + "moveStats": { + "rock": 0, + "paper": 0, + "scissors": 0 + }, + "losss": 1 + }, + "cr0sis": { + "totalPoints": 6, + "totalGames": 2, + "wins": 2, + "losses": 0, + "draws": 0, + "winStreak": 2, + "longestWinStreak": 2, + "vsBot": { + "wins": 0, + "losses": 0, + "draws": 0 + }, + "vsPlayer": { + "wins": 2, + "losses": 0, + "draws": 0 + }, + "favoriteMove": null, + "moveStats": { + "rock": 0, + "paper": 0, + "scissors": 0 + } + } +} \ No newline at end of file diff --git a/plugins/scramble_scores.json b/plugins/scramble_scores.json new file mode 100644 index 0000000..01ae1db --- /dev/null +++ b/plugins/scramble_scores.json @@ -0,0 +1,28 @@ +{ + "megasconed": { + "totalPoints": 6, + "totalSolved": 2, + "avgTime": 3.5725000000000002, + "bestTime": 2.657, + "currentStreak": 2, + "longestStreak": 2, + "difficultyStats": { + "easy": 1, + "medium": 0, + "hard": 1 + } + }, + "cr0sis": { + "totalPoints": 2, + "totalSolved": 1, + "avgTime": 19.268, + "bestTime": 19.268, + "currentStreak": 1, + "longestStreak": 1, + "difficultyStats": { + "easy": 0, + "medium": 1, + "hard": 0 + } + } +} \ No newline at end of file diff --git a/plugins/slots_scores.json b/plugins/slots_scores.json index ae7a0e8..19df367 100644 --- a/plugins/slots_scores.json +++ b/plugins/slots_scores.json @@ -1,8 +1,8 @@ { "megasconed": { - "totalWins": 57, + "totalWins": 62, "biggestWin": 33, - "totalSpins": 47, + "totalSpins": 60, "jackpots": 0, "name": "megasconed" }, diff --git a/plugins/word_scramble_plugin.js b/plugins/word_scramble_plugin.js new file mode 100644 index 0000000..1261f53 --- /dev/null +++ b/plugins/word_scramble_plugin.js @@ -0,0 +1,462 @@ +// plugins/word_scramble_plugin.js - Word Scramble Game Plugin +const fs = require('fs'); +const path = require('path'); + +module.exports = { + gameState: { + activeScrambles: new Map(), // channel -> scramble data + scores: new Map() // nick -> score data + }, + + // Word lists organized by difficulty - easy to expand! + wordLists: { + easy: [ + 'cat', 'dog', 'run', 'sun', 'fun', 'big', 'red', 'car', 'hat', 'map', + 'pen', 'box', 'key', 'cup', 'bag', 'egg', 'ice', 'zoo', 'owl', 'fox', + 'bus', 'toy', 'fly', 'sky', 'day', 'way', 'boy', 'joy', 'may', 'say', + 'boat', 'coat', 'goat', 'road', 'toad', 'load', 'cold', 'gold', 'hold', + 'book', 'look', 'took', 'cook', 'hook', 'good', 'wood', 'food', 'mood', + 'hand', 'land', 'band', 'sand', 'wind', 'kind', 'find', 'mind', 'wild' + ], + + medium: [ + 'house', 'mouse', 'horse', 'nurse', 'purse', 'curse', 'force', 'voice', + 'table', 'cable', 'fable', 'stable', 'noble', 'double', 'trouble', 'bubble', + 'water', 'later', 'paper', 'tiger', 'tower', 'power', 'flower', 'shower', + 'school', 'smooth', 'choose', 'cheese', 'freeze', 'breeze', 'please', 'sleeve', + 'market', 'basket', 'rocket', 'socket', 'pocket', 'ticket', 'jacket', 'bucket', + 'garden', 'golden', 'wooden', 'broken', 'spoken', 'chosen', 'frozen', 'proven', + 'friend', 'prince', 'bridge', 'orange', 'change', 'chance', 'france', 'dance', + 'winter', 'summer', 'spring', 'autumn', 'season', 'reason', 'person', 'lesson' + ], + + hard: [ + 'computer', 'elephant', 'treasure', 'building', 'mountain', 'champion', 'surprise', + 'together', 'birthday', 'question', 'language', 'sandwich', 'princess', 'keyboard', + 'umbrella', 'hospital', 'festival', 'material', 'original', 'terminal', 'personal', + 'elephant', 'telephone', 'envelope', 'magazine', 'medicine', 'adventure', 'passenger', + 'butterfly', 'wonderful', 'beautiful', 'dangerous', 'important', 'different', 'excellent', + 'knowledge', 'challenge', 'advantage', 'encourage', 'education', 'operation', 'direction', + 'beginning', 'happening', 'lightning', 'frightening', 'strengthening', 'interesting', + 'playground', 'background', 'understand', 'recommend', 'restaurant', 'government' + ] + }, + + init(bot) { + console.log('Word Scramble plugin initialized - Get ready to unscramble! ๐Ÿ”ค'); + + // Set up score file path + this.scoresFile = path.join(__dirname, 'scramble_scores.json'); + + // Load existing scores + this.loadScores(); + }, + + cleanup(bot) { + console.log('Word Scramble plugin cleaned up'); + + // Save scores before cleanup + this.saveScores(); + + // Clear all active scrambles and timers + this.gameState.activeScrambles.forEach(scramble => { + if (scramble.timer) { + clearTimeout(scramble.timer); + } + }); + this.gameState.activeScrambles.clear(); + }, + + commands: [ + { + name: 'scramble', + description: 'Start a word scramble game (easy/medium/hard)', + execute(context, bot) { + module.exports.startScramble(context, bot); + } + }, + + { + name: 'hint', + description: 'Get a hint for the current scramble', + execute(context, bot) { + module.exports.giveHint(context, bot); + } + }, + + { + name: 'skip', + description: 'Skip the current scramble', + execute(context, bot) { + module.exports.skipScramble(context, bot); + } + }, + + { + name: 'scramblestats', + description: 'Show your word scramble statistics', + execute(context, bot) { + module.exports.showPlayerStats(context, bot); + } + }, + + { + name: 'topscramble', + description: 'Show top word scramble players', + execute(context, bot) { + module.exports.showLeaderboard(context, bot); + } + }, + + { + name: 'scramblescore', + description: 'Show your current scramble score', + execute(context, bot) { + module.exports.showScore(context, bot); + } + } + ], + + // Handle channel messages for answer checking + onMessage(data, bot) { + if (data.isChannel) { + this.checkAnswer(data, bot); + } + }, + + startScramble(context, bot) { + const channel = context.channel; + if (!channel) { + bot.say(context.replyTo, '๐Ÿ”ค Word scramble can only be played in channels!'); + return; + } + + // Check if there's already an active scramble + if (this.gameState.activeScrambles.has(channel)) { + const scramble = this.gameState.activeScrambles.get(channel); + const timeLeft = Math.ceil((scramble.endTime - Date.now()) / 1000); + bot.say(context.replyTo, `๐Ÿ”ค Scramble already active! "${scramble.scrambledWord}" (${timeLeft}s left)`); + return; + } + + // Determine difficulty + const difficulty = this.parseDifficulty(context.args[0]); + + // Select random word + const word = this.getRandomWord(difficulty); + const scrambledWord = this.scrambleWord(word); + + // Create scramble data + const scrambleData = { + word: word, + scrambledWord: scrambledWord, + difficulty: difficulty, + startTime: Date.now(), + endTime: Date.now() + 60000, // 60 seconds + hintUsed: false, + starter: context.nick + }; + + // Set timer for timeout + scrambleData.timer = setTimeout(() => { + this.timeoutScramble(channel, bot, scrambleData); + }, 60000); + + this.gameState.activeScrambles.set(channel, scrambleData); + + // Announce the scramble + bot.say(channel, `๐Ÿ”ค WORD SCRAMBLE: "${scrambledWord}" (${word.length} letters, ${difficulty}) - 60s to solve!`); + bot.say(channel, `๐Ÿ’ก Use !hint for a clue or !skip to skip (started by ${context.nick})`); + + console.log(`๐Ÿ”ค Started scramble in ${channel}: ${word} โ†’ ${scrambledWord} (${difficulty})`); + }, + + checkAnswer(data, bot) { + const channel = data.target; + const scramble = this.gameState.activeScrambles.get(channel); + + if (!scramble) { + return; // No active scramble + } + + const answer = data.message.toLowerCase().trim(); + const correctAnswer = scramble.word.toLowerCase(); + + if (answer === correctAnswer) { + // Correct answer! + this.handleCorrectAnswer(channel, data.nick, scramble, bot); + } + }, + + handleCorrectAnswer(channel, nick, scramble, bot) { + // Clear timer + clearTimeout(scramble.timer); + + // Calculate solve time + const solveTime = (Date.now() - scramble.startTime) / 1000; + + // Calculate points + let points = this.getDifficultyPoints(scramble.difficulty); + + // Speed bonus + if (solveTime < 10) { + points += 1; + } + + // Hint penalty + if (scramble.hintUsed) { + points = Math.max(1, points - 1); + } + + // Update player score + this.updatePlayerScore(nick, scramble.difficulty, points, solveTime); + + // Save scores + this.saveScores(); + + // Remove scramble + this.gameState.activeScrambles.delete(channel); + + // Announce success + const speedBonus = solveTime < 10 ? ' โšก SPEED BONUS!' : ''; + const hintPenalty = scramble.hintUsed ? ' (hint used)' : ''; + bot.say(channel, `โœ… Correct! ${nick} solved "${scramble.word}" in ${solveTime.toFixed(1)}s (+${points} points)${speedBonus}${hintPenalty}`); + + console.log(`๐ŸŽฏ ${nick} solved scramble in ${channel}: ${scramble.word} (${solveTime.toFixed(1)}s, ${points}pts)`); + }, + + giveHint(context, bot) { + const channel = context.channel; + if (!channel) { + bot.say(context.replyTo, '๐Ÿ”ค Hints only work in channels!'); + return; + } + + const scramble = this.gameState.activeScrambles.get(channel); + if (!scramble) { + bot.say(context.replyTo, '๐Ÿ”ค No active scramble to give hints for!'); + return; + } + + if (scramble.hintUsed) { + bot.say(context.replyTo, '๐Ÿ’ก Hint already used for this scramble!'); + return; + } + + // Generate hint (first and last letter) + const word = scramble.word; + const hint = word.length > 2 ? + `${word[0]}${'_'.repeat(word.length - 2)}${word[word.length - 1]}` : + `${word[0]}${'_'.repeat(word.length - 1)}`; + + scramble.hintUsed = true; + bot.say(channel, `๐Ÿ’ก Hint: ${hint} (points reduced by 1)`); + }, + + skipScramble(context, bot) { + const channel = context.channel; + if (!channel) { + bot.say(context.replyTo, '๐Ÿ”ค Skip only works in channels!'); + return; + } + + const scramble = this.gameState.activeScrambles.get(channel); + if (!scramble) { + bot.say(context.replyTo, '๐Ÿ”ค No active scramble to skip!'); + return; + } + + // Only starter or admin can skip + if (context.nick !== scramble.starter && context.nick !== 'megasconed') { + bot.say(context.replyTo, '๐Ÿ”ค Only the scramble starter or admin can skip!'); + return; + } + + this.timeoutScramble(channel, bot, scramble); + }, + + timeoutScramble(channel, bot, scramble) { + clearTimeout(scramble.timer); + this.gameState.activeScrambles.delete(channel); + + bot.say(channel, `โฐ Time's up! The word was "${scramble.word}" - better luck next time!`); + console.log(`โฐ Scramble timed out in ${channel}: ${scramble.word}`); + }, + + // Utility functions + parseDifficulty(input) { + if (!input) return 'medium'; + + const difficulty = input.toLowerCase(); + if (['easy', 'medium', 'hard'].includes(difficulty)) { + return difficulty; + } + + if (difficulty === 'random') { + const difficulties = ['easy', 'medium', 'hard']; + return difficulties[Math.floor(Math.random() * difficulties.length)]; + } + + return 'medium'; + }, + + getRandomWord(difficulty) { + const words = this.wordLists[difficulty]; + return words[Math.floor(Math.random() * words.length)]; + }, + + scrambleWord(word) { + const letters = word.split(''); + + // Fisher-Yates shuffle + for (let i = letters.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [letters[i], letters[j]] = [letters[j], letters[i]]; + } + + const scrambled = letters.join(''); + + // If scrambled word is the same as original, try again + if (scrambled === word && word.length > 2) { + return this.scrambleWord(word); + } + + return scrambled; + }, + + getDifficultyPoints(difficulty) { + const points = { + easy: 1, + medium: 2, + hard: 3 + }; + return points[difficulty] || 2; + }, + + // Player score management + initializePlayerData(nick) { + if (!this.gameState.scores.has(nick)) { + this.gameState.scores.set(nick, { + totalPoints: 0, + totalSolved: 0, + avgTime: 0, + bestTime: null, + currentStreak: 0, + longestStreak: 0, + difficultyStats: { + easy: 0, + medium: 0, + hard: 0 + } + }); + } + }, + + updatePlayerScore(nick, difficulty, points, solveTime) { + this.initializePlayerData(nick); + const playerData = this.gameState.scores.get(nick); + + // Update totals + playerData.totalPoints += points; + playerData.totalSolved += 1; + playerData.currentStreak += 1; + + // Update best time + if (playerData.bestTime === null || solveTime < playerData.bestTime) { + playerData.bestTime = solveTime; + } + + // Update longest streak + if (playerData.currentStreak > playerData.longestStreak) { + playerData.longestStreak = playerData.currentStreak; + } + + // Update average time + playerData.avgTime = ((playerData.avgTime * (playerData.totalSolved - 1)) + solveTime) / playerData.totalSolved; + + // Update difficulty stats + playerData.difficultyStats[difficulty] += 1; + + this.gameState.scores.set(nick, playerData); + }, + + showScore(context, bot) { + const playerData = this.gameState.scores.get(context.nick); + if (!playerData) { + bot.say(context.replyTo, `๐Ÿ”ค ${context.nick} hasn't solved any scrambles yet!`); + return; + } + + bot.say(context.replyTo, `๐ŸŽฏ ${context.nick}: ${playerData.totalPoints} points from ${playerData.totalSolved} solved words!`); + }, + + showPlayerStats(context, bot) { + const playerData = this.gameState.scores.get(context.nick); + if (!playerData) { + bot.say(context.replyTo, `๐Ÿ”ค ${context.nick} hasn't solved any scrambles yet!`); + return; + } + + const stats = playerData.difficultyStats; + bot.say(context.replyTo, `๐Ÿ“Š ${context.nick}'s Stats:`); + bot.say(context.replyTo, `๐ŸŽฏ Points: ${playerData.totalPoints} | Words: ${playerData.totalSolved} | Streak: ${playerData.currentStreak} (best: ${playerData.longestStreak})`); + bot.say(context.replyTo, `โฑ๏ธ Avg time: ${playerData.avgTime.toFixed(1)}s | Best: ${playerData.bestTime ? playerData.bestTime.toFixed(1) + 's' : 'N/A'}`); + bot.say(context.replyTo, `๐Ÿ”ค Easy: ${stats.easy} | Medium: ${stats.medium} | Hard: ${stats.hard}`); + }, + + showLeaderboard(context, bot) { + const scores = Array.from(this.gameState.scores.entries()) + .sort((a, b) => b[1].totalPoints - a[1].totalPoints) + .slice(0, 10); + + if (scores.length === 0) { + bot.say(context.replyTo, '๐Ÿ”ค No one has solved any scrambles yet!'); + return; + } + + bot.say(context.replyTo, '๐Ÿ† TOP SCRAMBLERS ๐Ÿ†'); + scores.forEach(([nick, data], index) => { + const medal = index === 0 ? '๐Ÿฅ‡' : index === 1 ? '๐Ÿฅˆ' : index === 2 ? '๐Ÿฅ‰' : '๐ŸŽฏ'; + bot.say(context.replyTo, `${medal} ${index + 1}. ${nick}: ${data.totalPoints} points (${data.totalSolved} words)`); + }); + }, + + // Score persistence + loadScores() { + try { + if (fs.existsSync(this.scoresFile)) { + const data = fs.readFileSync(this.scoresFile, 'utf8'); + const scoresObject = JSON.parse(data); + + // Convert back to Map + this.gameState.scores = new Map(Object.entries(scoresObject)); + console.log(`๐Ÿ”ค Loaded ${this.gameState.scores.size} scramble scores from ${this.scoresFile}`); + + // Log top 3 players if any exist + if (this.gameState.scores.size > 0) { + const topPlayers = Array.from(this.gameState.scores.entries()) + .sort((a, b) => b[1].totalPoints - a[1].totalPoints) + .slice(0, 3); + console.log('๐Ÿ† Top scramblers:', topPlayers.map(([name, data]) => `${name}(${data.totalPoints}pts)`).join(', ')); + } + } else { + console.log(`๐Ÿ”ค No existing scramble scores file found, starting fresh`); + } + } catch (error) { + console.error(`โŒ Error loading scramble scores:`, error); + this.gameState.scores = new Map(); + } + }, + + saveScores() { + try { + const scoresObject = Object.fromEntries(this.gameState.scores); + const data = JSON.stringify(scoresObject, null, 2); + + fs.writeFileSync(this.scoresFile, data, 'utf8'); + console.log(`๐Ÿ’พ Saved ${this.gameState.scores.size} scramble scores to ${this.scoresFile}`); + } catch (error) { + console.error(`โŒ Error saving scramble scores:`, error); + } + } +}; \ No newline at end of file