Separate player stats per channel

This commit is contained in:
3nd3r
2025-12-30 22:43:23 -06:00
parent 214d1ed263
commit 67bf6957a7
3 changed files with 199 additions and 91 deletions

203
src/db.py Normal file → Executable file
View File

@@ -23,7 +23,8 @@ class DuckDB:
project_root = os.path.dirname(os.path.dirname(__file__)) project_root = os.path.dirname(os.path.dirname(__file__))
self.db_file = os.path.join(project_root, db_file) self.db_file = os.path.join(project_root, db_file)
self.bot = bot self.bot = bot
self.players = {} # Channel-scoped data: {"#channel": {"players": {"nick": player_dict}}}
self.channels = {}
self.logger = logging.getLogger('DuckHuntBot.DB') self.logger = logging.getLogger('DuckHuntBot.DB')
# Error recovery configuration # Error recovery configuration
@@ -32,12 +33,37 @@ class DuckDB:
data = self.load_database() data = self.load_database()
# Hydrate in-memory state from disk. # Hydrate in-memory state from disk.
# Previously, load_database() returned data but self.players stayed empty, if isinstance(data, dict) and isinstance(data.get('channels'), dict):
# making it look like everything reset after restart. self.channels = data['channels']
if isinstance(data, dict) and isinstance(data.get('players'), dict):
self.players = data['players']
else: 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: def load_database(self) -> dict:
"""Load the database, creating it if it doesn't exist""" """Load the database, creating it if it doesn't exist"""
@@ -66,15 +92,40 @@ class DuckDB:
'created': datetime.now().isoformat(), 'created': datetime.now().isoformat(),
'last_modified': datetime.now().isoformat() 'last_modified': datetime.now().isoformat()
} }
# Initialize players section if missing # Migrate legacy flat structure (players) -> channels
if 'players' not in data: if 'channels' not in data or not isinstance(data.get('channels'), dict):
data['players'] = {} 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 # Update last_modified
data['metadata']['last_modified'] = datetime.now().isoformat() 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 return data
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
@@ -88,9 +139,9 @@ class DuckDB:
"""Create a new default database file with proper structure""" """Create a new default database file with proper structure"""
try: try:
default_data = { default_data = {
"players": {}, "channels": {},
"last_save": str(time.time()), "last_save": str(time.time()),
"version": "1.0", "version": "2.0",
"created": time.strftime("%Y-%m-%d %H:%M:%S"), "created": time.strftime("%Y-%m-%d %H:%M:%S"),
"description": "DuckHunt Bot Player Database" "description": "DuckHunt Bot Player Database"
} }
@@ -105,9 +156,9 @@ class DuckDB:
self.logger.error(f"Failed to create default database: {e}") self.logger.error(f"Failed to create default database: {e}")
# Return a minimal valid structure even if file creation fails # Return a minimal valid structure even if file creation fails
return { return {
"players": {}, "channels": {},
"last_save": str(time.time()), "last_save": str(time.time()),
"version": "1.0", "version": "2.0",
"created": time.strftime("%Y-%m-%d %H:%M:%S"), "created": time.strftime("%Y-%m-%d %H:%M:%S"),
"description": "DuckHunt Bot Player Database" "description": "DuckHunt Bot Player Database"
} }
@@ -135,6 +186,14 @@ class DuckDB:
# Equipment and stats # Equipment and stats
sanitized['accuracy'] = max(0, min(max_accuracy, int(float(player_data.get('accuracy', default_accuracy))))) 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)) 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 # Ammo system with validation
sanitized['current_ammo'] = max(0, min(50, int(float(player_data.get('current_ammo', default_bullets_per_mag))))) 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: try:
# Prepare data with validation # Prepare data with validation
data = { data = {
'players': {}, 'channels': {},
'last_save': str(time.time()), 'last_save': str(time.time()),
'version': '1.0' 'version': '2.0'
} }
# Validate and clean player data before saving # Validate and clean player data before saving
valid_count = 0 valid_count = 0
for nick, player_data in self.players.items(): for channel_key, channel_data in (self.channels or {}).items():
if isinstance(nick, str) and isinstance(player_data, dict): if not isinstance(channel_key, str) or not isinstance(channel_data, dict):
try: continue
sanitized_nick = sanitize_user_input(nick, max_length=50) players = channel_data.get('players', {})
data['players'][sanitized_nick] = self._sanitize_player_data(player_data) if not isinstance(players, dict):
valid_count += 1 continue
except Exception as e:
self.logger.warning(f"Error processing player {nick} during save: {e}") out_channel_key = str(channel_key)
else: data['channels'].setdefault(out_channel_key, {'players': {}})
self.logger.warning(f"Skipping invalid player data during save: {nick}") 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). # 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. # Previously this raised and prevented the file from being written/updated.
@@ -270,8 +336,52 @@ class DuckDB:
except Exception: except Exception:
pass pass
def get_player(self, nick): def get_players_for_channel(self, channel: str) -> dict:
"""Get player data, creating if doesn't exist with comprehensive validation""" """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: try:
# Validate and sanitize nick # Validate and sanitize nick
if not isinstance(nick, str) or not nick.strip(): if not isinstance(nick, str) or not nick.strip():
@@ -290,15 +400,17 @@ class DuckDB:
if not nick_lower: if not nick_lower:
self.logger.warning(f"Empty nick after sanitization: {nick}") self.logger.warning(f"Empty nick after sanitization: {nick}")
return self.create_player('Unknown') return self.create_player('Unknown')
if nick_lower not in self.players: players = self.get_players_for_channel(channel)
self.players[nick_lower] = self.create_player(nick_clean)
if nick_lower not in players:
players[nick_lower] = self.create_player(nick_clean)
else: else:
# Ensure existing players have all required fields # Ensure existing players have all required fields
player = self.players[nick_lower] player = players[nick_lower]
if not isinstance(player, dict): if not isinstance(player, dict):
self.logger.warning(f"Invalid player data for {nick_lower}, recreating") 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: else:
# Migrate and validate existing player data with error recovery # Migrate and validate existing player data with error recovery
validated = self.error_recovery.safe_execute( validated = self.error_recovery.safe_execute(
@@ -306,9 +418,9 @@ class DuckDB:
fallback=self.create_player(nick_clean), fallback=self.create_player(nick_clean),
logger=self.logger logger=self.logger
) )
self.players[nick_lower] = validated players[nick_lower] = validated
return self.players[nick_lower] return players[nick_lower]
except Exception as e: except Exception as e:
self.logger.error(f"Critical error getting player {nick}: {e}") self.logger.error(f"Critical error getting player {nick}: {e}")
@@ -376,6 +488,9 @@ class DuckDB:
'confiscated_magazines': 0, 'confiscated_magazines': 0,
'inventory': {}, 'inventory': {},
'temporary_effects': [], 'temporary_effects': [],
'last_activity_channel': '',
'last_activity_time': 0.0,
'ignored': False,
# Additional fields to prevent KeyErrors # Additional fields to prevent KeyErrors
'best_time': 0.0, 'best_time': 0.0,
'worst_time': 0.0, 'worst_time': 0.0,
@@ -408,6 +523,9 @@ class DuckDB:
'confiscated_magazines': 0, 'confiscated_magazines': 0,
'inventory': {}, 'inventory': {},
'temporary_effects': [], 'temporary_effects': [],
'last_activity_channel': '',
'last_activity_time': 0.0,
'ignored': False,
'best_time': 0.0, 'best_time': 0.0,
'worst_time': 0.0, 'worst_time': 0.0,
'total_time_hunting': 0.0, 'total_time_hunting': 0.0,
@@ -421,12 +539,13 @@ class DuckDB:
'chargers': 2 'chargers': 2
} }
def get_leaderboard(self, category='xp', limit=3): def get_leaderboard(self, channel: str, category='xp', limit=3):
"""Get top players by specified category""" """Get top players by specified category for a given channel"""
try: try:
leaderboard = [] 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) sanitized_data = self._sanitize_player_data(player_data)
if category == 'xp': if category == 'xp':

69
src/duckhuntbot.py Normal file → Executable file
View File

@@ -62,7 +62,7 @@ class DuckHuntBot:
# Database health check # Database health check
self.health_checker.add_check( self.health_checker.add_check(
'database', '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 critical=True
) )
@@ -142,11 +142,8 @@ class DuckHuntBot:
self.send_message(channel, message) self.send_message(channel, message)
return False return False
target = args[0].lower() target = args[0]
player = self.db.get_player(target) player = self.db.get_player(target, channel)
if player is None:
player = self.db.create_player(target)
self.db.players[target] = player
action_func(player) action_func(player)
message = self.messages.get(success_message_key, target=target, admin=nick) message = self.messages.get(success_message_key, target=target, admin=nick)
@@ -164,25 +161,16 @@ class DuckHuntBot:
if not is_private_msg: if not is_private_msg:
if target_nick.lower() == nick.lower(): if target_nick.lower() == nick.lower():
target_nick = target_nick.lower() target_nick = target_nick.lower()
player = self.db.get_player(target_nick) player = self.db.get_player(target_nick, channel)
if player is None:
player = self.db.create_player(target_nick)
self.db.players[target_nick] = player
return player, None return player, None
else: else:
is_valid, player, error_msg = self.validate_target_player(target_nick, channel) is_valid, player, error_msg = self.validate_target_player(target_nick, channel)
if not is_valid: if not is_valid:
return None, error_msg 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 return player, None
else: else:
target_nick = target_nick.lower() target_nick = target_nick.lower()
player = self.db.get_player(target_nick) player = self.db.get_player(target_nick, channel)
if player is None:
player = self.db.create_player(target_nick)
self.db.players[target_nick] = player
return player, None return player, None
def _get_validated_target_player(self, nick, channel, target_nick): def _get_validated_target_player(self, nick, channel, target_nick):
@@ -620,7 +608,7 @@ class DuckHuntBot:
# Get player data with error recovery # Get player data with error recovery
player = self.error_recovery.safe_execute( 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}, fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
logger=self.logger logger=self.logger
) )
@@ -633,7 +621,6 @@ class DuckHuntBot:
try: try:
player['last_activity_channel'] = safe_channel player['last_activity_channel'] = safe_channel
player['last_activity_time'] = time.time() player['last_activity_time'] = time.time()
self.db.players[nick.lower()] = player
except Exception as e: except Exception as e:
self.logger.warning(f"Error updating player activity for {nick}: {e}") self.logger.warning(f"Error updating player activity for {nick}: {e}")
@@ -811,9 +798,9 @@ class DuckHuntBot:
if not target_nick: if not target_nick:
return False, None, "Invalid target nickname" 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: 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 = ( has_activity = (
player.get('xp', 0) > 0 or 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. We assume if someone has been active recently, they're still in the channel.
""" """
try: try:
player = self.db.get_player(nick) player = self.db.get_player_if_exists(nick, channel)
if not player: if not player:
return False return False
@@ -954,9 +941,9 @@ class DuckHuntBot:
"""Handle !duckstats command""" """Handle !duckstats command"""
if args and len(args) > 0: if args and len(args) > 0:
target_nick = 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: 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) self.send_message(channel, message)
return return
display_nick = target_nick display_nick = target_nick
@@ -1048,10 +1035,10 @@ class DuckHuntBot:
reset = self.messages.messages.get('colours', {}).get('reset', '') reset = self.messages.messages.get('colours', {}).get('reset', '')
# Get top 3 by XP # 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 # 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 # Format XP leaderboard as single line
if top_xp: if top_xp:
@@ -1329,12 +1316,20 @@ class DuckHuntBot:
# Check if admin wants to rearm all players # Check if admin wants to rearm all players
if target_nick.lower() == 'all': if target_nick.lower() == 'all':
rearmed_count = 0 rearmed_count = 0
for player_nick, player in self.db.players.items(): if is_private_msg:
if player.get('gun_confiscated', False): for _ch, _pn, p in self.db.iter_all_players():
player['gun_confiscated'] = False if p.get('gun_confiscated', False):
self.levels.update_player_magazines(player) p['gun_confiscated'] = False
player['current_ammo'] = player.get('bullets_per_magazine', 6) self.levels.update_player_magazines(p)
rearmed_count += 1 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: if is_private_msg:
message = f"{nick} > Rearmed all players ({rearmed_count} players)" message = f"{nick} > Rearmed all players ({rearmed_count} players)"
@@ -1368,10 +1363,7 @@ class DuckHuntBot:
return return
# Rearm the admin themselves (only in channels) # Rearm the admin themselves (only in channels)
player = self.db.get_player(nick) player = self.db.get_player(nick, channel)
if player is None:
player = self.db.create_player(nick)
self.db.players[nick.lower()] = player
player['gun_confiscated'] = False player['gun_confiscated'] = False
@@ -1434,10 +1426,7 @@ class DuckHuntBot:
return return
target = args[0].lower() target = args[0].lower()
player = self.db.get_player(target) player = self.db.get_player(target, channel)
if player is None:
player = self.db.create_player(target)
self.db.players[target] = player
action_func(player) action_func(player)

18
src/game.py Normal file → Executable file
View File

@@ -284,7 +284,7 @@ class DuckGame:
# If config option enabled, rearm all disarmed players when duck is shot # If config option enabled, rearm all disarmed players when duck is shot
if self.bot.get_config('duck_spawning.rearm_on_duck_shot', False): 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 # Check for item drops
dropped_item = self._check_item_drop(player, duck_type) dropped_item = self._check_item_drop(player, duck_type)
@@ -324,8 +324,8 @@ class DuckGame:
if random.random() < friendly_fire_chance: if random.random() < friendly_fire_chance:
# Get other armed players in the same channel # Get other armed players in the same channel
armed_players = [] armed_players = []
for other_nick, other_player in self.db.players.items(): for other_nick, other_player in self.db.get_players_for_channel(channel).items():
if (other_nick.lower() != nick.lower() and if (str(other_nick).lower() != nick.lower() and
not other_player.get('gun_confiscated', False) and not other_player.get('gun_confiscated', False) and
other_player.get('current_ammo', 0) > 0): other_player.get('current_ammo', 0) > 0):
armed_players.append((other_nick, other_player)) 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 config option enabled, rearm all disarmed players when duck is befriended
if self.bot.get_config('rearm_on_duck_shot', False): 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() self.db.save_database()
return { return {
@@ -494,11 +494,11 @@ class DuckGame:
} }
} }
def _rearm_all_disarmed_players(self): def _rearm_all_disarmed_players(self, channel):
"""Rearm all players who have been disarmed (gun confiscated)""" """Rearm all players who have been disarmed (gun confiscated) in the given channel"""
try: try:
rearmed_count = 0 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): if player_data.get('gun_confiscated', False):
player_data['gun_confiscated'] = False player_data['gun_confiscated'] = False
# Update magazines based on player level # Update magazines based on player level
@@ -518,7 +518,7 @@ class DuckGame:
current_time = time.time() current_time = time.time()
try: 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', []) effects = player_data.get('temporary_effects', [])
for effect in effects: for effect in effects:
if (effect.get('type') == 'attract_ducks' and if (effect.get('type') == 'attract_ducks' and
@@ -566,7 +566,7 @@ class DuckGame:
current_time = time.time() current_time = time.time()
try: 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', []) effects = player_data.get('temporary_effects', [])
active_effects = [] active_effects = []