Revert to original working code - clean slate

- Start from original code that was known to work
- Will add features back incrementally
- This ensures we know exactly what breaks/works
This commit is contained in:
3nd3r
2025-12-28 17:43:11 -06:00
parent 90b604ba72
commit 5db4ce0ab3
5 changed files with 186 additions and 728 deletions

231
src/db.py
View File

@@ -8,7 +8,6 @@ import logging
import time import time
import os import os
from datetime import datetime from datetime import datetime
from typing import Optional
from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input
@@ -24,8 +23,6 @@ class DuckDB:
project_root = os.path.dirname(os.path.dirname(__file__)) project_root = os.path.dirname(os.path.dirname(__file__))
self.db_file = os.path.join(project_root, db_file) self.db_file = os.path.join(project_root, db_file)
self.bot = bot self.bot = bot
# Channel-scoped player storage:
# {"#channel": {"nick": {player_data}}, ...}
self.players = {} self.players = {}
self.logger = logging.getLogger('DuckHuntBot.DB') self.logger = logging.getLogger('DuckHuntBot.DB')
@@ -34,61 +31,12 @@ class DuckDB:
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0) self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
data = self.load_database() data = self.load_database()
self._hydrate_from_data(data) # Hydrate in-memory state from disk.
# Previously, load_database() returned data but self.players stayed empty,
def _default_channel(self) -> str: # making it look like everything reset after restart.
"""Pick a reasonable default channel context.""" if isinstance(data, dict) and isinstance(data.get('players'), dict):
if self.bot: self.players = data['players']
channels = self.bot.get_config('connection.channels', []) or [] else:
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 = {} self.players = {}
def load_database(self) -> dict: def load_database(self) -> dict:
@@ -119,28 +67,14 @@ class DuckDB:
'last_modified': datetime.now().isoformat() 'last_modified': datetime.now().isoformat()
} }
# Initialize channels section if missing # Initialize players section if missing
if 'channels' not in data: if 'players' not in data:
# If old format has players, keep it for migration. data['players'] = {}
data.setdefault('players', {})
else:
if not isinstance(data.get('channels'), dict):
data['channels'] = {}
# Update last_modified # Update last_modified
data['metadata']['last_modified'] = datetime.now().isoformat() data['metadata']['last_modified'] = datetime.now().isoformat()
try: self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
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 return data
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
@@ -154,9 +88,9 @@ class DuckDB:
"""Create a new default database file with proper structure""" """Create a new default database file with proper structure"""
try: try:
default_data = { default_data = {
"channels": {}, "players": {},
"last_save": str(time.time()), "last_save": str(time.time()),
"version": "2.0", "version": "1.0",
"created": time.strftime("%Y-%m-%d %H:%M:%S"), "created": time.strftime("%Y-%m-%d %H:%M:%S"),
"description": "DuckHunt Bot Player Database" "description": "DuckHunt Bot Player Database"
} }
@@ -171,9 +105,9 @@ class DuckDB:
self.logger.error(f"Failed to create default database: {e}") self.logger.error(f"Failed to create default database: {e}")
# Return a minimal valid structure even if file creation fails # Return a minimal valid structure even if file creation fails
return { return {
"channels": {}, "players": {},
"last_save": str(time.time()), "last_save": str(time.time()),
"version": "2.0", "version": "1.0",
"created": time.strftime("%Y-%m-%d %H:%M:%S"), "created": time.strftime("%Y-%m-%d %H:%M:%S"),
"description": "DuckHunt Bot Player Database" "description": "DuckHunt Bot Player Database"
} }
@@ -280,37 +214,23 @@ class DuckDB:
try: try:
# Prepare data with validation # Prepare data with validation
data = { data = {
'channels': {}, 'players': {},
'last_save': str(time.time()), 'last_save': str(time.time()),
'version': '2.0' 'version': '1.0'
} }
# Validate and clean player data before saving # Validate and clean player data before saving
valid_count = 0 valid_count = 0
for channel_name, channel_players in self.players.items(): for nick, player_data in self.players.items():
if not isinstance(channel_name, str) or not isinstance(channel_players, dict): if isinstance(nick, str) and isinstance(player_data, dict):
continue try:
sanitized_nick = sanitize_user_input(nick, max_length=50)
safe_channel = sanitize_user_input( data['players'][sanitized_nick] = self._sanitize_player_data(player_data)
channel_name, valid_count += 1
max_length=100, except Exception as e:
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\' self.logger.warning(f"Error processing player {nick} during save: {e}")
) else:
if not safe_channel or not (safe_channel.startswith('#') or safe_channel.startswith('&')): self.logger.warning(f"Skipping invalid player data during save: {nick}")
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). # Saving an empty database is valid (e.g., first run or after admin wipes).
# Previously this raised and prevented the file from being written/updated. # Previously this raised and prevented the file from being written/updated.
@@ -350,79 +270,7 @@ class DuckDB:
except Exception: except Exception:
pass pass
def get_players_for_channel(self, channel: Optional[str]) -> dict: def get_player(self, nick):
"""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""" """Get player data, creating if doesn't exist with comprehensive validation"""
try: try:
# Validate and sanitize nick # Validate and sanitize nick
@@ -443,16 +291,14 @@ class DuckDB:
self.logger.warning(f"Empty nick after sanitization: {nick}") self.logger.warning(f"Empty nick after sanitization: {nick}")
return self.create_player('Unknown') return self.create_player('Unknown')
channel_players = self.get_players_for_channel(channel) if nick_lower not in self.players:
self.players[nick_lower] = self.create_player(nick_clean)
if nick_lower not in channel_players:
channel_players[nick_lower] = self.create_player(nick_clean)
else: else:
# Ensure existing players have all required fields # Ensure existing players have all required fields
player = channel_players[nick_lower] player = self.players[nick_lower]
if not isinstance(player, dict): if not isinstance(player, dict):
self.logger.warning(f"Invalid player data for {nick_lower}, recreating") self.logger.warning(f"Invalid player data for {nick_lower}, recreating")
channel_players[nick_lower] = self.create_player(nick_clean) self.players[nick_lower] = self.create_player(nick_clean)
else: else:
# Migrate and validate existing player data with error recovery # Migrate and validate existing player data with error recovery
validated = self.error_recovery.safe_execute( validated = self.error_recovery.safe_execute(
@@ -460,9 +306,9 @@ class DuckDB:
fallback=self.create_player(nick_clean), fallback=self.create_player(nick_clean),
logger=self.logger logger=self.logger
) )
channel_players[nick_lower] = validated self.players[nick_lower] = validated
return channel_players[nick_lower] return self.players[nick_lower]
except Exception as e: except Exception as e:
self.logger.error(f"Critical error getting player {nick}: {e}") self.logger.error(f"Critical error getting player {nick}: {e}")
@@ -576,16 +422,11 @@ class DuckDB:
} }
def get_leaderboard(self, category='xp', limit=3): def get_leaderboard(self, category='xp', limit=3):
"""Get top players by specified category (default channel).""" """Get top players by specified category"""
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: try:
leaderboard = [] leaderboard = []
channel_players = self.get_players_for_channel(channel) for nick, player_data in self.players.items():
for nick, player_data in channel_players.items():
sanitized_data = self._sanitize_player_data(player_data) sanitized_data = self._sanitize_player_data(player_data)
if category == 'xp': if category == 'xp':

View File

@@ -23,9 +23,6 @@ class DuckHuntBot:
self.writer: Optional[asyncio.StreamWriter] = None self.writer: Optional[asyncio.StreamWriter] = None
self.registered = False self.registered = False
self.channels_joined = set() self.channels_joined = set()
# Track requested joins so we can report success/failure.
# channel -> requester nick (or None for startup/rejoin)
self.pending_joins = {}
self.shutdown_requested = False self.shutdown_requested = False
self.rejoin_attempts = {} # Track rejoin attempts per channel self.rejoin_attempts = {} # Track rejoin attempts per channel
self.rejoin_tasks = {} # Track active rejoin tasks self.rejoin_tasks = {} # Track active rejoin tasks
@@ -41,9 +38,6 @@ class DuckHuntBot:
self.messages = MessageManager() self.messages = MessageManager()
self.sasl_handler = SASLHandler(self, config) 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 # Set up health checks
self._setup_health_checks() self._setup_health_checks()
@@ -64,7 +58,7 @@ class DuckHuntBot:
# Database health check # Database health check
self.health_checker.add_check( self.health_checker.add_check(
'database', 'database',
lambda: self.db is not None, lambda: self.db is not None and len(self.db.players) >= 0,
critical=True critical=True
) )
@@ -95,15 +89,6 @@ class DuckHuntBot:
else: else:
return default return default
return value return value
def _channel_key(self, channel: str) -> str:
"""Normalize channel names for internal comparisons (IRC channels are case-insensitive)."""
if not isinstance(channel, str):
return ""
channel = channel.strip()
if channel.startswith('#') or channel.startswith('&'):
return channel.lower()
return channel
def is_admin(self, user): def is_admin(self, user):
if '!' not in user: if '!' not in user:
@@ -137,6 +122,24 @@ class DuckHuntBot:
return False return False
def _handle_single_target_admin_command(self, args, usage_message_key, action_func, success_message_key, nick, channel):
"""Helper for admin commands that target a single player"""
if not args:
message = self.messages.get(usage_message_key)
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
action_func(player)
message = self.messages.get(success_message_key, target=target, admin=nick)
self.send_message(channel, message)
self.db.save_database()
return True
def _get_admin_target_player(self, nick, channel, target_nick): def _get_admin_target_player(self, nick, channel, target_nick):
""" """
@@ -144,21 +147,29 @@ class DuckHuntBot:
Returns (player, error_message) - if error_message is not None, command should return early. Returns (player, error_message) - if error_message is not None, command should return early.
""" """
is_private_msg = not channel.startswith('#') is_private_msg = not channel.startswith('#')
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
if not is_private_msg: if not is_private_msg:
if target_nick.lower() == nick.lower(): if target_nick.lower() == nick.lower():
target_nick = target_nick.lower() target_nick = target_nick.lower()
player = self.db.get_player(target_nick, channel_ctx) player = self.db.get_player(target_nick)
if player is None:
player = self.db.create_player(target_nick)
self.db.players[target_nick] = player
return player, None return player, None
else: else:
is_valid, player, error_msg = self.validate_target_player(target_nick, channel) is_valid, player, error_msg = self.validate_target_player(target_nick, channel)
if not is_valid: if not is_valid:
return None, error_msg return None, error_msg
target_nick = target_nick.lower()
if target_nick not in self.db.players:
self.db.players[target_nick] = player
return player, None return player, None
else: else:
target_nick = target_nick.lower() target_nick = target_nick.lower()
player = self.db.get_player(target_nick, channel_ctx) player = self.db.get_player(target_nick)
if player is None:
player = self.db.create_player(target_nick)
self.db.players[target_nick] = player
return player, None return player, None
def _get_validated_target_player(self, nick, channel, target_nick): def _get_validated_target_player(self, nick, channel, target_nick):
@@ -304,16 +315,19 @@ class DuckHuntBot:
# Attempt to rejoin # Attempt to rejoin
if self.send_raw(f"JOIN {channel}"): if self.send_raw(f"JOIN {channel}"):
self.pending_joins[channel] = None self.channels_joined.add(channel)
self.logger.info(f"Sent JOIN for {channel} (waiting for server confirmation)") self.logger.info(f"Successfully rejoined {channel}")
# Reset attempt counter and remove task
self.rejoin_attempts[channel] = 0
if channel in self.rejoin_tasks:
del self.rejoin_tasks[channel]
return
else: else:
self.logger.warning(f"Failed to send JOIN command for {channel}") self.logger.warning(f"Failed to send JOIN command for {channel}")
# If we've exceeded max attempts or channel was successfully joined # If we've exceeded max attempts or channel was successfully joined
if channel in self.channels_joined: if self.rejoin_attempts[channel] >= max_attempts:
self.rejoin_attempts[channel] = 0
self.logger.info(f"Rejoin confirmed for {channel}")
elif self.rejoin_attempts[channel] >= max_attempts:
self.logger.error(f"Exhausted all {max_attempts} rejoin attempts for {channel}") self.logger.error(f"Exhausted all {max_attempts} rejoin attempts for {channel}")
# Clean up # Clean up
@@ -347,9 +361,7 @@ class DuckHuntBot:
# Sanitize target and message # Sanitize target and message
safe_target = sanitize_user_input(target, max_length=100, safe_target = sanitize_user_input(target, max_length=100,
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\') allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
# Sanitize message (preserve IRC formatting codes - only remove CR/LF) safe_msg = sanitize_user_input(msg, max_length=400)
safe_msg = msg[:400] if isinstance(msg, str) else str(msg)[:400]
safe_msg = safe_msg.replace('\r', '').replace('\n', ' ').strip()
if not safe_target or not safe_msg: if not safe_target or not safe_msg:
self.logger.warning(f"Empty target or message after sanitization") self.logger.warning(f"Empty target or message after sanitization")
@@ -380,12 +392,17 @@ class DuckHuntBot:
# Send all message parts # Send all message parts
success_count = 0 success_count = 0
for i, message_part in enumerate(messages): for i, message_part in enumerate(messages):
if i > 0: # Small delay between messages to avoid flooding
time.sleep(0.1)
if self.send_raw(f"PRIVMSG {safe_target} :{message_part}"): if self.send_raw(f"PRIVMSG {safe_target} :{message_part}"):
success_count += 1 success_count += 1
else: else:
self.logger.error(f"Failed to send message part {i+1}/{len(messages)}") self.logger.error(f"Failed to send message part {i+1}/{len(messages)}")
return success_count == len(messages) return success_count == len(messages)
return self.send_raw(f"PRIVMSG {target} :{sanitized_msg}")
except Exception as e: except Exception as e:
self.logger.error(f"Error sanitizing/sending message: {e}") self.logger.error(f"Error sanitizing/sending message: {e}")
return False return False
@@ -444,75 +461,29 @@ class DuckHuntBot:
for channel in channels: for channel in channels:
try: try:
self.send_raw(f"JOIN {channel}") self.send_raw(f"JOIN {channel}")
self.pending_joins[self._channel_key(channel)] = None self.channels_joined.add(channel)
except Exception as e: except Exception as e:
self.logger.error(f"Error joining channel {channel}: {e}") self.logger.error(f"Error joining channel {channel}: {e}")
# JOIN failures (numeric replies)
elif command in {"403", "405", "437", "471", "473", "474", "475", "477"}:
# Common formats:
# 471 <me> <#chan> :Cannot join channel (+l)
# 475 <me> <#chan> :Cannot join channel (+k)
# 477 <me> <#chan> :You need to be identified...
our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt'
if params and len(params) >= 2 and params[0].lower() == our_nick.lower():
failed_channel = params[1]
reason = trailing or "Join rejected"
failed_key = self._channel_key(failed_channel)
self.channels_joined.discard(failed_key)
requester = self.pending_joins.pop(failed_key, None)
self.logger.warning(f"Failed to join {failed_channel}: ({command}) {reason}")
if requester:
try:
self.send_message(requester, f"{requester} > Failed to join {failed_channel}: {reason}")
except Exception:
pass
return
elif command == "JOIN": elif command == "JOIN":
if prefix: if len(params) >= 1 and prefix:
# Some servers send: ":nick!user@host JOIN :#chan" (channel in trailing) channel = params[0]
channel = None
if len(params) >= 1:
channel = params[0]
elif trailing and isinstance(trailing, str) and trailing.startswith('#'):
channel = trailing
if not channel:
return
joiner_nick = prefix.split('!')[0] if '!' in prefix else prefix joiner_nick = prefix.split('!')[0] if '!' in prefix else prefix
our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt' our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt'
# Check if we successfully joined (or rejoined) a channel # Check if we successfully joined (or rejoined) a channel
if joiner_nick and joiner_nick.lower() == our_nick.lower(): if joiner_nick and joiner_nick.lower() == our_nick.lower():
channel_key = self._channel_key(channel) self.channels_joined.add(channel)
self.channels_joined.add(channel_key)
self.logger.info(f"Successfully joined channel {channel}") self.logger.info(f"Successfully joined channel {channel}")
# If this was an admin-requested join, persist it now.
requester = self.pending_joins.pop(channel_key, None)
if requester:
try:
channels = self._config_channels_list()
if not any(self._channel_key(c) == channel_key for c in channels if isinstance(c, str)):
channels.append(channel)
self._persist_config()
self.send_message(requester, f"{requester} > Joined {channel}.")
except Exception:
pass
else:
# Startup/rejoin joins shouldn't change config here.
self.pending_joins.pop(channel_key, None)
# Cancel any pending rejoin attempts for this channel # Cancel any pending rejoin attempts for this channel
if channel_key in self.rejoin_tasks: if channel in self.rejoin_tasks:
self.rejoin_tasks[channel_key].cancel() self.rejoin_tasks[channel].cancel()
del self.rejoin_tasks[channel_key] del self.rejoin_tasks[channel]
# Reset rejoin attempts counter # Reset rejoin attempts counter
if channel_key in self.rejoin_attempts: if channel in self.rejoin_attempts:
self.rejoin_attempts[channel_key] = 0 self.rejoin_attempts[channel] = 0
elif command == "PRIVMSG": elif command == "PRIVMSG":
if len(params) >= 1: if len(params) >= 1:
@@ -533,12 +504,11 @@ class DuckHuntBot:
self.logger.warning(f"Kicked from {channel} by {kicker}: {reason}") self.logger.warning(f"Kicked from {channel} by {kicker}: {reason}")
# Remove from joined channels # Remove from joined channels
channel_key = self._channel_key(channel) self.channels_joined.discard(channel)
self.channels_joined.discard(channel_key)
# Schedule rejoin if auto-rejoin is enabled # Schedule rejoin if auto-rejoin is enabled
if self.get_config('connection.auto_rejoin.enabled', True): if self.get_config('connection.auto_rejoin.enabled', True):
asyncio.create_task(self.schedule_rejoin(channel_key)) asyncio.create_task(self.schedule_rejoin(channel))
elif command == "PING": elif command == "PING":
try: try:
@@ -553,12 +523,7 @@ class DuckHuntBot:
"""Handle bot commands with enhanced error handling and input validation""" """Handle bot commands with enhanced error handling and input validation"""
try: try:
# Validate input parameters # Validate input parameters
if not isinstance(message, str): if not isinstance(message, str) or not message.startswith('!'):
return
# Some clients/users may prefix commands with whitespace (e.g. " !bang").
message = message.lstrip()
if not message.startswith('!'):
return return
if not isinstance(user, str) or not isinstance(channel, str): if not isinstance(user, str) or not isinstance(channel, str):
@@ -566,13 +531,9 @@ class DuckHuntBot:
return return
# Sanitize inputs # Sanitize inputs
safe_message = sanitize_user_input(message, max_length=500).lstrip() safe_message = sanitize_user_input(message, max_length=500)
safe_user = sanitize_user_input(user, max_length=200) safe_user = sanitize_user_input(user, max_length=200)
safe_channel = sanitize_user_input(channel, max_length=100) safe_channel = sanitize_user_input(channel, max_length=100)
# Normalize channel casing for internal consistency.
if isinstance(safe_channel, str) and (safe_channel.startswith('#') or safe_channel.startswith('&')):
safe_channel = self._channel_key(safe_channel)
if not safe_message.startswith('!'): if not safe_message.startswith('!'):
return return
@@ -605,9 +566,8 @@ class DuckHuntBot:
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\') allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
# Get player data with error recovery # 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( player = self.error_recovery.safe_execute(
lambda: self.db.get_player(nick, channel_ctx), lambda: self.db.get_player(nick),
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False}, fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
logger=self.logger logger=self.logger
) )
@@ -620,6 +580,7 @@ class DuckHuntBot:
try: try:
player['last_activity_channel'] = safe_channel player['last_activity_channel'] = safe_channel
player['last_activity_time'] = time.time() player['last_activity_time'] = time.time()
self.db.players[nick.lower()] = player
except Exception as e: except Exception as e:
self.logger.warning(f"Error updating player activity for {nick}: {e}") self.logger.warning(f"Error updating player activity for {nick}: {e}")
@@ -650,28 +611,25 @@ class DuckHuntBot:
if cmd == "bang": if cmd == "bang":
command_executed = True command_executed = True
try: await self.error_recovery.safe_execute_async(
await self.handle_bang(nick, channel, player) lambda: self.handle_bang(nick, channel, player),
except Exception as e: fallback=None,
self.logger.error(f"Error in handle_bang for {nick}: {e}") logger=self.logger
error_msg = f"{nick} > ⚠️ Error processing !bang command. Please try again." )
self.send_message(channel, error_msg)
elif cmd == "bef" or cmd == "befriend": elif cmd == "bef" or cmd == "befriend":
command_executed = True command_executed = True
try: await self.error_recovery.safe_execute_async(
await self.handle_bef(nick, channel, player) lambda: self.handle_bef(nick, channel, player),
except Exception as e: fallback=None,
self.logger.error(f"Error in handle_bef for {nick}: {e}") logger=self.logger
error_msg = f"{nick} > ⚠️ Error processing !bef command. Please try again." )
self.send_message(channel, error_msg)
elif cmd == "reload": elif cmd == "reload":
command_executed = True command_executed = True
try: await self.error_recovery.safe_execute_async(
await self.handle_reload(nick, channel, player) lambda: self.handle_reload(nick, channel, player),
except Exception as e: fallback=None,
self.logger.error(f"Error in handle_reload for {nick}: {e}") logger=self.logger
error_msg = f"{nick} > ⚠️ Error processing !reload command. Please try again." )
self.send_message(channel, error_msg)
elif cmd == "shop": elif cmd == "shop":
command_executed = True command_executed = True
await self.error_recovery.safe_execute_async( await self.error_recovery.safe_execute_async(
@@ -714,13 +672,6 @@ class DuckHuntBot:
fallback=None, fallback=None,
logger=self.logger 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): elif cmd == "rearm" and self.is_admin(user):
command_executed = True command_executed = True
await self.error_recovery.safe_execute_async( await self.error_recovery.safe_execute_async(
@@ -756,20 +707,6 @@ class DuckHuntBot:
fallback=None, fallback=None,
logger=self.logger 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 no command was executed, it might be an unknown command
if not command_executed: if not command_executed:
@@ -806,9 +743,8 @@ class DuckHuntBot:
if not target_nick: if not target_nick:
return False, None, "Invalid target nickname" return False, None, "Invalid target nickname"
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None player = self.db.get_player(target_nick)
player = self.db.get_player(target_nick, channel_ctx)
if not player: if not player:
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first." return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
@@ -832,8 +768,7 @@ class DuckHuntBot:
We assume if someone has been active recently, they're still in the channel. We assume if someone has been active recently, they're still in the channel.
""" """
try: try:
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None player = self.db.get_player(nick)
player = self.db.get_player(nick, channel_ctx)
if not player: if not player:
return False return False
@@ -952,8 +887,7 @@ class DuckHuntBot:
"""Handle !duckstats command""" """Handle !duckstats command"""
if args and len(args) > 0: if args and len(args) > 0:
target_nick = args[0] target_nick = args[0]
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None target_player = self.db.get_player(target_nick)
target_player = self.db.get_player(target_nick, channel_ctx)
if not target_player: if not target_player:
message = f"{nick} > Player '{target_nick}' not found." message = f"{nick} > Player '{target_nick}' not found."
self.send_message(channel, message) self.send_message(channel, message)
@@ -1046,13 +980,11 @@ class DuckHuntBot:
bold = self.messages.messages.get('colours', {}).get('bold', '') bold = self.messages.messages.get('colours', {}).get('bold', '')
reset = self.messages.messages.get('colours', {}).get('reset', '') reset = self.messages.messages.get('colours', {}).get('reset', '')
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None # Get top 3 by XP
top_xp = self.db.get_leaderboard('xp', 3)
# 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
top_ducks = self.db.get_leaderboard('ducks_shot', 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 # Format XP leaderboard as single line
if top_xp: if top_xp:
@@ -1081,208 +1013,19 @@ class DuckHuntBot:
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.") self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
async def handle_duckhelp(self, nick, channel, _player): 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 = [ help_lines = [
"DuckHunt Commands (sent via PM)", self.messages.get('help_header'),
"Player commands:", self.messages.get('help_user_commands'),
"- !bang — shoot when a duck appears. Example: !bang", self.messages.get('help_help_command')
"- !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",
] ]
# Include admin commands only for admins. # Add admin commands if user is admin
# (Using nick list avoids relying on hostmask parsing.) if self.is_admin(f"{nick}!user@host"):
if nick.lower() in self.admins: help_lines.append(self.messages.get('help_admin_commands'))
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: for line in help_lines:
self.send_message(dm_target, line) self.send_message(channel, 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
target_key = self._channel_key(target_channel)
if target_key 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
# Wait for server JOIN confirmation before marking joined/persisting.
self.pending_joins[target_key] = nick
self.send_message(channel, f"{nick} > Attempting to join {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
target_key = self._channel_key(target_channel)
# Cancel any pending rejoin attempts and forget state.
if target_key in self.rejoin_tasks:
try:
self.rejoin_tasks[target_key].cancel()
except Exception:
pass
del self.rejoin_tasks[target_key]
if target_key in self.rejoin_attempts:
del self.rejoin_attempts[target_key]
self.channels_joined.discard(target_key)
# Update in-memory config so reconnects do not rejoin the channel.
channels = self._config_channels_list()
try:
channels[:] = [c for c in channels if not (isinstance(c, str) and self._channel_key(c) == target_key)]
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): async def handle_use(self, nick, channel, player, args):
"""Handle !use command""" """Handle !use command"""
@@ -1345,72 +1088,6 @@ class DuckHuntBot:
message = self.messages.get('use_dry_clothes', nick=nick) message = self.messages.get('use_dry_clothes', nick=nick)
else: else:
message = self.messages.get('use_dry_clothes_not_needed', nick=nick) message = self.messages.get('use_dry_clothes_not_needed', nick=nick)
elif effect_type == 'perfect_aim':
duration_seconds = int(effect.get('duration', 1800))
minutes = max(1, duration_seconds // 60)
message = self.messages.get('use_perfect_aim', nick=nick, duration_minutes=minutes)
elif effect_type == 'duck_radar':
duration_seconds = int(effect.get('duration', 21600))
hours = max(1, duration_seconds // 3600)
message = self.messages.get('use_duck_radar', nick=nick, duration_hours=hours)
elif effect_type == 'summon_duck':
# Summoning needs a channel context. If used in PM, pick the first configured channel.
delay = int(effect.get('delay', 0))
target_channel = channel
is_private_msg = not isinstance(target_channel, str) or not target_channel.startswith('#')
if is_private_msg:
channels = self.get_config('connection.channels', []) or []
target_channel = channels[0] if channels else None
if not target_channel:
message = f"{nick} > I don't know which channel to summon a duck in. Use this in a channel."
else:
# PM commands should only spawn normal/fast/golden.
spawn_type = None
if is_private_msg:
import random
golden_chance = self.get_config('duck_types.golden.chance', self.get_config('golden_duck_chance', 0.15))
fast_chance = self.get_config('duck_types.fast.chance', self.get_config('fast_duck_chance', 0.25))
try:
golden_chance = float(golden_chance)
except (TypeError, ValueError):
golden_chance = 0.15
try:
fast_chance = float(fast_chance)
except (TypeError, ValueError):
fast_chance = 0.25
r = random.random()
if r < max(0.0, golden_chance):
spawn_type = 'golden'
elif r < max(0.0, golden_chance) + max(0.0, fast_chance):
spawn_type = 'fast'
else:
spawn_type = 'normal'
if delay <= 0:
if spawn_type:
await self.game.force_spawn_duck(target_channel, spawn_type)
else:
await self.game.spawn_duck(target_channel)
message = self.messages.get('use_summon_duck', nick=nick, channel=target_channel)
else:
async def delayed_summon():
try:
await asyncio.sleep(delay)
if target_channel in self.channels_joined:
if spawn_type:
await self.game.force_spawn_duck(target_channel, spawn_type)
else:
await self.game.spawn_duck(target_channel)
except asyncio.CancelledError:
return
except Exception:
return
asyncio.create_task(delayed_summon())
minutes = max(1, delay // 60)
message = self.messages.get('use_summon_duck_delayed', nick=nick, channel=target_channel, delay_minutes=minutes)
elif result.get("target_affected"): elif result.get("target_affected"):
# Check if it's a gift (beneficial effect to target) # Check if it's a gift (beneficial effect to target)
if effect.get('is_gift', False): if effect.get('is_gift', False):
@@ -1535,8 +1212,7 @@ class DuckHuntBot:
# Check if admin wants to rearm all players # Check if admin wants to rearm all players
if target_nick.lower() == 'all': if target_nick.lower() == 'all':
rearmed_count = 0 rearmed_count = 0
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None for player_nick, player in self.db.players.items():
for player_nick, player in self.db.get_players_for_channel(channel_ctx).items():
if player.get('gun_confiscated', False): if player.get('gun_confiscated', False):
player['gun_confiscated'] = False player['gun_confiscated'] = False
self.levels.update_player_magazines(player) self.levels.update_player_magazines(player)
@@ -1575,8 +1251,10 @@ class DuckHuntBot:
return return
# Rearm the admin themselves (only in channels) # Rearm the admin themselves (only in channels)
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None player = self.db.get_player(nick)
player = self.db.get_player(nick, channel_ctx) if player is None:
player = self.db.create_player(nick)
self.db.players[nick.lower()] = player
player['gun_confiscated'] = False player['gun_confiscated'] = False
@@ -1639,8 +1317,10 @@ class DuckHuntBot:
return return
target = args[0].lower() target = args[0].lower()
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None player = self.db.get_player(target)
player = self.db.get_player(target, channel_ctx) if player is None:
player = self.db.create_player(target)
self.db.players[target] = player
action_func(player) action_func(player)
@@ -1681,17 +1361,15 @@ class DuckHuntBot:
if is_private_msg: if is_private_msg:
if not args: if not args:
self.send_message(channel, f"{nick} > Usage: !ducklaunch [channel] [duck_type]") self.send_message(channel, f"{nick} > Usage: !ducklaunch [channel] [duck_type] - duck_type can be: normal, golden, fast")
return return
target_channel = args[0] target_channel = args[0]
duck_type_arg = args[1] if len(args) > 1 else "normal" duck_type_arg = args[1] if len(args) > 1 else "normal"
else: else:
duck_type_arg = args[0] if args else "normal" duck_type_arg = args[0] if args else "normal"
target_key = self._channel_key(target_channel)
# Validate target channel # Validate target channel
if target_key not in self.channels_joined: if target_channel not in self.channels_joined:
if is_private_msg: if is_private_msg:
self.send_message(channel, f"{nick} > Channel {target_channel} is not available for duckhunt") self.send_message(channel, f"{nick} > Channel {target_channel} is not available for duckhunt")
else: else:
@@ -1701,21 +1379,52 @@ class DuckHuntBot:
# Validate duck type # Validate duck type
duck_type_arg = duck_type_arg.lower() duck_type_arg = duck_type_arg.lower()
if is_private_msg: valid_types = ["normal", "golden", "fast"]
valid_types = {'normal', 'fast', 'golden'}
else:
duck_types_cfg = self.get_config('duck_types', {}) or {}
if not isinstance(duck_types_cfg, dict):
duck_types_cfg = {}
valid_types = set(['normal']) | set(duck_types_cfg.keys())
if duck_type_arg not in valid_types: if duck_type_arg not in valid_types:
valid_list = ', '.join(sorted(valid_types)) self.send_message(channel, f"{nick} > Invalid duck type '{duck_type_arg}'. Valid types: {', '.join(valid_types)}")
self.send_message(channel, f"{nick} > Invalid duck type '{duck_type_arg}'. Valid types: {valid_list}")
return return
# Force spawn the specified duck type (supports multi-spawn types like couple/family) # Force spawn the specified duck type
await self.game.force_spawn_duck(target_key, duck_type_arg) import time
import random
if target_channel not in self.game.ducks:
self.game.ducks[target_channel] = []
# Create duck based on specified type
current_time = time.time()
duck_id = f"{duck_type_arg}_duck_{int(current_time)}_{random.randint(1000, 9999)}"
if duck_type_arg == "golden":
min_hp_val = self.get_config('duck_types.golden.min_hp', 3)
max_hp_val = self.get_config('duck_types.golden.max_hp', 5)
min_hp = int(min_hp_val) if min_hp_val is not None else 3
max_hp = int(max_hp_val) if max_hp_val is not None else 5
hp = random.randint(min_hp, max_hp)
duck = {
'id': duck_id,
'spawn_time': current_time,
'channel': target_channel,
'duck_type': 'golden',
'max_hp': hp,
'current_hp': hp
}
else:
# Both normal and fast ducks have 1 HP
duck = {
'id': duck_id,
'spawn_time': current_time,
'channel': target_channel,
'duck_type': duck_type_arg,
'max_hp': 1,
'current_hp': 1
}
self.game.ducks[target_channel].append(duck)
duck_message = self.messages.get('duck_spawn')
# Send duck spawn message to target channel
self.send_message(target_channel, duck_message)
# Send confirmation to admin (either in channel or private message) # Send confirmation to admin (either in channel or private message)
if is_private_msg: if is_private_msg:

View File

@@ -165,52 +165,6 @@ class DuckGame:
self.ducks[channel].append(duck) self.ducks[channel].append(duck)
self.bot.send_message(channel, message) self.bot.send_message(channel, message)
async def force_spawn_duck(self, channel, duck_type='normal'):
"""Force spawn a specific duck type (admin command)"""
if channel not in self.ducks:
self.ducks[channel] = []
# Validate duck type
duck_type = (duck_type or 'normal').lower()
if duck_type not in ['normal', 'golden', 'fast']:
duck_type = 'normal'
# Create the specified duck type
if duck_type == 'golden':
min_hp = self.bot.get_config('golden_duck_min_hp', 3)
max_hp = self.bot.get_config('golden_duck_max_hp', 5)
hp = random.randint(min_hp, max_hp)
duck = {
'id': f"golden_duck_{int(time.time())}_{random.randint(1000, 9999)}",
'spawn_time': time.time(),
'channel': channel,
'duck_type': 'golden',
'max_hp': hp,
'current_hp': hp
}
elif duck_type == 'fast':
duck = {
'id': f"fast_duck_{int(time.time())}_{random.randint(1000, 9999)}",
'spawn_time': time.time(),
'channel': channel,
'duck_type': 'fast',
'max_hp': 1,
'current_hp': 1
}
else: # normal
duck = {
'id': f"duck_{int(time.time())}_{random.randint(1000, 9999)}",
'spawn_time': time.time(),
'channel': channel,
'duck_type': 'normal',
'max_hp': 1,
'current_hp': 1
}
self.ducks[channel].append(duck)
message = self.bot.messages.get('duck_spawn')
self.bot.send_message(channel, message)
def shoot_duck(self, nick, channel, player): def shoot_duck(self, nick, channel, player):
"""Handle shooting at a duck""" """Handle shooting at a duck"""
# Check if gun is confiscated # Check if gun is confiscated
@@ -285,9 +239,6 @@ class DuckGame:
if duck['current_hp'] > 0: if duck['current_hp'] > 0:
# Still alive, reveal it's golden but don't remove # Still alive, reveal it's golden but don't remove
# Award XP for hitting (but not killing) the golden duck
player['xp'] = player.get('xp', 0) + xp_gained
accuracy_gain = self.bot.get_config('accuracy_gain_on_hit', 1) accuracy_gain = self.bot.get_config('accuracy_gain_on_hit', 1)
max_accuracy = self.bot.get_config('max_accuracy', 100) max_accuracy = self.bot.get_config('max_accuracy', 100)
player['accuracy'] = min(player.get('accuracy', self.bot.get_config('default_accuracy', 75)) + accuracy_gain, max_accuracy) player['accuracy'] = min(player.get('accuracy', self.bot.get_config('default_accuracy', 75)) + accuracy_gain, max_accuracy)

View File

@@ -388,49 +388,6 @@ class ShopManager:
"spawn_multiplier": spawn_multiplier, "spawn_multiplier": spawn_multiplier,
"duration": duration // 60 # return duration in minutes "duration": duration // 60 # return duration in minutes
} }
elif item_type == 'perfect_aim':
# Temporarily force shots to hit (bot/game enforces this)
if 'temporary_effects' not in player:
player['temporary_effects'] = []
duration = int(item.get('duration', 1800)) # seconds
effect = {
'type': 'perfect_aim',
'expires_at': time.time() + max(1, duration)
}
player['temporary_effects'].append(effect)
return {
"type": "perfect_aim",
"duration": duration
}
elif item_type == 'duck_radar':
# DM alert on duck spawns (game loop sends the DM)
if 'temporary_effects' not in player:
player['temporary_effects'] = []
duration = int(item.get('duration', 21600)) # seconds
effect = {
'type': 'duck_radar',
'expires_at': time.time() + max(1, duration)
}
player['temporary_effects'].append(effect)
return {
"type": "duck_radar",
"duration": duration
}
elif item_type == 'summon_duck':
# Actual spawning is handled by the bot (needs channel context)
delay = int(item.get('delay', 0))
delay = max(0, min(delay, 86400)) # cap to 24h
return {
"type": "summon_duck",
"delay": delay
}
elif item_type == 'insurance': elif item_type == 'insurance':
# Add insurance protection against friendly fire # Add insurance protection against friendly fire

View File

@@ -56,7 +56,7 @@ class MessageManager:
"help_header": "DuckHunt Commands:", "help_header": "DuckHunt Commands:",
"help_user_commands": "!bang - Shoot at ducks | !reload - Reload your gun | !shop - View the shop", "help_user_commands": "!bang - Shoot at ducks | !reload - Reload your gun | !shop - View the shop",
"help_help_command": "!duckhelp - Show this help", "help_help_command": "!duckhelp - Show this help",
"help_admin_commands": "Admin: !rearm <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch | !join <#channel> | !leave <#channel>", "help_admin_commands": "Admin: !rearm <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch",
"admin_rearm_player": "[ADMIN] {target} has been rearmed by {admin}", "admin_rearm_player": "[ADMIN] {target} has been rearmed by {admin}",
"admin_rearm_all": "[ADMIN] All players have been rearmed by {admin}", "admin_rearm_all": "[ADMIN] All players have been rearmed by {admin}",
"admin_disarm": "[ADMIN] {target} has been disarmed by {admin}", "admin_disarm": "[ADMIN] {target} has been disarmed by {admin}",