Separate player stats per channel
This commit is contained in:
203
src/db.py
Normal file → Executable file
203
src/db.py
Normal file → Executable 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
69
src/duckhuntbot.py
Normal file → Executable 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
18
src/game.py
Normal file → Executable 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 = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user