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__))
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':

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

@@ -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)

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 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 = []