diff --git a/src/db.py b/src/db.py index d4c75b7..f095152 100644 --- a/src/db.py +++ b/src/db.py @@ -8,7 +8,6 @@ import logging import time import os from datetime import datetime -from typing import Optional from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input @@ -24,8 +23,6 @@ class DuckDB: project_root = os.path.dirname(os.path.dirname(__file__)) self.db_file = os.path.join(project_root, db_file) self.bot = bot - # Channel-scoped player storage: - # {"#channel": {"nick": {player_data}}, ...} self.players = {} self.logger = logging.getLogger('DuckHuntBot.DB') @@ -34,61 +31,12 @@ class DuckDB: self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0) data = self.load_database() - self._hydrate_from_data(data) - - def _default_channel(self) -> str: - """Pick a reasonable default channel context.""" - if self.bot: - channels = self.bot.get_config('connection.channels', []) or [] - if isinstance(channels, list) and channels: - first = channels[0] - if isinstance(first, str) and first.strip(): - return first.strip() - return "#duckhunt" - - def _normalize_channel(self, channel: Optional[str]) -> str: - if isinstance(channel, str) and channel.strip().startswith(('#', '&')): - return channel.strip() - return self._default_channel() - - def _hydrate_from_data(self, data: dict) -> None: - """Load in-memory channel->players structure from parsed JSON.""" - try: - players_by_channel: dict = {} - - if isinstance(data, dict) and isinstance(data.get('channels'), dict): - # New format - for ch, ch_data in data['channels'].items(): - if not isinstance(ch, str): - continue - if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict): - players = ch_data.get('players', {}) - elif isinstance(ch_data, dict): - # Support legacy "channels: {"#c": {nick: {...}}}" shape - players = ch_data - else: - continue - - # Keep only dict players - clean_players = {} - for nick, pdata in players.items(): - if isinstance(nick, str) and isinstance(pdata, dict): - clean_players[nick.lower()] = pdata - if clean_players: - players_by_channel[ch] = clean_players - - elif isinstance(data, dict) and isinstance(data.get('players'), dict): - # Old format: single global player dictionary - default_channel = self._default_channel() - migrated = {} - for nick, pdata in data['players'].items(): - if isinstance(nick, str) and isinstance(pdata, dict): - migrated[nick.lower()] = pdata - players_by_channel[default_channel] = migrated - - self.players = players_by_channel if isinstance(players_by_channel, dict) else {} - except Exception as e: - self.logger.error(f"Error hydrating database in-memory state: {e}") + # Hydrate in-memory state from disk. + # Previously, load_database() returned data but self.players stayed empty, + # making it look like everything reset after restart. + if isinstance(data, dict) and isinstance(data.get('players'), dict): + self.players = data['players'] + else: self.players = {} def load_database(self) -> dict: @@ -119,28 +67,14 @@ class DuckDB: 'last_modified': datetime.now().isoformat() } - # Initialize channels section if missing - if 'channels' not in data: - # If old format has players, keep it for migration. - data.setdefault('players', {}) - else: - if not isinstance(data.get('channels'), dict): - data['channels'] = {} + # Initialize players section if missing + if 'players' not in data: + data['players'] = {} # Update last_modified data['metadata']['last_modified'] = datetime.now().isoformat() - try: - if isinstance(data.get('channels'), dict): - total_players = 0 - for ch_data in data['channels'].values(): - if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict): - total_players += len(ch_data.get('players', {})) - self.logger.info(f"Successfully loaded database with {total_players} total players across {len(data.get('channels', {}))} channels") - else: - self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players") - except Exception: - self.logger.info("Successfully loaded database") + self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players") return data except (json.JSONDecodeError, ValueError) as e: @@ -154,9 +88,9 @@ class DuckDB: """Create a new default database file with proper structure""" try: default_data = { - "channels": {}, + "players": {}, "last_save": str(time.time()), - "version": "2.0", + "version": "1.0", "created": time.strftime("%Y-%m-%d %H:%M:%S"), "description": "DuckHunt Bot Player Database" } @@ -171,9 +105,9 @@ class DuckDB: self.logger.error(f"Failed to create default database: {e}") # Return a minimal valid structure even if file creation fails return { - "channels": {}, + "players": {}, "last_save": str(time.time()), - "version": "2.0", + "version": "1.0", "created": time.strftime("%Y-%m-%d %H:%M:%S"), "description": "DuckHunt Bot Player Database" } @@ -280,37 +214,23 @@ class DuckDB: try: # Prepare data with validation data = { - 'channels': {}, + 'players': {}, 'last_save': str(time.time()), - 'version': '2.0' + 'version': '1.0' } # Validate and clean player data before saving valid_count = 0 - for channel_name, channel_players in self.players.items(): - if not isinstance(channel_name, str) or not isinstance(channel_players, dict): - continue - - safe_channel = sanitize_user_input( - channel_name, - max_length=100, - allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\' - ) - if not safe_channel or not (safe_channel.startswith('#') or safe_channel.startswith('&')): - continue - - data['channels'].setdefault(safe_channel, {'players': {}}) - - for nick, player_data in channel_players.items(): - if isinstance(nick, str) and isinstance(player_data, dict): - try: - sanitized_nick = sanitize_user_input(nick, max_length=50) - if not sanitized_nick: - continue - data['channels'][safe_channel]['players'][sanitized_nick.lower()] = self._sanitize_player_data(player_data) - valid_count += 1 - except Exception as e: - self.logger.warning(f"Error processing player {nick} in {safe_channel} during save: {e}") + for nick, player_data in self.players.items(): + if isinstance(nick, str) and isinstance(player_data, dict): + try: + sanitized_nick = sanitize_user_input(nick, max_length=50) + data['players'][sanitized_nick] = self._sanitize_player_data(player_data) + valid_count += 1 + except Exception as e: + self.logger.warning(f"Error processing player {nick} during save: {e}") + else: + self.logger.warning(f"Skipping invalid player data during save: {nick}") # Saving an empty database is valid (e.g., first run or after admin wipes). # Previously this raised and prevented the file from being written/updated. @@ -350,79 +270,7 @@ class DuckDB: except Exception: pass - def get_players_for_channel(self, channel: Optional[str]) -> dict: - """Get the mutable player dict for a channel, creating the channel bucket if needed.""" - ch = self._normalize_channel(channel) - if ch not in self.players or not isinstance(self.players.get(ch), dict): - self.players[ch] = {} - return self.players[ch] - - def get_player_if_exists(self, nick: str, channel: Optional[str]) -> Optional[dict]: - """Get an existing player record for a channel without creating one.""" - try: - if not isinstance(nick, str) or not nick.strip(): - return None - nick_clean = sanitize_user_input( - nick, - max_length=50, - allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\' - ) - nick_lower = (nick_clean or '').lower().strip() - if not nick_lower: - return None - - ch = self._normalize_channel(channel) - channel_players = self.players.get(ch) - if not isinstance(channel_players, dict): - return None - - player = channel_players.get(nick_lower) - if isinstance(player, dict): - return player - return None - except Exception: - return None - - def get_global_duck_totals(self, nick: str, channels: list) -> dict: - """Sum ducks_shot/ducks_befriended for a user across the provided channels.""" - total_shot = 0 - total_bef = 0 - channels_counted = 0 - - for ch in channels or []: - if not isinstance(ch, str): - continue - player = self.get_player_if_exists(nick, ch) - if not player: - continue - channels_counted += 1 - try: - total_shot += int(player.get('ducks_shot', 0) or 0) - except Exception: - pass - try: - total_bef += int(player.get('ducks_befriended', 0) or 0) - except Exception: - pass - - return { - 'nick': nick, - 'ducks_shot': total_shot, - 'ducks_befriended': total_bef, - 'total_ducks': total_shot + total_bef, - 'channels_counted': channels_counted, - } - - def iter_all_players(self): - """Yield (channel, nick, player_dict) for all players.""" - for ch, players in (self.players or {}).items(): - if not isinstance(players, dict): - continue - for nick, pdata in players.items(): - if isinstance(nick, str) and isinstance(pdata, dict): - yield ch, nick, pdata - - def get_player(self, nick, channel: Optional[str] = None): + def get_player(self, nick): """Get player data, creating if doesn't exist with comprehensive validation""" try: # Validate and sanitize nick @@ -443,16 +291,14 @@ class DuckDB: self.logger.warning(f"Empty nick after sanitization: {nick}") return self.create_player('Unknown') - channel_players = self.get_players_for_channel(channel) - - if nick_lower not in channel_players: - channel_players[nick_lower] = self.create_player(nick_clean) + if nick_lower not in self.players: + self.players[nick_lower] = self.create_player(nick_clean) else: # Ensure existing players have all required fields - player = channel_players[nick_lower] + player = self.players[nick_lower] if not isinstance(player, dict): self.logger.warning(f"Invalid player data for {nick_lower}, recreating") - channel_players[nick_lower] = self.create_player(nick_clean) + self.players[nick_lower] = self.create_player(nick_clean) else: # Migrate and validate existing player data with error recovery validated = self.error_recovery.safe_execute( @@ -460,9 +306,9 @@ class DuckDB: fallback=self.create_player(nick_clean), logger=self.logger ) - channel_players[nick_lower] = validated + self.players[nick_lower] = validated - return channel_players[nick_lower] + return self.players[nick_lower] except Exception as e: self.logger.error(f"Critical error getting player {nick}: {e}") @@ -576,16 +422,11 @@ class DuckDB: } def get_leaderboard(self, category='xp', limit=3): - """Get top players by specified category (default channel).""" - return self.get_leaderboard_for_channel(self._default_channel(), category=category, limit=limit) - - def get_leaderboard_for_channel(self, channel: Optional[str], category='xp', limit=3): - """Get top players for a channel by specified category""" + """Get top players by specified category""" try: leaderboard = [] - - channel_players = self.get_players_for_channel(channel) - for nick, player_data in channel_players.items(): + + for nick, player_data in self.players.items(): sanitized_data = self._sanitize_player_data(player_data) if category == 'xp': diff --git a/src/duckhuntbot.py b/src/duckhuntbot.py index 4de1d8c..62ee76d 100644 --- a/src/duckhuntbot.py +++ b/src/duckhuntbot.py @@ -23,9 +23,6 @@ class DuckHuntBot: self.writer: Optional[asyncio.StreamWriter] = None self.registered = False self.channels_joined = set() - # Track requested joins so we can report success/failure. - # channel -> requester nick (or None for startup/rejoin) - self.pending_joins = {} self.shutdown_requested = False self.rejoin_attempts = {} # Track rejoin attempts per channel self.rejoin_tasks = {} # Track active rejoin tasks @@ -41,9 +38,6 @@ class DuckHuntBot: self.messages = MessageManager() self.sasl_handler = SASLHandler(self, config) - - # Config file path for persisting runtime config changes (e.g., admin join/leave). - self.config_file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json') # Set up health checks self._setup_health_checks() @@ -64,7 +58,7 @@ class DuckHuntBot: # Database health check self.health_checker.add_check( 'database', - lambda: self.db is not None, + lambda: self.db is not None and len(self.db.players) >= 0, critical=True ) @@ -95,15 +89,6 @@ class DuckHuntBot: else: return default return value - - def _channel_key(self, channel: str) -> str: - """Normalize channel names for internal comparisons (IRC channels are case-insensitive).""" - if not isinstance(channel, str): - return "" - channel = channel.strip() - if channel.startswith('#') or channel.startswith('&'): - return channel.lower() - return channel def is_admin(self, user): if '!' not in user: @@ -137,6 +122,24 @@ class DuckHuntBot: return False + def _handle_single_target_admin_command(self, args, usage_message_key, action_func, success_message_key, nick, channel): + """Helper for admin commands that target a single player""" + if not args: + message = self.messages.get(usage_message_key) + self.send_message(channel, message) + return False + + target = args[0].lower() + player = self.db.get_player(target) + if player is None: + player = self.db.create_player(target) + self.db.players[target] = player + action_func(player) + + message = self.messages.get(success_message_key, target=target, admin=nick) + self.send_message(channel, message) + self.db.save_database() + return True def _get_admin_target_player(self, nick, channel, target_nick): """ @@ -144,21 +147,29 @@ class DuckHuntBot: Returns (player, error_message) - if error_message is not None, command should return early. """ is_private_msg = not channel.startswith('#') - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None if not is_private_msg: if target_nick.lower() == nick.lower(): target_nick = target_nick.lower() - player = self.db.get_player(target_nick, channel_ctx) + player = self.db.get_player(target_nick) + if player is None: + player = self.db.create_player(target_nick) + self.db.players[target_nick] = player return player, None else: is_valid, player, error_msg = self.validate_target_player(target_nick, channel) if not is_valid: return None, error_msg + target_nick = target_nick.lower() + if target_nick not in self.db.players: + self.db.players[target_nick] = player return player, None else: target_nick = target_nick.lower() - player = self.db.get_player(target_nick, channel_ctx) + player = self.db.get_player(target_nick) + if player is None: + player = self.db.create_player(target_nick) + self.db.players[target_nick] = player return player, None def _get_validated_target_player(self, nick, channel, target_nick): @@ -304,16 +315,19 @@ class DuckHuntBot: # Attempt to rejoin if self.send_raw(f"JOIN {channel}"): - self.pending_joins[channel] = None - self.logger.info(f"Sent JOIN for {channel} (waiting for server confirmation)") + self.channels_joined.add(channel) + self.logger.info(f"Successfully rejoined {channel}") + + # Reset attempt counter and remove task + self.rejoin_attempts[channel] = 0 + if channel in self.rejoin_tasks: + del self.rejoin_tasks[channel] + return else: self.logger.warning(f"Failed to send JOIN command for {channel}") # If we've exceeded max attempts or channel was successfully joined - if channel in self.channels_joined: - self.rejoin_attempts[channel] = 0 - self.logger.info(f"Rejoin confirmed for {channel}") - elif self.rejoin_attempts[channel] >= max_attempts: + if self.rejoin_attempts[channel] >= max_attempts: self.logger.error(f"Exhausted all {max_attempts} rejoin attempts for {channel}") # Clean up @@ -347,9 +361,7 @@ class DuckHuntBot: # Sanitize target and message safe_target = sanitize_user_input(target, max_length=100, allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\') - # Sanitize message (preserve IRC formatting codes - only remove CR/LF) - safe_msg = msg[:400] if isinstance(msg, str) else str(msg)[:400] - safe_msg = safe_msg.replace('\r', '').replace('\n', ' ').strip() + safe_msg = sanitize_user_input(msg, max_length=400) if not safe_target or not safe_msg: self.logger.warning(f"Empty target or message after sanitization") @@ -380,12 +392,17 @@ class DuckHuntBot: # Send all message parts success_count = 0 for i, message_part in enumerate(messages): + if i > 0: # Small delay between messages to avoid flooding + time.sleep(0.1) + if self.send_raw(f"PRIVMSG {safe_target} :{message_part}"): success_count += 1 else: self.logger.error(f"Failed to send message part {i+1}/{len(messages)}") return success_count == len(messages) + + return self.send_raw(f"PRIVMSG {target} :{sanitized_msg}") except Exception as e: self.logger.error(f"Error sanitizing/sending message: {e}") return False @@ -444,75 +461,29 @@ class DuckHuntBot: for channel in channels: try: self.send_raw(f"JOIN {channel}") - self.pending_joins[self._channel_key(channel)] = None + self.channels_joined.add(channel) except Exception as e: self.logger.error(f"Error joining channel {channel}: {e}") - - # JOIN failures (numeric replies) - elif command in {"403", "405", "437", "471", "473", "474", "475", "477"}: - # Common formats: - # 471 <#chan> :Cannot join channel (+l) - # 475 <#chan> :Cannot join channel (+k) - # 477 <#chan> :You need to be identified... - our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt' - if params and len(params) >= 2 and params[0].lower() == our_nick.lower(): - failed_channel = params[1] - reason = trailing or "Join rejected" - failed_key = self._channel_key(failed_channel) - self.channels_joined.discard(failed_key) - requester = self.pending_joins.pop(failed_key, None) - self.logger.warning(f"Failed to join {failed_channel}: ({command}) {reason}") - if requester: - try: - self.send_message(requester, f"{requester} > Failed to join {failed_channel}: {reason}") - except Exception: - pass - return elif command == "JOIN": - if prefix: - # Some servers send: ":nick!user@host JOIN :#chan" (channel in trailing) - channel = None - if len(params) >= 1: - channel = params[0] - elif trailing and isinstance(trailing, str) and trailing.startswith('#'): - channel = trailing - - if not channel: - return - + if len(params) >= 1 and prefix: + channel = params[0] joiner_nick = prefix.split('!')[0] if '!' in prefix else prefix our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt' # Check if we successfully joined (or rejoined) a channel if joiner_nick and joiner_nick.lower() == our_nick.lower(): - channel_key = self._channel_key(channel) - self.channels_joined.add(channel_key) + self.channels_joined.add(channel) self.logger.info(f"Successfully joined channel {channel}") - - # If this was an admin-requested join, persist it now. - requester = self.pending_joins.pop(channel_key, None) - if requester: - try: - channels = self._config_channels_list() - if not any(self._channel_key(c) == channel_key for c in channels if isinstance(c, str)): - channels.append(channel) - self._persist_config() - self.send_message(requester, f"{requester} > Joined {channel}.") - except Exception: - pass - else: - # Startup/rejoin joins shouldn't change config here. - self.pending_joins.pop(channel_key, None) # Cancel any pending rejoin attempts for this channel - if channel_key in self.rejoin_tasks: - self.rejoin_tasks[channel_key].cancel() - del self.rejoin_tasks[channel_key] + if channel in self.rejoin_tasks: + self.rejoin_tasks[channel].cancel() + del self.rejoin_tasks[channel] # Reset rejoin attempts counter - if channel_key in self.rejoin_attempts: - self.rejoin_attempts[channel_key] = 0 + if channel in self.rejoin_attempts: + self.rejoin_attempts[channel] = 0 elif command == "PRIVMSG": if len(params) >= 1: @@ -533,12 +504,11 @@ class DuckHuntBot: self.logger.warning(f"Kicked from {channel} by {kicker}: {reason}") # Remove from joined channels - channel_key = self._channel_key(channel) - self.channels_joined.discard(channel_key) + self.channels_joined.discard(channel) # Schedule rejoin if auto-rejoin is enabled if self.get_config('connection.auto_rejoin.enabled', True): - asyncio.create_task(self.schedule_rejoin(channel_key)) + asyncio.create_task(self.schedule_rejoin(channel)) elif command == "PING": try: @@ -553,12 +523,7 @@ class DuckHuntBot: """Handle bot commands with enhanced error handling and input validation""" try: # Validate input parameters - if not isinstance(message, str): - return - - # Some clients/users may prefix commands with whitespace (e.g. " !bang"). - message = message.lstrip() - if not message.startswith('!'): + if not isinstance(message, str) or not message.startswith('!'): return if not isinstance(user, str) or not isinstance(channel, str): @@ -566,13 +531,9 @@ class DuckHuntBot: return # Sanitize inputs - safe_message = sanitize_user_input(message, max_length=500).lstrip() + safe_message = sanitize_user_input(message, max_length=500) safe_user = sanitize_user_input(user, max_length=200) safe_channel = sanitize_user_input(channel, max_length=100) - - # Normalize channel casing for internal consistency. - if isinstance(safe_channel, str) and (safe_channel.startswith('#') or safe_channel.startswith('&')): - safe_channel = self._channel_key(safe_channel) if not safe_message.startswith('!'): return @@ -605,9 +566,8 @@ class DuckHuntBot: allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\') # Get player data with error recovery - channel_ctx = safe_channel if isinstance(safe_channel, str) and safe_channel.startswith('#') else None player = self.error_recovery.safe_execute( - lambda: self.db.get_player(nick, channel_ctx), + lambda: self.db.get_player(nick), fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False}, logger=self.logger ) @@ -620,6 +580,7 @@ class DuckHuntBot: try: player['last_activity_channel'] = safe_channel player['last_activity_time'] = time.time() + self.db.players[nick.lower()] = player except Exception as e: self.logger.warning(f"Error updating player activity for {nick}: {e}") @@ -650,28 +611,25 @@ class DuckHuntBot: if cmd == "bang": command_executed = True - try: - await self.handle_bang(nick, channel, player) - except Exception as e: - self.logger.error(f"Error in handle_bang for {nick}: {e}") - error_msg = f"{nick} > ⚠️ Error processing !bang command. Please try again." - self.send_message(channel, error_msg) + await self.error_recovery.safe_execute_async( + lambda: self.handle_bang(nick, channel, player), + fallback=None, + logger=self.logger + ) elif cmd == "bef" or cmd == "befriend": command_executed = True - try: - await self.handle_bef(nick, channel, player) - except Exception as e: - self.logger.error(f"Error in handle_bef for {nick}: {e}") - error_msg = f"{nick} > ⚠️ Error processing !bef command. Please try again." - self.send_message(channel, error_msg) + await self.error_recovery.safe_execute_async( + lambda: self.handle_bef(nick, channel, player), + fallback=None, + logger=self.logger + ) elif cmd == "reload": command_executed = True - try: - await self.handle_reload(nick, channel, player) - except Exception as e: - self.logger.error(f"Error in handle_reload for {nick}: {e}") - error_msg = f"{nick} > ⚠️ Error processing !reload command. Please try again." - self.send_message(channel, error_msg) + await self.error_recovery.safe_execute_async( + lambda: self.handle_reload(nick, channel, player), + fallback=None, + logger=self.logger + ) elif cmd == "shop": command_executed = True await self.error_recovery.safe_execute_async( @@ -714,13 +672,6 @@ class DuckHuntBot: fallback=None, logger=self.logger ) - elif cmd == "globalducks": - command_executed = True - await self.error_recovery.safe_execute_async( - lambda: self.handle_globalducks(nick, channel, safe_args, user), - fallback=None, - logger=self.logger - ) elif cmd == "rearm" and self.is_admin(user): command_executed = True await self.error_recovery.safe_execute_async( @@ -756,20 +707,6 @@ class DuckHuntBot: fallback=None, logger=self.logger ) - elif cmd == "join" and self.is_admin(user): - command_executed = True - await self.error_recovery.safe_execute_async( - lambda: self.handle_join_channel(nick, channel, safe_args), - fallback=None, - logger=self.logger - ) - elif (cmd == "leave" or cmd == "part") and self.is_admin(user): - command_executed = True - await self.error_recovery.safe_execute_async( - lambda: self.handle_leave_channel(nick, channel, safe_args), - fallback=None, - logger=self.logger - ) # If no command was executed, it might be an unknown command if not command_executed: @@ -806,9 +743,8 @@ class DuckHuntBot: if not target_nick: return False, None, "Invalid target nickname" - - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None - player = self.db.get_player(target_nick, channel_ctx) + + player = self.db.get_player(target_nick) if not player: return False, None, f"Player '{target_nick}' not found. They need to participate in the game first." @@ -832,8 +768,7 @@ class DuckHuntBot: We assume if someone has been active recently, they're still in the channel. """ try: - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None - player = self.db.get_player(nick, channel_ctx) + player = self.db.get_player(nick) if not player: return False @@ -952,8 +887,7 @@ class DuckHuntBot: """Handle !duckstats command""" if args and len(args) > 0: target_nick = args[0] - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None - target_player = self.db.get_player(target_nick, channel_ctx) + target_player = self.db.get_player(target_nick) if not target_player: message = f"{nick} > Player '{target_nick}' not found." self.send_message(channel, message) @@ -1046,13 +980,11 @@ class DuckHuntBot: bold = self.messages.messages.get('colours', {}).get('bold', '') reset = self.messages.messages.get('colours', {}).get('reset', '') - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None - - # Get top 3 by XP (channel-scoped) - top_xp = self.db.get_leaderboard_for_channel(channel_ctx, 'xp', 3) - - # Get top 3 by ducks shot (channel-scoped) - top_ducks = self.db.get_leaderboard_for_channel(channel_ctx, 'ducks_shot', 3) + # Get top 3 by XP + top_xp = self.db.get_leaderboard('xp', 3) + + # Get top 3 by ducks shot + top_ducks = self.db.get_leaderboard('ducks_shot', 3) # Format XP leaderboard as single line if top_xp: @@ -1081,208 +1013,19 @@ class DuckHuntBot: self.send_message(channel, f"{nick} > Error retrieving leaderboard data.") async def handle_duckhelp(self, nick, channel, _player): - """Handle !duckhelp command - - Sends help to the user via private message (PM/DM) with examples. - """ - - dm_target = nick # PRIVMSG target for a PM is the nick - + """Handle !duckhelp command""" help_lines = [ - "DuckHunt Commands (sent via PM)", - "Player commands:", - "- !bang — shoot when a duck appears. Example: !bang", - "- !reload — reload your weapon. Example: !reload", - "- !shop — list shop items. Example: !shop", - "- !buy — buy from shop. Example: !buy 3", - "- !use [target] — use an inventory item. Example: !use 7 OR !use 9 SomeNick", - "- !duckstats [player] — view stats/inventory. Example: !duckstats OR !duckstats SomeNick", - "- !topduck — show leaderboards. Example: !topduck", - "- !give — gift an owned item. Example: !give 2 SomeNick", - "- !globalducks [player] — duck totals across all configured channels. Example: !globalducks OR !globalducks SomeNick", - "- !duckhelp — show this help. Example: !duckhelp", + self.messages.get('help_header'), + self.messages.get('help_user_commands'), + self.messages.get('help_help_command') ] - - # Include admin commands only for admins. - # (Using nick list avoids relying on hostmask parsing.) - if nick.lower() in self.admins: - help_lines.extend([ - "Admin commands:", - "- !rearm — rearm a player. Example: !rearm SomeNick OR !rearm all", - "- !disarm — confiscate gun. Example: !disarm SomeNick", - "- !ignore — ignore a player. Example: !ignore SomeNick", - "- !unignore — unignore a player. Example: !unignore SomeNick", - "- !ducklaunch [duck_type] — force spawn. Example: !ducklaunch golden", - "- (PM) !ducklaunch <#channel> [duck_type] — Example: !ducklaunch #ct fast", - "- !join <#channel> — make the bot join a channel. Example: !join #ct", - "- !leave <#channel> — make the bot leave a channel. Example: !leave #ct", - ]) - + + # Add admin commands if user is admin + if self.is_admin(f"{nick}!user@host"): + help_lines.append(self.messages.get('help_admin_commands')) + for line in help_lines: - self.send_message(dm_target, line) - - # If invoked in a channel, add a brief confirmation to check PMs. - if isinstance(channel, str) and channel.startswith('#'): - self.send_message(channel, f"{nick} > I sent you a PM with commands and examples. Please check your PM window.") - - async def handle_globalducks(self, nick, channel, args, user): - """User: !globalducks [player] — totals across all configured channels. - - Non-admins can query themselves only. Admins can query other nicks. - """ - try: - channels = self.get_config('connection.channels', []) or [] - if not isinstance(channels, list): - channels = [] - - target_nick = nick - if args and len(args) >= 1: - requested = sanitize_user_input( - str(args[0]), - max_length=50, - allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\' - ) - if requested: - target_nick = requested - - # Anyone can query anyone via !globalducks - - totals = self.db.get_global_duck_totals(target_nick, channels) - shot = totals.get('ducks_shot', 0) - bef = totals.get('ducks_befriended', 0) - total = totals.get('total_ducks', shot + bef) - counted = totals.get('channels_counted', 0) - - if not channels: - self.send_message(channel, f"{nick} > No configured channels to total.") - return - - self.send_message( - channel, - f"{nick} > Global totals for {target_nick} across configured channels: {shot} shot, {bef} befriended ({total} total) [{counted}/{len(channels)} channels have stats]." - ) - except Exception as e: - self.logger.error(f"Error in handle_globalducks: {e}") - self.send_message(channel, f"{nick} > Error calculating global totals.") - - def _sanitize_channel_name(self, channel_name: str) -> str: - """Validate/sanitize an IRC channel name.""" - if not isinstance(channel_name, str): - return "" - safe = sanitize_user_input( - channel_name.strip(), - max_length=100, - allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\' - ) - if not safe: - return "" - if not (safe.startswith('#') or safe.startswith('&')): - return "" - return safe - - def _config_channels_list(self): - """Return the mutable in-memory config channel list, creating it if needed.""" - if not isinstance(self.config, dict): - self.config = {} - connection = self.config.get('connection') - if not isinstance(connection, dict): - connection = {} - self.config['connection'] = connection - channels = connection.get('channels') - if not isinstance(channels, list): - channels = [] - connection['channels'] = channels - return channels - - def _persist_config(self) -> bool: - """Persist current config to disk (best-effort, atomic write).""" - try: - config_dir = os.path.dirname(self.config_file_path) - os.makedirs(config_dir, exist_ok=True) - - tmp_path = f"{self.config_file_path}.tmp" - with open(tmp_path, 'w', encoding='utf-8') as f: - # Keep it stable/human-readable. - import json - json.dump(self.config, f, indent=4, ensure_ascii=False) - f.write("\n") - f.flush() - os.fsync(f.fileno()) - - os.replace(tmp_path, self.config_file_path) - return True - except Exception as e: - self.logger.error(f"Failed to persist config to disk: {e}") - return False - - async def handle_join_channel(self, nick, channel, args): - """Admin: !join <#channel> (supports PM and channel invocation).""" - if not args: - self.send_message(channel, f"{nick} > Usage: !join <#channel>") - return - - target_channel = self._sanitize_channel_name(args[0]) - if not target_channel: - self.send_message(channel, f"{nick} > Invalid channel. Usage: !join <#channel>") - return - - target_key = self._channel_key(target_channel) - - if target_key in self.channels_joined: - self.send_message(channel, f"{nick} > I'm already in {target_channel}.") - return - - if not self.send_raw(f"JOIN {target_channel}"): - self.send_message(channel, f"{nick} > Couldn't send JOIN (not connected?).") - return - - # Wait for server JOIN confirmation before marking joined/persisting. - self.pending_joins[target_key] = nick - self.send_message(channel, f"{nick} > Attempting to join {target_channel}...") - - async def handle_leave_channel(self, nick, channel, args): - """Admin: !leave <#channel> / !part <#channel> (supports PM and channel invocation).""" - if not args: - self.send_message(channel, f"{nick} > Usage: !leave <#channel>") - return - - target_channel = self._sanitize_channel_name(args[0]) - if not target_channel: - self.send_message(channel, f"{nick} > Invalid channel. Usage: !leave <#channel>") - return - - target_key = self._channel_key(target_channel) - - # Cancel any pending rejoin attempts and forget state. - if target_key in self.rejoin_tasks: - try: - self.rejoin_tasks[target_key].cancel() - except Exception: - pass - del self.rejoin_tasks[target_key] - if target_key in self.rejoin_attempts: - del self.rejoin_attempts[target_key] - - self.channels_joined.discard(target_key) - - # Update in-memory config so reconnects do not rejoin the channel. - channels = self._config_channels_list() - try: - channels[:] = [c for c in channels if not (isinstance(c, str) and self._channel_key(c) == target_key)] - except Exception: - pass - - # Persist across restarts - if not self._persist_config(): - self.send_message(channel, f"{nick} > Removed {target_channel} from my channel list, but failed to write config.json (check permissions).") - # Continue attempting PART anyway. - - # Send PART even if we don't think we're in it (server will ignore or error). - if not self.send_raw(f"PART {target_channel} :Requested by {nick}"): - self.send_message(channel, f"{nick} > Couldn't send PART (not connected?).") - return - - self.send_message(channel, f"{nick} > Leaving {target_channel}.") + self.send_message(channel, line) async def handle_use(self, nick, channel, player, args): """Handle !use command""" @@ -1345,72 +1088,6 @@ class DuckHuntBot: message = self.messages.get('use_dry_clothes', nick=nick) else: message = self.messages.get('use_dry_clothes_not_needed', nick=nick) - elif effect_type == 'perfect_aim': - duration_seconds = int(effect.get('duration', 1800)) - minutes = max(1, duration_seconds // 60) - message = self.messages.get('use_perfect_aim', nick=nick, duration_minutes=minutes) - elif effect_type == 'duck_radar': - duration_seconds = int(effect.get('duration', 21600)) - hours = max(1, duration_seconds // 3600) - message = self.messages.get('use_duck_radar', nick=nick, duration_hours=hours) - elif effect_type == 'summon_duck': - # Summoning needs a channel context. If used in PM, pick the first configured channel. - delay = int(effect.get('delay', 0)) - target_channel = channel - is_private_msg = not isinstance(target_channel, str) or not target_channel.startswith('#') - if is_private_msg: - channels = self.get_config('connection.channels', []) or [] - target_channel = channels[0] if channels else None - - if not target_channel: - message = f"{nick} > I don't know which channel to summon a duck in. Use this in a channel." - else: - # PM commands should only spawn normal/fast/golden. - spawn_type = None - if is_private_msg: - import random - golden_chance = self.get_config('duck_types.golden.chance', self.get_config('golden_duck_chance', 0.15)) - fast_chance = self.get_config('duck_types.fast.chance', self.get_config('fast_duck_chance', 0.25)) - try: - golden_chance = float(golden_chance) - except (TypeError, ValueError): - golden_chance = 0.15 - try: - fast_chance = float(fast_chance) - except (TypeError, ValueError): - fast_chance = 0.25 - - r = random.random() - if r < max(0.0, golden_chance): - spawn_type = 'golden' - elif r < max(0.0, golden_chance) + max(0.0, fast_chance): - spawn_type = 'fast' - else: - spawn_type = 'normal' - - if delay <= 0: - if spawn_type: - await self.game.force_spawn_duck(target_channel, spawn_type) - else: - await self.game.spawn_duck(target_channel) - message = self.messages.get('use_summon_duck', nick=nick, channel=target_channel) - else: - async def delayed_summon(): - try: - await asyncio.sleep(delay) - if target_channel in self.channels_joined: - if spawn_type: - await self.game.force_spawn_duck(target_channel, spawn_type) - else: - await self.game.spawn_duck(target_channel) - except asyncio.CancelledError: - return - except Exception: - return - - asyncio.create_task(delayed_summon()) - minutes = max(1, delay // 60) - message = self.messages.get('use_summon_duck_delayed', nick=nick, channel=target_channel, delay_minutes=minutes) elif result.get("target_affected"): # Check if it's a gift (beneficial effect to target) if effect.get('is_gift', False): @@ -1535,8 +1212,7 @@ class DuckHuntBot: # Check if admin wants to rearm all players if target_nick.lower() == 'all': rearmed_count = 0 - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None - for player_nick, player in self.db.get_players_for_channel(channel_ctx).items(): + for player_nick, player in self.db.players.items(): if player.get('gun_confiscated', False): player['gun_confiscated'] = False self.levels.update_player_magazines(player) @@ -1575,8 +1251,10 @@ class DuckHuntBot: return # Rearm the admin themselves (only in channels) - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None - player = self.db.get_player(nick, channel_ctx) + player = self.db.get_player(nick) + if player is None: + player = self.db.create_player(nick) + self.db.players[nick.lower()] = player player['gun_confiscated'] = False @@ -1639,8 +1317,10 @@ class DuckHuntBot: return target = args[0].lower() - channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None - player = self.db.get_player(target, channel_ctx) + player = self.db.get_player(target) + if player is None: + player = self.db.create_player(target) + self.db.players[target] = player action_func(player) @@ -1681,17 +1361,15 @@ class DuckHuntBot: if is_private_msg: if not args: - self.send_message(channel, f"{nick} > Usage: !ducklaunch [channel] [duck_type]") + self.send_message(channel, f"{nick} > Usage: !ducklaunch [channel] [duck_type] - duck_type can be: normal, golden, fast") return target_channel = args[0] duck_type_arg = args[1] if len(args) > 1 else "normal" else: duck_type_arg = args[0] if args else "normal" - - target_key = self._channel_key(target_channel) # Validate target channel - if target_key not in self.channels_joined: + if target_channel not in self.channels_joined: if is_private_msg: self.send_message(channel, f"{nick} > Channel {target_channel} is not available for duckhunt") else: @@ -1701,21 +1379,52 @@ class DuckHuntBot: # Validate duck type duck_type_arg = duck_type_arg.lower() - if is_private_msg: - valid_types = {'normal', 'fast', 'golden'} - else: - duck_types_cfg = self.get_config('duck_types', {}) or {} - if not isinstance(duck_types_cfg, dict): - duck_types_cfg = {} - valid_types = set(['normal']) | set(duck_types_cfg.keys()) - + valid_types = ["normal", "golden", "fast"] if duck_type_arg not in valid_types: - valid_list = ', '.join(sorted(valid_types)) - self.send_message(channel, f"{nick} > Invalid duck type '{duck_type_arg}'. Valid types: {valid_list}") + self.send_message(channel, f"{nick} > Invalid duck type '{duck_type_arg}'. Valid types: {', '.join(valid_types)}") return - - # Force spawn the specified duck type (supports multi-spawn types like couple/family) - await self.game.force_spawn_duck(target_key, duck_type_arg) + + # Force spawn the specified duck type + import time + import random + + if target_channel not in self.game.ducks: + self.game.ducks[target_channel] = [] + + # Create duck based on specified type + current_time = time.time() + duck_id = f"{duck_type_arg}_duck_{int(current_time)}_{random.randint(1000, 9999)}" + + if duck_type_arg == "golden": + min_hp_val = self.get_config('duck_types.golden.min_hp', 3) + max_hp_val = self.get_config('duck_types.golden.max_hp', 5) + min_hp = int(min_hp_val) if min_hp_val is not None else 3 + max_hp = int(max_hp_val) if max_hp_val is not None else 5 + hp = random.randint(min_hp, max_hp) + duck = { + 'id': duck_id, + 'spawn_time': current_time, + 'channel': target_channel, + 'duck_type': 'golden', + 'max_hp': hp, + 'current_hp': hp + } + else: + # Both normal and fast ducks have 1 HP + duck = { + 'id': duck_id, + 'spawn_time': current_time, + 'channel': target_channel, + 'duck_type': duck_type_arg, + 'max_hp': 1, + 'current_hp': 1 + } + + self.game.ducks[target_channel].append(duck) + duck_message = self.messages.get('duck_spawn') + + # Send duck spawn message to target channel + self.send_message(target_channel, duck_message) # Send confirmation to admin (either in channel or private message) if is_private_msg: diff --git a/src/game.py b/src/game.py index ba6cfc2..b3ed361 100644 --- a/src/game.py +++ b/src/game.py @@ -165,52 +165,6 @@ class DuckGame: self.ducks[channel].append(duck) self.bot.send_message(channel, message) - async def force_spawn_duck(self, channel, duck_type='normal'): - """Force spawn a specific duck type (admin command)""" - if channel not in self.ducks: - self.ducks[channel] = [] - - # Validate duck type - duck_type = (duck_type or 'normal').lower() - if duck_type not in ['normal', 'golden', 'fast']: - duck_type = 'normal' - - # Create the specified duck type - if duck_type == 'golden': - min_hp = self.bot.get_config('golden_duck_min_hp', 3) - max_hp = self.bot.get_config('golden_duck_max_hp', 5) - hp = random.randint(min_hp, max_hp) - duck = { - 'id': f"golden_duck_{int(time.time())}_{random.randint(1000, 9999)}", - 'spawn_time': time.time(), - 'channel': channel, - 'duck_type': 'golden', - 'max_hp': hp, - 'current_hp': hp - } - elif duck_type == 'fast': - duck = { - 'id': f"fast_duck_{int(time.time())}_{random.randint(1000, 9999)}", - 'spawn_time': time.time(), - 'channel': channel, - 'duck_type': 'fast', - 'max_hp': 1, - 'current_hp': 1 - } - else: # normal - duck = { - 'id': f"duck_{int(time.time())}_{random.randint(1000, 9999)}", - 'spawn_time': time.time(), - 'channel': channel, - 'duck_type': 'normal', - 'max_hp': 1, - 'current_hp': 1 - } - - self.ducks[channel].append(duck) - message = self.bot.messages.get('duck_spawn') - self.bot.send_message(channel, message) - def shoot_duck(self, nick, channel, player): """Handle shooting at a duck""" # Check if gun is confiscated @@ -285,9 +239,6 @@ class DuckGame: if duck['current_hp'] > 0: # Still alive, reveal it's golden but don't remove - # Award XP for hitting (but not killing) the golden duck - player['xp'] = player.get('xp', 0) + xp_gained - accuracy_gain = self.bot.get_config('accuracy_gain_on_hit', 1) max_accuracy = self.bot.get_config('max_accuracy', 100) player['accuracy'] = min(player.get('accuracy', self.bot.get_config('default_accuracy', 75)) + accuracy_gain, max_accuracy) diff --git a/src/shop.py b/src/shop.py index 6932174..3eb1938 100644 --- a/src/shop.py +++ b/src/shop.py @@ -388,49 +388,6 @@ class ShopManager: "spawn_multiplier": spawn_multiplier, "duration": duration // 60 # return duration in minutes } - - elif item_type == 'perfect_aim': - # Temporarily force shots to hit (bot/game enforces this) - if 'temporary_effects' not in player: - player['temporary_effects'] = [] - - duration = int(item.get('duration', 1800)) # seconds - effect = { - 'type': 'perfect_aim', - 'expires_at': time.time() + max(1, duration) - } - player['temporary_effects'].append(effect) - - return { - "type": "perfect_aim", - "duration": duration - } - - elif item_type == 'duck_radar': - # DM alert on duck spawns (game loop sends the DM) - if 'temporary_effects' not in player: - player['temporary_effects'] = [] - - duration = int(item.get('duration', 21600)) # seconds - effect = { - 'type': 'duck_radar', - 'expires_at': time.time() + max(1, duration) - } - player['temporary_effects'].append(effect) - - return { - "type": "duck_radar", - "duration": duration - } - - elif item_type == 'summon_duck': - # Actual spawning is handled by the bot (needs channel context) - delay = int(item.get('delay', 0)) - delay = max(0, min(delay, 86400)) # cap to 24h - return { - "type": "summon_duck", - "delay": delay - } elif item_type == 'insurance': # Add insurance protection against friendly fire diff --git a/src/utils.py b/src/utils.py index 1b575d3..14f7e1e 100644 --- a/src/utils.py +++ b/src/utils.py @@ -56,7 +56,7 @@ class MessageManager: "help_header": "DuckHunt Commands:", "help_user_commands": "!bang - Shoot at ducks | !reload - Reload your gun | !shop - View the shop", "help_help_command": "!duckhelp - Show this help", - "help_admin_commands": "Admin: !rearm | !disarm | !ignore | !unignore | !ducklaunch | !join <#channel> | !leave <#channel>", + "help_admin_commands": "Admin: !rearm | !disarm | !ignore | !unignore | !ducklaunch", "admin_rearm_player": "[ADMIN] {target} has been rearmed by {admin}", "admin_rearm_all": "[ADMIN] All players have been rearmed by {admin}", "admin_disarm": "[ADMIN] {target} has been disarmed by {admin}",