From 67bf6957a762261630d5db5bc8b79631194427c0 Mon Sep 17 00:00:00 2001 From: 3nd3r Date: Tue, 30 Dec 2025 22:43:23 -0600 Subject: [PATCH] Separate player stats per channel --- src/db.py | 203 +++++++++++++++++++++++++++++++++++---------- src/duckhuntbot.py | 69 +++++++-------- src/game.py | 18 ++-- 3 files changed, 199 insertions(+), 91 deletions(-) mode change 100644 => 100755 src/db.py mode change 100644 => 100755 src/duckhuntbot.py mode change 100644 => 100755 src/game.py diff --git a/src/db.py b/src/db.py old mode 100644 new mode 100755 index f095152..8c00935 --- a/src/db.py +++ b/src/db.py @@ -23,7 +23,8 @@ class DuckDB: project_root = os.path.dirname(os.path.dirname(__file__)) self.db_file = os.path.join(project_root, db_file) self.bot = bot - self.players = {} + # Channel-scoped data: {"#channel": {"players": {"nick": player_dict}}} + self.channels = {} self.logger = logging.getLogger('DuckHuntBot.DB') # Error recovery configuration @@ -32,12 +33,37 @@ class DuckDB: data = self.load_database() # 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'] + if isinstance(data, dict) and isinstance(data.get('channels'), dict): + self.channels = data['channels'] else: - self.players = {} + self.channels = {} + + @staticmethod + def _normalize_channel(channel: str) -> str: + """Normalize channel keys (case-insensitive). Non-channel contexts go to a reserved bucket.""" + if not isinstance(channel, str): + return '__unknown__' + channel = channel.strip() + if not channel: + return '__unknown__' + if channel.startswith('#') or channel.startswith('&'): + return channel.lower() + return '__pm__' + + @property + def players(self): + """Backward-compatible flattened view of all players across channels.""" + flattened = {} + try: + for _channel_key, channel_data in (self.channels or {}).items(): + players = channel_data.get('players', {}) if isinstance(channel_data, dict) else {} + if isinstance(players, dict): + for nick, player in players.items(): + # Last-write-wins if the same nick exists in multiple channels. + flattened[nick] = player + except Exception: + return {} + return flattened def load_database(self) -> dict: """Load the database, creating it if it doesn't exist""" @@ -66,15 +92,40 @@ class DuckDB: 'created': datetime.now().isoformat(), 'last_modified': datetime.now().isoformat() } - - # Initialize players section if missing - if 'players' not in data: - data['players'] = {} + + # Migrate legacy flat structure (players) -> channels + if 'channels' not in data or not isinstance(data.get('channels'), dict): + legacy_players = data.get('players') if isinstance(data.get('players'), dict) else {} + channels = {} + if isinstance(legacy_players, dict): + for legacy_nick, legacy_player in legacy_players.items(): + try: + last_channel = legacy_player.get('last_activity_channel') if isinstance(legacy_player, dict) else None + channel_key = self._normalize_channel(last_channel) if last_channel else '__global__' + channels.setdefault(channel_key, {'players': {}}) + if isinstance(channels[channel_key].get('players'), dict): + channels[channel_key]['players'][str(legacy_nick).lower()] = legacy_player + except Exception: + continue + + data['channels'] = channels + data['metadata']['version'] = '2.0' + + # Ensure channels structure exists + if 'channels' not in data: + data['channels'] = {} # Update last_modified data['metadata']['last_modified'] = datetime.now().isoformat() - self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players") + total_players = 0 + try: + for _c, cdata in data.get('channels', {}).items(): + if isinstance(cdata, dict) and isinstance(cdata.get('players'), dict): + total_players += len(cdata['players']) + except Exception: + total_players = 0 + self.logger.info(f"Successfully loaded database with {total_players} players across {len(data.get('channels', {}))} channels") return data except (json.JSONDecodeError, ValueError) as e: @@ -88,9 +139,9 @@ class DuckDB: """Create a new default database file with proper structure""" try: default_data = { - "players": {}, + "channels": {}, "last_save": str(time.time()), - "version": "1.0", + "version": "2.0", "created": time.strftime("%Y-%m-%d %H:%M:%S"), "description": "DuckHunt Bot Player Database" } @@ -105,9 +156,9 @@ class DuckDB: self.logger.error(f"Failed to create default database: {e}") # Return a minimal valid structure even if file creation fails return { - "players": {}, + "channels": {}, "last_save": str(time.time()), - "version": "1.0", + "version": "2.0", "created": time.strftime("%Y-%m-%d %H:%M:%S"), "description": "DuckHunt Bot Player Database" } @@ -135,6 +186,14 @@ class DuckDB: # Equipment and stats sanitized['accuracy'] = max(0, min(max_accuracy, int(float(player_data.get('accuracy', default_accuracy))))) sanitized['gun_confiscated'] = bool(player_data.get('gun_confiscated', False)) + + # Activity / admin flags + sanitized['last_activity_channel'] = str(player_data.get('last_activity_channel', ''))[:100] + try: + sanitized['last_activity_time'] = float(player_data.get('last_activity_time', 0.0)) + except (ValueError, TypeError): + sanitized['last_activity_time'] = 0.0 + sanitized['ignored'] = bool(player_data.get('ignored', False)) # Ammo system with validation sanitized['current_ammo'] = max(0, min(50, int(float(player_data.get('current_ammo', default_bullets_per_mag))))) @@ -214,23 +273,30 @@ class DuckDB: try: # Prepare data with validation data = { - 'players': {}, + 'channels': {}, 'last_save': str(time.time()), - 'version': '1.0' + 'version': '2.0' } - + # Validate and clean player data before saving valid_count = 0 - 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}") + for channel_key, channel_data in (self.channels or {}).items(): + if not isinstance(channel_key, str) or not isinstance(channel_data, dict): + continue + players = channel_data.get('players', {}) + if not isinstance(players, dict): + continue + + out_channel_key = str(channel_key) + data['channels'].setdefault(out_channel_key, {'players': {}}) + for nick, player_data in players.items(): + if isinstance(nick, str) and isinstance(player_data, dict): + try: + sanitized_nick = sanitize_user_input(nick, max_length=50) + data['channels'][out_channel_key]['players'][sanitized_nick] = self._sanitize_player_data(player_data) + valid_count += 1 + except Exception as e: + self.logger.warning(f"Error processing player {nick} in {out_channel_key} during save: {e}") # 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. @@ -270,8 +336,52 @@ class DuckDB: except Exception: pass - def get_player(self, nick): - """Get player data, creating if doesn't exist with comprehensive validation""" + def get_players_for_channel(self, channel: str) -> dict: + """Get the players dict for a channel, creating the channel bucket if needed.""" + channel_key = self._normalize_channel(channel) + bucket = self.channels.setdefault(channel_key, {'players': {}}) + if not isinstance(bucket, dict): + bucket = {'players': {}} + self.channels[channel_key] = bucket + if 'players' not in bucket or not isinstance(bucket.get('players'), dict): + bucket['players'] = {} + return bucket['players'] + + def iter_all_players(self): + """Yield (channel_key, nick, player_dict) for all players in all channels.""" + for channel_key, channel_data in (self.channels or {}).items(): + if not isinstance(channel_data, dict): + continue + players = channel_data.get('players', {}) + if not isinstance(players, dict): + continue + for nick, player in players.items(): + yield channel_key, nick, player + + def get_player_if_exists(self, nick, channel: str): + """Return player dict for nick+channel if present; does not create records.""" + 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.lower().strip() + if not nick_lower: + return None + channel_key = self._normalize_channel(channel) + channel_data = self.channels.get(channel_key) + if not isinstance(channel_data, dict): + return None + players = channel_data.get('players') + if not isinstance(players, dict): + return None + player = players.get(nick_lower) + return player if isinstance(player, dict) else None + except Exception: + return None + + def get_player(self, nick, channel: str): + """Get player data for a specific channel, creating if doesn't exist with comprehensive validation""" try: # Validate and sanitize nick if not isinstance(nick, str) or not nick.strip(): @@ -290,15 +400,17 @@ class DuckDB: if not nick_lower: self.logger.warning(f"Empty nick after sanitization: {nick}") return self.create_player('Unknown') - - if nick_lower not in self.players: - self.players[nick_lower] = self.create_player(nick_clean) + + players = self.get_players_for_channel(channel) + + if nick_lower not in players: + players[nick_lower] = self.create_player(nick_clean) else: # Ensure existing players have all required fields - player = self.players[nick_lower] + player = players[nick_lower] if not isinstance(player, dict): self.logger.warning(f"Invalid player data for {nick_lower}, recreating") - self.players[nick_lower] = self.create_player(nick_clean) + players[nick_lower] = self.create_player(nick_clean) else: # Migrate and validate existing player data with error recovery validated = self.error_recovery.safe_execute( @@ -306,9 +418,9 @@ class DuckDB: fallback=self.create_player(nick_clean), logger=self.logger ) - self.players[nick_lower] = validated - - return self.players[nick_lower] + players[nick_lower] = validated + + return players[nick_lower] except Exception as e: self.logger.error(f"Critical error getting player {nick}: {e}") @@ -376,6 +488,9 @@ class DuckDB: 'confiscated_magazines': 0, 'inventory': {}, 'temporary_effects': [], + 'last_activity_channel': '', + 'last_activity_time': 0.0, + 'ignored': False, # Additional fields to prevent KeyErrors 'best_time': 0.0, 'worst_time': 0.0, @@ -408,6 +523,9 @@ class DuckDB: 'confiscated_magazines': 0, 'inventory': {}, 'temporary_effects': [], + 'last_activity_channel': '', + 'last_activity_time': 0.0, + 'ignored': False, 'best_time': 0.0, 'worst_time': 0.0, 'total_time_hunting': 0.0, @@ -421,12 +539,13 @@ class DuckDB: 'chargers': 2 } - def get_leaderboard(self, category='xp', limit=3): - """Get top players by specified category""" + def get_leaderboard(self, channel: str, category='xp', limit=3): + """Get top players by specified category for a given channel""" try: leaderboard = [] - - for nick, player_data in self.players.items(): + + players = self.get_players_for_channel(channel) + for nick, player_data in players.items(): sanitized_data = self._sanitize_player_data(player_data) if category == 'xp': diff --git a/src/duckhuntbot.py b/src/duckhuntbot.py old mode 100644 new mode 100755 index 200fadc..e076deb --- a/src/duckhuntbot.py +++ b/src/duckhuntbot.py @@ -62,7 +62,7 @@ class DuckHuntBot: # Database health check self.health_checker.add_check( 'database', - lambda: self.db is not None and len(self.db.players) >= 0, + lambda: self.db is not None and hasattr(self.db, 'channels'), critical=True ) @@ -142,11 +142,8 @@ class DuckHuntBot: 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 + target = args[0] + player = self.db.get_player(target, channel) action_func(player) message = self.messages.get(success_message_key, target=target, admin=nick) @@ -164,25 +161,16 @@ class DuckHuntBot: if not is_private_msg: if target_nick.lower() == nick.lower(): target_nick = target_nick.lower() - player = self.db.get_player(target_nick) - if player is None: - player = self.db.create_player(target_nick) - self.db.players[target_nick] = player + player = self.db.get_player(target_nick, channel) 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) - if player is None: - player = self.db.create_player(target_nick) - self.db.players[target_nick] = player + player = self.db.get_player(target_nick, channel) return player, None def _get_validated_target_player(self, nick, channel, target_nick): @@ -620,7 +608,7 @@ class DuckHuntBot: # Get player data with error recovery player = self.error_recovery.safe_execute( - lambda: self.db.get_player(nick), + lambda: self.db.get_player(nick, safe_channel), fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False}, logger=self.logger ) @@ -633,7 +621,6 @@ 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}") @@ -811,9 +798,9 @@ class DuckHuntBot: if not target_nick: return False, None, "Invalid target nickname" - player = self.db.get_player(target_nick) + player = self.db.get_player_if_exists(target_nick, channel) if not player: - return False, None, f"Player '{target_nick}' not found. They need to participate in the game first." + return False, None, f"Player '{target_nick}' not found in {channel}. They need to participate in this channel first." has_activity = ( player.get('xp', 0) > 0 or @@ -835,7 +822,7 @@ class DuckHuntBot: We assume if someone has been active recently, they're still in the channel. """ try: - player = self.db.get_player(nick) + player = self.db.get_player_if_exists(nick, channel) if not player: return False @@ -954,9 +941,9 @@ class DuckHuntBot: """Handle !duckstats command""" if args and len(args) > 0: target_nick = args[0] - target_player = self.db.get_player(target_nick) + target_player = self.db.get_player_if_exists(target_nick, channel) if not target_player: - message = f"{nick} > Player '{target_nick}' not found." + message = f"{nick} > Player '{target_nick}' not found in {channel}." self.send_message(channel, message) return display_nick = target_nick @@ -1048,10 +1035,10 @@ class DuckHuntBot: reset = self.messages.messages.get('colours', {}).get('reset', '') # Get top 3 by XP - top_xp = self.db.get_leaderboard('xp', 3) + top_xp = self.db.get_leaderboard(channel, 'xp', 3) # Get top 3 by ducks shot - top_ducks = self.db.get_leaderboard('ducks_shot', 3) + top_ducks = self.db.get_leaderboard(channel, 'ducks_shot', 3) # Format XP leaderboard as single line if top_xp: @@ -1329,12 +1316,20 @@ class DuckHuntBot: # Check if admin wants to rearm all players if target_nick.lower() == 'all': rearmed_count = 0 - 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) - player['current_ammo'] = player.get('bullets_per_magazine', 6) - rearmed_count += 1 + if is_private_msg: + for _ch, _pn, p in self.db.iter_all_players(): + if p.get('gun_confiscated', False): + p['gun_confiscated'] = False + self.levels.update_player_magazines(p) + p['current_ammo'] = p.get('bullets_per_magazine', 6) + rearmed_count += 1 + else: + for _pn, p in self.db.get_players_for_channel(channel).items(): + if p.get('gun_confiscated', False): + p['gun_confiscated'] = False + self.levels.update_player_magazines(p) + p['current_ammo'] = p.get('bullets_per_magazine', 6) + rearmed_count += 1 if is_private_msg: message = f"{nick} > Rearmed all players ({rearmed_count} players)" @@ -1368,10 +1363,7 @@ class DuckHuntBot: return # Rearm the admin themselves (only in channels) - player = self.db.get_player(nick) - if player is None: - player = self.db.create_player(nick) - self.db.players[nick.lower()] = player + player = self.db.get_player(nick, channel) player['gun_confiscated'] = False @@ -1434,10 +1426,7 @@ class DuckHuntBot: return 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 + player = self.db.get_player(target, channel) action_func(player) diff --git a/src/game.py b/src/game.py old mode 100644 new mode 100755 index b3ed361..150c685 --- a/src/game.py +++ b/src/game.py @@ -284,7 +284,7 @@ class DuckGame: # If config option enabled, rearm all disarmed players when duck is shot if self.bot.get_config('duck_spawning.rearm_on_duck_shot', False): - self._rearm_all_disarmed_players() + self._rearm_all_disarmed_players(channel) # Check for item drops dropped_item = self._check_item_drop(player, duck_type) @@ -324,8 +324,8 @@ class DuckGame: if random.random() < friendly_fire_chance: # Get other armed players in the same channel armed_players = [] - for other_nick, other_player in self.db.players.items(): - if (other_nick.lower() != nick.lower() and + for other_nick, other_player in self.db.get_players_for_channel(channel).items(): + if (str(other_nick).lower() != nick.lower() and not other_player.get('gun_confiscated', False) and other_player.get('current_ammo', 0) > 0): armed_players.append((other_nick, other_player)) @@ -424,7 +424,7 @@ class DuckGame: # If config option enabled, rearm all disarmed players when duck is befriended if self.bot.get_config('rearm_on_duck_shot', False): - self._rearm_all_disarmed_players() + self._rearm_all_disarmed_players(channel) self.db.save_database() return { @@ -494,11 +494,11 @@ class DuckGame: } } - def _rearm_all_disarmed_players(self): - """Rearm all players who have been disarmed (gun confiscated)""" + def _rearm_all_disarmed_players(self, channel): + """Rearm all players who have been disarmed (gun confiscated) in the given channel""" try: rearmed_count = 0 - for player_name, player_data in self.db.players.items(): + for _player_name, player_data in self.db.get_players_for_channel(channel).items(): if player_data.get('gun_confiscated', False): player_data['gun_confiscated'] = False # Update magazines based on player level @@ -518,7 +518,7 @@ class DuckGame: current_time = time.time() try: - for player_name, player_data in self.db.players.items(): + for _ch, _player_name, player_data in self.db.iter_all_players(): effects = player_data.get('temporary_effects', []) for effect in effects: if (effect.get('type') == 'attract_ducks' and @@ -566,7 +566,7 @@ class DuckGame: current_time = time.time() try: - for player_name, player_data in self.db.players.items(): + for _ch, player_name, player_data in self.db.iter_all_players(): effects = player_data.get('temporary_effects', []) active_effects = []