Prepare for GitHub release

This commit is contained in:
3nd3r
2025-12-28 13:16:55 -06:00
parent f8c46980de
commit 4d17ae8f04
8 changed files with 672 additions and 394 deletions

242
src/db.py
View File

@@ -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':

View File

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

View File

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

View File

@@ -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}",