Prepare for GitHub release
This commit is contained in:
242
src/db.py
242
src/db.py
@@ -8,6 +8,7 @@ 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
|
||||
|
||||
|
||||
@@ -15,8 +16,16 @@ class DuckDB:
|
||||
"""Simplified database management"""
|
||||
|
||||
def __init__(self, db_file="duckhunt.json", bot=None):
|
||||
self.db_file = db_file
|
||||
# Resolve relative paths against the project root (repo root), not the process CWD.
|
||||
# This prevents "stats wiped" symptoms when the bot is launched from a different working dir.
|
||||
if os.path.isabs(db_file):
|
||||
self.db_file = db_file
|
||||
else:
|
||||
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')
|
||||
|
||||
@@ -24,7 +33,63 @@ class DuckDB:
|
||||
self.error_recovery = ErrorRecovery()
|
||||
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
|
||||
|
||||
self.load_database()
|
||||
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}")
|
||||
self.players = {}
|
||||
|
||||
def load_database(self) -> dict:
|
||||
"""Load the database, creating it if it doesn't exist"""
|
||||
@@ -54,14 +119,28 @@ class DuckDB:
|
||||
'last_modified': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Initialize players section if missing
|
||||
if 'players' not in data:
|
||||
data['players'] = {}
|
||||
# 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'] = {}
|
||||
|
||||
# Update last_modified
|
||||
data['metadata']['last_modified'] = datetime.now().isoformat()
|
||||
|
||||
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
|
||||
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")
|
||||
return data
|
||||
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
@@ -75,9 +154,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"
|
||||
}
|
||||
@@ -92,9 +171,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"
|
||||
}
|
||||
@@ -201,26 +280,40 @@ 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}")
|
||||
|
||||
if valid_count == 0:
|
||||
raise ValueError("No valid player data to save")
|
||||
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}")
|
||||
|
||||
# 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.
|
||||
|
||||
# Write to temporary file first (atomic write)
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
@@ -257,7 +350,79 @@ class DuckDB:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_player(self, nick):
|
||||
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):
|
||||
"""Get player data, creating if doesn't exist with comprehensive validation"""
|
||||
try:
|
||||
# Validate and sanitize nick
|
||||
@@ -278,14 +443,16 @@ class DuckDB:
|
||||
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)
|
||||
channel_players = self.get_players_for_channel(channel)
|
||||
|
||||
if nick_lower not in channel_players:
|
||||
channel_players[nick_lower] = self.create_player(nick_clean)
|
||||
else:
|
||||
# Ensure existing players have all required fields
|
||||
player = self.players[nick_lower]
|
||||
player = channel_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)
|
||||
channel_players[nick_lower] = self.create_player(nick_clean)
|
||||
else:
|
||||
# Migrate and validate existing player data with error recovery
|
||||
validated = self.error_recovery.safe_execute(
|
||||
@@ -293,9 +460,9 @@ class DuckDB:
|
||||
fallback=self.create_player(nick_clean),
|
||||
logger=self.logger
|
||||
)
|
||||
self.players[nick_lower] = validated
|
||||
channel_players[nick_lower] = validated
|
||||
|
||||
return self.players[nick_lower]
|
||||
return channel_players[nick_lower]
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Critical error getting player {nick}: {e}")
|
||||
@@ -409,11 +576,16 @@ class DuckDB:
|
||||
}
|
||||
|
||||
def get_leaderboard(self, category='xp', limit=3):
|
||||
"""Get top players by specified category"""
|
||||
"""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"""
|
||||
try:
|
||||
leaderboard = []
|
||||
|
||||
for nick, player_data in self.players.items():
|
||||
|
||||
channel_players = self.get_players_for_channel(channel)
|
||||
for nick, player_data in channel_players.items():
|
||||
sanitized_data = self._sanitize_player_data(player_data)
|
||||
|
||||
if category == 'xp':
|
||||
|
||||
@@ -38,6 +38,9 @@ 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()
|
||||
@@ -58,7 +61,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 sum(1 for _ in self.db.iter_all_players()) >= 0,
|
||||
critical=True
|
||||
)
|
||||
|
||||
@@ -130,10 +133,8 @@ class DuckHuntBot:
|
||||
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
|
||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||
player = self.db.get_player(target, channel_ctx)
|
||||
action_func(player)
|
||||
|
||||
message = self.messages.get(success_message_key, target=target, admin=nick)
|
||||
@@ -147,29 +148,21 @@ 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)
|
||||
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_ctx)
|
||||
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_ctx)
|
||||
return player, None
|
||||
|
||||
def _get_validated_target_player(self, nick, channel, target_nick):
|
||||
@@ -566,8 +559,9 @@ 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),
|
||||
lambda: self.db.get_player(nick, channel_ctx),
|
||||
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
|
||||
logger=self.logger
|
||||
)
|
||||
@@ -580,7 +574,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}")
|
||||
|
||||
@@ -672,6 +665,13 @@ 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(
|
||||
@@ -707,6 +707,20 @@ 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:
|
||||
@@ -743,8 +757,9 @@ class DuckHuntBot:
|
||||
|
||||
if not target_nick:
|
||||
return False, None, "Invalid target nickname"
|
||||
|
||||
player = self.db.get_player(target_nick)
|
||||
|
||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||
player = self.db.get_player(target_nick, channel_ctx)
|
||||
if not player:
|
||||
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
|
||||
|
||||
@@ -768,7 +783,8 @@ class DuckHuntBot:
|
||||
We assume if someone has been active recently, they're still in the channel.
|
||||
"""
|
||||
try:
|
||||
player = self.db.get_player(nick)
|
||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||
player = self.db.get_player(nick, channel_ctx)
|
||||
if not player:
|
||||
return False
|
||||
|
||||
@@ -887,7 +903,8 @@ class DuckHuntBot:
|
||||
"""Handle !duckstats command"""
|
||||
if args and len(args) > 0:
|
||||
target_nick = args[0]
|
||||
target_player = self.db.get_player(target_nick)
|
||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||
target_player = self.db.get_player(target_nick, channel_ctx)
|
||||
if not target_player:
|
||||
message = f"{nick} > Player '{target_nick}' not found."
|
||||
self.send_message(channel, message)
|
||||
@@ -980,11 +997,13 @@ class DuckHuntBot:
|
||||
bold = self.messages.messages.get('colours', {}).get('bold', '')
|
||||
reset = self.messages.messages.get('colours', {}).get('reset', '')
|
||||
|
||||
# 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)
|
||||
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)
|
||||
|
||||
# Format XP leaderboard as single line
|
||||
if top_xp:
|
||||
@@ -1013,19 +1032,216 @@ class DuckHuntBot:
|
||||
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
|
||||
|
||||
async def handle_duckhelp(self, nick, channel, _player):
|
||||
"""Handle !duckhelp command"""
|
||||
"""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
|
||||
|
||||
help_lines = [
|
||||
self.messages.get('help_header'),
|
||||
self.messages.get('help_user_commands'),
|
||||
self.messages.get('help_help_command')
|
||||
"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 <item_id> — buy from shop. Example: !buy 3",
|
||||
"- !use <item_id> [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 <item_id> <player> — 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",
|
||||
]
|
||||
|
||||
# Add admin commands if user is admin
|
||||
if self.is_admin(f"{nick}!user@host"):
|
||||
help_lines.append(self.messages.get('help_admin_commands'))
|
||||
|
||||
|
||||
# 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 <player|all> — rearm a player. Example: !rearm SomeNick OR !rearm all",
|
||||
"- !disarm <player> — confiscate gun. Example: !disarm SomeNick",
|
||||
"- !ignore <player> — ignore a player. Example: !ignore SomeNick",
|
||||
"- !unignore <player> — 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",
|
||||
])
|
||||
|
||||
for line in help_lines:
|
||||
self.send_message(channel, line)
|
||||
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 <player>
|
||||
|
||||
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
|
||||
|
||||
if target_channel 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
|
||||
|
||||
# Track it immediately (we also reconcile on actual JOIN server message).
|
||||
self.channels_joined.add(target_channel)
|
||||
|
||||
# Update in-memory config so reconnects keep the new channel.
|
||||
channels = self._config_channels_list()
|
||||
if target_channel not in channels:
|
||||
channels.append(target_channel)
|
||||
|
||||
# Persist across restarts
|
||||
if not self._persist_config():
|
||||
self.send_message(channel, f"{nick} > Joined {target_channel}, but failed to write config.json (check permissions).")
|
||||
return
|
||||
|
||||
self.send_message(channel, f"{nick} > Joining {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
|
||||
|
||||
# Cancel any pending rejoin attempts and forget state.
|
||||
if target_channel in self.rejoin_tasks:
|
||||
try:
|
||||
self.rejoin_tasks[target_channel].cancel()
|
||||
except Exception:
|
||||
pass
|
||||
del self.rejoin_tasks[target_channel]
|
||||
if target_channel in self.rejoin_attempts:
|
||||
del self.rejoin_attempts[target_channel]
|
||||
|
||||
self.channels_joined.discard(target_channel)
|
||||
|
||||
# Update in-memory config so reconnects do not rejoin the channel.
|
||||
channels = self._config_channels_list()
|
||||
try:
|
||||
while target_channel in channels:
|
||||
channels.remove(target_channel)
|
||||
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}.")
|
||||
|
||||
async def handle_use(self, nick, channel, player, args):
|
||||
"""Handle !use command"""
|
||||
@@ -1212,7 +1428,8 @@ 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():
|
||||
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():
|
||||
if player.get('gun_confiscated', False):
|
||||
player['gun_confiscated'] = False
|
||||
self.levels.update_player_magazines(player)
|
||||
@@ -1251,10 +1468,8 @@ 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
|
||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||
player = self.db.get_player(nick, channel_ctx)
|
||||
|
||||
player['gun_confiscated'] = False
|
||||
|
||||
@@ -1317,10 +1532,8 @@ 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
|
||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||
player = self.db.get_player(target, channel_ctx)
|
||||
|
||||
action_func(player)
|
||||
|
||||
|
||||
36
src/game.py
36
src/game.py
@@ -34,12 +34,20 @@ class DuckGame:
|
||||
"""Duck spawning loop with responsive shutdown"""
|
||||
try:
|
||||
while True:
|
||||
# Pick a target channel first so spawn multipliers are per-channel
|
||||
channels = list(self.bot.channels_joined)
|
||||
if not channels:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
channel = random.choice(channels)
|
||||
|
||||
# Wait random time between spawns, but in small chunks for responsiveness
|
||||
min_wait = self.bot.get_config('duck_spawning.spawn_min', 300) # 5 minutes
|
||||
max_wait = self.bot.get_config('duck_spawning.spawn_max', 900) # 15 minutes
|
||||
|
||||
# Check for active bread effects to modify spawn timing
|
||||
spawn_multiplier = self._get_active_spawn_multiplier()
|
||||
spawn_multiplier = self._get_active_spawn_multiplier(channel)
|
||||
if spawn_multiplier > 1.0:
|
||||
# Reduce wait time when bread is active
|
||||
min_wait = int(min_wait / spawn_multiplier)
|
||||
@@ -51,10 +59,8 @@ class DuckGame:
|
||||
for _ in range(wait_time):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Spawn duck in random channel
|
||||
channels = list(self.bot.channels_joined)
|
||||
if channels:
|
||||
channel = random.choice(channels)
|
||||
# Spawn duck in the chosen channel (if still joined)
|
||||
if channel in self.bot.channels_joined:
|
||||
await self.spawn_duck(channel)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
@@ -284,7 +290,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,7 +330,7 @@ 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():
|
||||
for other_nick, other_player in self.db.get_players_for_channel(channel).items():
|
||||
if (other_nick.lower() != nick.lower() and
|
||||
not other_player.get('gun_confiscated', False) and
|
||||
other_player.get('current_ammo', 0) > 0):
|
||||
@@ -424,7 +430,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 +500,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 a 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
|
||||
@@ -511,14 +517,14 @@ class DuckGame:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in _rearm_all_disarmed_players: {e}")
|
||||
|
||||
def _get_active_spawn_multiplier(self):
|
||||
"""Get the current spawn rate multiplier from active bread effects"""
|
||||
def _get_active_spawn_multiplier(self, channel):
|
||||
"""Get the current spawn rate multiplier from active bread effects in a channel"""
|
||||
import time
|
||||
max_multiplier = 1.0
|
||||
current_time = time.time()
|
||||
|
||||
try:
|
||||
for player_name, player_data in self.db.players.items():
|
||||
for player_name, player_data in self.db.get_players_for_channel(channel).items():
|
||||
effects = player_data.get('temporary_effects', [])
|
||||
for effect in effects:
|
||||
if (effect.get('type') == 'attract_ducks' and
|
||||
@@ -566,7 +572,7 @@ class DuckGame:
|
||||
current_time = time.time()
|
||||
|
||||
try:
|
||||
for player_name, player_data in self.db.players.items():
|
||||
for _channel, player_name, player_data in self.db.iter_all_players():
|
||||
effects = player_data.get('temporary_effects', [])
|
||||
active_effects = []
|
||||
|
||||
|
||||
@@ -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 <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch",
|
||||
"help_admin_commands": "Admin: !rearm <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch | !join <#channel> | !leave <#channel>",
|
||||
"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}",
|
||||
|
||||
Reference in New Issue
Block a user