diff --git a/README.md b/README.md index 6228c5b..f17e78d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,172 @@ -# duckhunt +# ๐Ÿฆ† DuckHunt IRC Bot -DuckHunt IRC game bot. \ No newline at end of file +A feature-rich IRC game bot where players hunt ducks, upgrade weapons, trade items, and compete on leaderboards! + +## ๐Ÿš€ Features + +### ๐ŸŽฏ Core Game Mechanics +- **Different Duck Types**: Common, Rare, Golden, and Armored ducks with varying rewards +- **Weapon System**: Multiple weapon types (Basic Gun, Shotgun, Rifle) with durability +- **Ammunition Types**: Standard, Rubber Bullets, Explosive Rounds +- **Weapon Attachments**: Laser Sight, Extended Magazine, Bipod +- **Accuracy & Reliability**: Skill-based hit/miss and reload failure mechanics + +### ๐Ÿฆ Economy System +- **Shop**: Buy/sell weapons, attachments, and upgrades +- **Banking**: Deposit coins for interest, take loans +- **Trading**: Trade coins and items with other players +- **Insurance**: Protect your equipment from damage +- **Hunting Licenses**: Unlock premium features and bonuses + +### ๐Ÿ‘ค Player Progression +- **Hunter Levels**: Gain XP and level up for better abilities +- **Account System**: Register accounts with password authentication +- **Multiple Auth Methods**: Nick-based, hostmask, or registered account +- **Persistent Stats**: All progress saved to SQLite database + +### ๐Ÿ† Social Features +- **Leaderboards**: Compete for top rankings +- **Duck Alerts**: Get notified when rare ducks spawn +- **Sabotage**: Interfere with other players (for a cost!) +- **Comprehensive Help**: Detailed command reference + +## ๐Ÿ“‹ Requirements + +- Python 3.7+ +- asyncio support +- SQLite3 (included with Python) + +## ๐Ÿ› ๏ธ Installation + +1. Clone or download the bot files +2. Edit `config.json` with your IRC server details: + ```json + { + "server": "irc.libera.chat", + "port": 6697, + "nick": "DuckHuntBot", + "channels": ["#yourchannel"], + "ssl": true, + "sasl": false, + "password": "", + "duck_spawn_min": 60, + "duck_spawn_max": 300 + } + ``` + +3. Test the bot: + ```bash + python test_bot.py + ``` + +4. Run the bot: + ```bash + python duckhunt.py + ``` + +## ๐ŸŽฎ Commands + +### ๐ŸŽฏ Hunting +- `!bang` - Shoot at a duck (accuracy-based hit/miss) +- `!reload` - Reload weapon (can fail based on reliability) +- `!catch` - Catch a duck with your hands +- `!bef` - Befriend a duck instead of shooting + +### ๐Ÿ›’ Economy +- `!shop` - View available items +- `!buy ` - Purchase items +- `!sell ` - Sell equipment +- `!bank` - Banking services +- `!trade ` - Trade with others + +### ๐Ÿ“Š Stats & Info +- `!stats` - Detailed combat statistics +- `!duckstats` - Personal hunting record +- `!leaderboard` - Top players ranking +- `!license` - Hunting license management + +### โš™๏ธ Settings +- `!alerts` - Toggle duck spawn notifications +- `!help` - Complete command reference + +### ๐Ÿ” Account System +- `/msg BotNick register ` - Register account +- `/msg BotNick identify ` - Login to account + +### ๐ŸŽฎ Advanced +- `!sabotage ` - Sabotage another hunter's weapon + +## ๐Ÿ—‚๏ธ File Structure + +``` +duckhunt/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ duckhuntbot.py # Main IRC bot logic +โ”‚ โ”œโ”€โ”€ game.py # Game mechanics and commands +โ”‚ โ”œโ”€โ”€ db.py # SQLite database handling +โ”‚ โ”œโ”€โ”€ auth.py # Authentication system +โ”‚ โ”œโ”€โ”€ items.py # Duck types, weapons, attachments +โ”‚ โ”œโ”€โ”€ logging_utils.py # Colored logging setup +โ”‚ โ””โ”€โ”€ utils.py # IRC message parsing +โ”œโ”€โ”€ config.json # Bot configuration +โ”œโ”€โ”€ duckhunt.py # Main entry point +โ”œโ”€โ”€ test_bot.py # Test script +โ””โ”€โ”€ README.md # This file +``` + +## ๐ŸŽฏ Game Balance + +### Duck Types & Rewards +- **Common Duck** ๐Ÿฆ†: 1 coin, 10 XP (70% spawn rate) +- **Rare Duck** ๐Ÿฆ†โœจ: 3 coins, 25 XP (20% spawn rate) +- **Golden Duck** ๐Ÿฅ‡๐Ÿฆ†: 10 coins, 50 XP (8% spawn rate) +- **Armored Duck** ๐Ÿ›ก๏ธ๐Ÿฆ†: 15 coins, 75 XP (2% spawn rate, 3 health) + +### Weapon Stats +- **Basic Gun**: 0% accuracy bonus, 100 durability, 1 attachment slot +- **Shotgun**: -10% accuracy, 80 durability, 2 slots, spread shot +- **Rifle**: +20% accuracy, 120 durability, 3 slots + +### Progression +- Players start with 100 coins and basic stats +- Level up by gaining XP from successful hunts +- Unlock better equipment and abilities as you progress + +## ๐Ÿ”ง Configuration + +Edit `config.json` to customize: +- IRC server and channels +- Duck spawn timing (min/max seconds) +- SSL and SASL authentication +- Bot nickname + +## ๐Ÿ›ก๏ธ Security + +- Passwords are hashed with PBKDF2 +- Account data stored separately from temporary nick data +- Multiple authentication methods supported +- Database uses prepared statements to prevent injection + +## ๐Ÿ› Troubleshooting + +1. **Bot won't connect**: Check server/port in config.json +2. **Database errors**: Ensure write permissions in bot directory +3. **Commands not working**: Verify bot has joined the channel +4. **Test failures**: Run `python test_bot.py` to diagnose issues + +## ๐ŸŽ–๏ธ Contributing + +Feel free to add new features: +- More duck types and weapons +- Additional mini-games +- Seasonal events +- Guild/team systems +- Advanced trading mechanics + +## ๐Ÿ“„ License + +This bot is provided as-is for educational and entertainment purposes. + +--- + +๐Ÿฆ† **Happy Hunting!** ๐Ÿฆ† diff --git a/__pycache__/simple_duckhunt.cpython-312.pyc b/__pycache__/simple_duckhunt.cpython-312.pyc new file mode 100644 index 0000000..88c77ff Binary files /dev/null and b/__pycache__/simple_duckhunt.cpython-312.pyc differ diff --git a/config.json b/config.json new file mode 100644 index 0000000..75a3b1d --- /dev/null +++ b/config.json @@ -0,0 +1,19 @@ +{ + "server": "irc.rizon.net", + "port": 6697, + "nick": "DuckHunt", + "channels": ["#computertech"], + "ssl": true, + "sasl": { + "enabled": true, + "username": "duckhunt", + "password": "duckhunt//789//" + }, + "password": "", + "admins": ["peorth", "computertech"], + "duck_spawn_min": 1800, + "duck_spawn_max": 5400, + "duck_timeout_min": 45, + "duck_timeout_max": 75, + "_comment": "Run with: python3 simple_duckhunt.py | Admins config-only | Private admin: /msg DuckHuntBot restart|quit|launch | Duck timeout: random between min-max seconds" +} diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..4cc3879 --- /dev/null +++ b/config.json.example @@ -0,0 +1,9 @@ +{ + "server": "irc.example.com", + "port": 6697, + "use_ssl": true, + "nick": "DuckHunt", + "channels": ["#duckhunt"], + "sasl_username": "your_nick_here", + "sasl_password": "your_password_here" +} diff --git a/duckhunt.json b/duckhunt.json new file mode 100644 index 0000000..42e2e9b --- /dev/null +++ b/duckhunt.json @@ -0,0 +1,205 @@ +{ + "players": { + "guest44288": { + "current_nick": "Guest44288", + "hostmask": "~Colby@Rizon-FFE0901B.ipcom.comunitel.net", + "coins": 102, + "caught": 1, + "ammo": 9, + "max_ammo": 10, + "chargers": 2, + "max_chargers": 2, + "xp": 15, + "accuracy": 85, + "reliability": 90, + "duck_start_time": null, + "gun_level": 1, + "luck": 0, + "gun_type": "pistol" + }, + "colby": { + "coins": 113, + "caught": 9, + "ammo": 10, + "max_ammo": 10, + "chargers": 1, + "max_chargers": 2, + "xp": 44, + "accuracy": 85, + "reliability": 90, + "duck_start_time": null, + "gun_level": 1, + "luck": 0, + "gun_type": "pistol", + "gun_confiscated": false, + "jammed": false, + "jammed_count": 1, + "total_ammo_used": 4, + "shot_at": 3, + "reflex_shots": 3, + "total_reflex_time": 34.926591634750366, + "best_time": 7.363537788391113, + "karma": 3, + "wild_shots": 1 + }, + "colby_": { + "xp": 0, + "caught": 0, + "befriended": 0, + "missed": 0, + "ammo": 6, + "max_ammo": 6, + "chargers": 2, + "max_chargers": 2, + "accuracy": 65, + "reliability": 70, + "gun_confiscated": false, + "explosive_ammo": false, + "settings": { + "notices": true, + "private_messages": false + }, + "golden_ducks": 0, + "karma": 0, + "deflection": 0, + "defense": 0, + "jammed": false, + "jammed_count": 0, + "deaths": 0, + "neutralized": 0, + "deflected": 0, + "best_time": 999.9, + "total_reflex_time": 0.0, + "reflex_shots": 0, + "wild_shots": 0, + "accidents": 0, + "total_ammo_used": 0, + "shot_at": 0, + "lucky_shots": 0, + "luck": 0, + "detector": 0, + "silencer": 0, + "sunglasses": 0, + "clothes": 0, + "grease": 0, + "brush": 0, + "mirror": 0, + "sand": 0, + "water": 0, + "sabotage": 0, + "life_insurance": 0, + "liability": 0, + "decoy": 0, + "bread": 0, + "duck_detector": 0, + "mechanical": 0 + }, + "computertech": { + "xp": 0, + "caught": 0, + "befriended": 0, + "missed": 0, + "ammo": 6, + "max_ammo": 6, + "chargers": 2, + "max_chargers": 2, + "accuracy": 65, + "reliability": 70, + "weapon": "pistol", + "gun_confiscated": false, + "explosive_ammo": false, + "settings": { + "notices": true, + "private_messages": false + }, + "golden_ducks": 0, + "karma": 0, + "deflection": 0, + "defense": 0, + "jammed": false, + "jammed_count": 0, + "deaths": 0, + "neutralized": 0, + "deflected": 0, + "best_time": 999.9, + "total_reflex_time": 0.0, + "reflex_shots": 0, + "wild_shots": 0, + "accidents": 0, + "total_ammo_used": 0, + "shot_at": 0, + "lucky_shots": 0, + "luck": 0, + "detector": 0, + "silencer": 0, + "sunglasses": 0, + "clothes": 0, + "grease": 0, + "brush": 0, + "mirror": 0, + "sand": 0, + "water": 0, + "sabotage": 0, + "life_insurance": 0, + "liability": 0, + "decoy": 0, + "bread": 0, + "duck_detector": 0, + "mechanical": 0 + }, + "boliver": { + "xp": 6, + "caught": 0, + "befriended": 1, + "missed": 1, + "ammo": 5, + "max_ammo": 6, + "chargers": 2, + "max_chargers": 2, + "accuracy": 65, + "reliability": 70, + "weapon": "pistol", + "gun_confiscated": false, + "explosive_ammo": false, + "settings": { + "notices": true, + "private_messages": false + }, + "golden_ducks": 0, + "karma": -1, + "deflection": 0, + "defense": 0, + "jammed": true, + "jammed_count": 1, + "deaths": 0, + "neutralized": 0, + "deflected": 0, + "best_time": 999.9, + "total_reflex_time": 0.0, + "reflex_shots": 0, + "wild_shots": 0, + "accidents": 0, + "total_ammo_used": 1, + "shot_at": 1, + "lucky_shots": 0, + "luck": 0, + "detector": 0, + "silencer": 0, + "sunglasses": 0, + "clothes": 0, + "grease": 0, + "brush": 0, + "mirror": 0, + "sand": 0, + "water": 0, + "sabotage": 0, + "life_insurance": 0, + "liability": 0, + "decoy": 0, + "bread": 0, + "duck_detector": 0, + "mechanical": 0 + } + }, + "last_save": "1757695515.2473938" +} \ No newline at end of file diff --git a/duckhunt.log b/duckhunt.log new file mode 100644 index 0000000..82f85c2 --- /dev/null +++ b/duckhunt.log @@ -0,0 +1,93 @@ +[2025-09-11 18:30:40,346] INFO: Loaded 3 players from duckhunt.json +[2025-09-11 18:30:40,346] INFO: DuckHunt Bot initializing... +[2025-09-11 18:30:40,347] INFO: Starting DuckHunt Bot... +[2025-09-11 18:30:40,347] INFO: Loaded 3 players from duckhunt.json +[2025-09-11 18:30:40,420] INFO: Connecting to irc.rizon.net:6697 (SSL: True) +[2025-09-11 18:30:40,579] INFO: Connected successfully! +[2025-09-11 18:30:40,579] INFO: Registering as DuckHuntBot +[2025-09-11 18:30:41,067] INFO: Successfully registered! +[2025-09-11 18:30:41,067] INFO: Joining #colby +[2025-09-11 18:30:41,118] INFO: Successfully joined #colby +[2025-09-11 18:30:41,582] INFO: Starting duck spawning... +[2025-09-11 18:30:46,583] INFO: Admin spawned normal duck 965d7945 in #colby +[2025-09-11 18:30:46,583] INFO: Waiting 56m 37s for next duck +[2025-09-11 18:31:46,591] INFO: Duck 965d7945 timed out in #colby +[2025-09-11 18:38:33,894] INFO: Received SIGINT, initiating graceful shutdown... +[2025-09-11 18:38:34,097] INFO: Shutdown requested, stopping all tasks... +[2025-09-11 18:38:34,097] INFO: Starting cleanup process... +[2025-09-11 18:38:35,211] INFO: IRC connection closed +[2025-09-11 18:38:35,225] INFO: Final database save completed - 3 players saved +[2025-09-11 18:38:35,226] INFO: Cleanup completed successfully +[2025-09-11 18:38:35,234] INFO: DuckHunt Bot shutdown complete +[2025-09-11 18:38:53,536] INFO: Loaded 3 players from duckhunt.json +[2025-09-11 18:38:53,536] INFO: DuckHunt Bot initializing... +[2025-09-11 18:38:53,537] INFO: Starting DuckHunt Bot... +[2025-09-11 18:38:53,537] INFO: Loaded 3 players from duckhunt.json +[2025-09-11 18:38:53,607] INFO: Connecting to irc.rizon.net:6697 (SSL: True) +[2025-09-11 18:38:53,785] INFO: Connected successfully! +[2025-09-11 18:38:53,785] INFO: SASL authentication enabled +[2025-09-11 18:38:54,162] INFO: SASL capability available +[2025-09-11 18:38:54,221] INFO: SASL capability acknowledged +[2025-09-11 18:38:54,221] INFO: Authenticating via SASL as duckhunt +[2025-09-11 18:38:54,645] INFO: Server ready for SASL authentication +[2025-09-11 18:38:54,645] ERROR: SASL authentication failed! +[2025-09-11 18:38:54,645] INFO: Registering as DuckHunt +[2025-09-11 18:39:27,102] WARNING: Connection closed by server +[2025-09-11 18:39:27,103] WARNING: A main task completed unexpectedly +[2025-09-11 18:39:27,103] INFO: Starting cleanup process... +[2025-09-11 18:39:27,105] INFO: Final database save completed - 3 players saved +[2025-09-11 18:39:27,105] INFO: Cleanup completed successfully +[2025-09-11 18:39:27,106] INFO: DuckHunt Bot shutdown complete +[2025-09-11 18:41:03,279] INFO: Loaded 3 players from duckhunt.json +[2025-09-11 18:41:03,279] INFO: DuckHunt Bot initializing... +[2025-09-11 18:41:03,280] INFO: Starting DuckHunt Bot... +[2025-09-11 18:41:03,280] INFO: Loaded 3 players from duckhunt.json +[2025-09-11 18:41:03,354] INFO: Connecting to irc.rizon.net:6697 (SSL: True) +[2025-09-11 18:41:03,516] INFO: Connected successfully! +[2025-09-11 18:41:03,517] INFO: SASL authentication enabled +[2025-09-11 18:41:03,611] INFO: SASL capability available +[2025-09-11 18:41:03,660] INFO: SASL capability acknowledged +[2025-09-11 18:41:03,660] INFO: Authenticating via SASL as duckhunt +[2025-09-11 18:41:04,075] INFO: Server ready for SASL authentication +[2025-09-11 18:41:04,076] ERROR: SASL authentication failed! (904 - Invalid credentials or account not found) +[2025-09-11 18:41:04,076] ERROR: Attempted username: duckhunt +[2025-09-11 18:41:04,076] INFO: Registering as DuckHunt +[2025-09-11 18:41:36,030] WARNING: Connection closed by server +[2025-09-11 18:41:36,031] WARNING: A main task completed unexpectedly +[2025-09-11 18:41:36,031] INFO: Starting cleanup process... +[2025-09-11 18:41:36,032] INFO: Final database save completed - 3 players saved +[2025-09-11 18:41:36,032] INFO: Cleanup completed successfully +[2025-09-11 18:41:36,033] INFO: DuckHunt Bot shutdown complete +[2025-09-12 17:44:59,246] INFO: Loaded 3 players from duckhunt.json +[2025-09-12 17:44:59,247] INFO: DuckHunt Bot initializing... +[2025-09-12 17:44:59,248] INFO: Starting DuckHunt Bot... +[2025-09-12 17:44:59,249] INFO: Loaded 3 players from duckhunt.json +[2025-09-12 17:44:59,329] INFO: Connecting to irc.rizon.net:6697 (SSL: True) +[2025-09-12 17:44:59,997] INFO: Connected successfully! +[2025-09-12 17:44:59,997] INFO: SASL authentication enabled +[2025-09-12 17:45:00,375] INFO: SASL capability available +[2025-09-12 17:45:00,433] INFO: SASL capability acknowledged +[2025-09-12 17:45:00,433] INFO: Authenticating via SASL as duckhunt +[2025-09-12 17:45:00,851] INFO: Server ready for SASL authentication +[2025-09-12 17:45:00,851] ERROR: SASL authentication failed! (904 - Invalid credentials or account not found) +[2025-09-12 17:45:00,852] INFO: Falling back to NickServ identification... +[2025-09-12 17:45:00,852] ERROR: Attempted username: duckhunt +[2025-09-12 17:45:00,852] INFO: Registering as DuckHunt +[2025-09-12 17:45:00,968] ERROR: SASL authentication aborted! (906) +[2025-09-12 17:45:00,968] INFO: Falling back to NickServ identification... +[2025-09-12 17:45:00,969] INFO: Registering as DuckHunt +[2025-09-12 17:45:00,969] INFO: Successfully registered! +[2025-09-12 17:45:00,969] INFO: Attempting NickServ identification for duckhunt +[2025-09-12 17:45:01,971] INFO: NickServ identification commands sent +[2025-09-12 17:45:01,971] INFO: Joining #computertech +[2025-09-12 17:45:02,032] INFO: Successfully joined #computertech +[2025-09-12 17:45:03,001] INFO: Starting duck spawning... +[2025-09-12 17:45:08,003] INFO: Admin spawned normal duck c2950231 in #computertech +[2025-09-12 17:45:08,003] INFO: Waiting 58m 28s for next duck +[2025-09-12 17:45:09,063] INFO: Admin spawned normal duck d30a9198 in #computertech +[2025-09-12 17:45:14,112] WARNING: A main task completed unexpectedly +[2025-09-12 17:45:14,141] INFO: Starting cleanup process... +[2025-09-12 17:45:15,246] INFO: IRC connection closed +[2025-09-12 17:45:15,248] INFO: Final database save completed - 5 players saved +[2025-09-12 17:45:15,248] INFO: Cleanup completed successfully +[2025-09-12 17:45:15,249] INFO: DuckHunt Bot shutdown complete diff --git a/duckhunt.py b/duckhunt.py new file mode 100644 index 0000000..c9545d7 --- /dev/null +++ b/duckhunt.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" +Main entry point for DuckHunt Bot +""" + +import asyncio +import json +import sys +import os + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from src.duckhuntbot import IRCBot + +def main(): + try: + with open('config.json') as f: + config = json.load(f) + + bot = IRCBot(config) + bot.logger.info("๐Ÿฆ† Starting DuckHunt Bot...") + + # Run the bot + asyncio.run(bot.run()) + + except KeyboardInterrupt: + print("\n๐Ÿ›‘ Bot stopped by user") + except FileNotFoundError: + print("โŒ config.json not found!") + sys.exit(1) + except Exception as e: + print(f"โŒ Error: {e}") + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/run_bot.sh b/run_bot.sh new file mode 100644 index 0000000..e69de29 diff --git a/simple_duckhunt.py b/simple_duckhunt.py new file mode 100644 index 0000000..e35b135 --- /dev/null +++ b/simple_duckhunt.py @@ -0,0 +1,2221 @@ +#!/usr/bin/env python3 +""" +Standalone DuckHunt IRC Bot with JSON Database Storage +""" + +import asyncio +import ssl +import json +import random +import logging +import sys +import os +import base64 +import subprocess +import time +import uuid +import signal +from functools import partial +from typing import Optional + +# Simple colored logger +class ColorFormatter(logging.Formatter): + COLORS = { + 'DEBUG': '\033[94m', + 'INFO': '\033[92m', + 'WARNING': '\033[93m', + 'ERROR': '\033[91m', + 'CRITICAL': '\033[95m', + 'ENDC': '\033[0m', + } + def format(self, record): + color = self.COLORS.get(record.levelname, '') + endc = self.COLORS['ENDC'] + msg = super().format(record) + return f"{color}{msg}{endc}" + +def setup_logger(): + logger = logging.getLogger('DuckHuntBot') + + # Clear any existing handlers + logger.handlers.clear() + + # Console handler with colors + console_handler = logging.StreamHandler(sys.stdout) + color_formatter = ColorFormatter('[%(asctime)s] %(levelname)s: %(message)s') + console_handler.setFormatter(color_formatter) + logger.addHandler(console_handler) + + # File handler without colors + file_handler = logging.FileHandler('duckhunt.log', mode='a', encoding='utf-8') + file_formatter = logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s') + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + logger.setLevel(logging.INFO) + logger.propagate = False + return logger + +# Simple IRC message parser +def parse_message(line): + prefix = '' + trailing = '' + if line.startswith(':'): + prefix, line = line[1:].split(' ', 1) + if ' :' in line: + line, trailing = line.split(' :', 1) + parts = line.split() + command = parts[0] if parts else '' + params = parts[1:] if len(parts) > 1 else [] + return prefix, command, params, trailing + +class SimpleIRCBot: + def __init__(self, config): + self.config = config + self.logger = setup_logger() + self.reader: Optional[asyncio.StreamReader] = None + self.writer: Optional[asyncio.StreamWriter] = None + self.registered = False + self.channels_joined = set() + self.players = {} # Memory cache for speed + self.ducks = {} # Format: {channel: [{'alive': True, 'spawn_time': time, 'id': uuid}, ...]} + self.db_file = "duckhunt.json" + self.admins = [admin.lower() for admin in self.config.get('admins', ['colby'])] # Load from config only, case insensitive + self.sasl_authenticated = False + self.ignored_nicks = set() # Nicks to ignore commands from + self.command_cooldowns = {} # Rate limiting for commands + self.duck_timeout_min = self.config.get('duck_timeout_min', 45) # Minimum duck timeout + self.duck_timeout_max = self.config.get('duck_timeout_max', 75) # Maximum duck timeout + self.duck_spawn_min = self.config.get('duck_spawn_min', 1800) # Minimum duck spawn time (30 min) + self.duck_spawn_max = self.config.get('duck_spawn_max', 5400) # Maximum duck spawn time (90 min) + self.shutdown_requested = False # Graceful shutdown flag + self.running_tasks = set() # Track running tasks for cleanup + + # IRC Color codes + self.colors = { + 'red': '\x0304', + 'green': '\x0303', + 'blue': '\x0302', + 'yellow': '\x0308', + 'orange': '\x0307', + 'purple': '\x0306', + 'cyan': '\x0311', + 'white': '\x0300', + 'black': '\x0301', + 'gray': '\x0314', + 'reset': '\x03' + } + + # 40-level progression system with titles + self.levels = [ + {'xp': 0, 'title': 'Duck Harasser'}, + {'xp': 10, 'title': 'Unemployed'}, + {'xp': 25, 'title': 'Hunter Apprentice'}, + {'xp': 45, 'title': 'Duck Tracker'}, + {'xp': 70, 'title': 'Sharp Shooter'}, + {'xp': 100, 'title': 'Hunter'}, + {'xp': 135, 'title': 'Experienced Hunter'}, + {'xp': 175, 'title': 'Skilled Hunter'}, + {'xp': 220, 'title': 'Expert Hunter'}, + {'xp': 270, 'title': 'Master Hunter'}, + {'xp': 325, 'title': 'Duck Slayer'}, + {'xp': 385, 'title': 'Duck Terminator'}, + {'xp': 450, 'title': 'Duck Destroyer'}, + {'xp': 520, 'title': 'Duck Exterminator'}, + {'xp': 595, 'title': 'Duck Assassin'}, + {'xp': 675, 'title': 'Legendary Hunter'}, + {'xp': 760, 'title': 'Elite Hunter'}, + {'xp': 850, 'title': 'Supreme Hunter'}, + {'xp': 945, 'title': 'Ultimate Hunter'}, + {'xp': 1045, 'title': 'Godlike Hunter'}, + {'xp': 1150, 'title': 'Duck Nightmare'}, + {'xp': 1260, 'title': 'Duck Executioner'}, + {'xp': 1375, 'title': 'Duck Eliminator'}, + {'xp': 1495, 'title': 'Duck Obliterator'}, + {'xp': 1620, 'title': 'Duck Annihilator'}, + {'xp': 1750, 'title': 'Duck Devastator'}, + {'xp': 1885, 'title': 'Duck Vanquisher'}, + {'xp': 2025, 'title': 'Duck Conqueror'}, + {'xp': 2170, 'title': 'Duck Dominator'}, + {'xp': 2320, 'title': 'Duck Emperor'}, + {'xp': 2475, 'title': 'Duck Overlord'}, + {'xp': 2635, 'title': 'Duck Deity'}, + {'xp': 2800, 'title': 'Duck God'}, + {'xp': 2970, 'title': 'Duck Nemesis'}, + {'xp': 3145, 'title': 'Duck Apocalypse'}, + {'xp': 3325, 'title': 'Duck Armageddon'}, + {'xp': 3510, 'title': 'Duck Ragnarok'}, + {'xp': 3700, 'title': 'Duck Cataclysm'}, + {'xp': 3895, 'title': 'Duck Holocaust'}, + {'xp': 4095, 'title': 'Duck Genesis'} + ] + + # Sleep hours configuration (when ducks don't spawn) + self.sleep_hours = self.config.get('sleep_hours', []) # Format: [[22, 30], [8, 0]] for 22:30 to 08:00 + + # Duck planning system + self.daily_duck_plan = {} # Format: {channel: [(hour, minute), ...]} + + # Karma system + self.karma_events = ['teamkill', 'miss', 'wild_shot', 'hit', 'golden_hit'] + + self.load_database() + + def get_player_level(self, xp): + """Get player level and title based on XP""" + for i in range(len(self.levels) - 1, -1, -1): + if xp >= self.levels[i]['xp']: + return i + 1, self.levels[i]['title'] + return 1, self.levels[0]['title'] + + def get_xp_for_next_level(self, xp): + """Get XP needed for next level""" + level, _ = self.get_player_level(xp) + if level < len(self.levels): + return self.levels[level]['xp'] - xp + return 0 # Max level reached + + def calculate_penalty_by_level(self, base_penalty, xp): + """Calculate penalty based on player level""" + level, _ = self.get_player_level(xp) + # Higher levels get higher penalties + return base_penalty + (level - 1) * 0.5 + + def update_karma(self, player, event): + """Update player karma based on event""" + if 'karma' not in player: + player['karma'] = 0 + + karma_changes = { + 'hit': 2, + 'golden_hit': 5, + 'teamkill': -10, + 'wild_shot': -3, + 'miss': -1 + } + + player['karma'] += karma_changes.get(event, 0) + + def get_player_coins(self, player): + """Get player coins with safe access""" + return player.get('coins', 0) + + def set_player_coins(self, player, amount): + """Set player coins safely""" + player['coins'] = max(0, amount) + + def add_player_coins(self, player, amount): + """Add coins to player safely""" + current_coins = self.get_player_coins(player) + self.set_player_coins(player, current_coins + amount) + + def deduct_player_coins(self, player, amount): + """Deduct coins from player safely""" + current_coins = self.get_player_coins(player) + self.set_player_coins(player, current_coins - amount) + + def is_sleep_time(self): + """Check if current time is within sleep hours""" + if not self.sleep_hours: + return False + + import datetime + now = datetime.datetime.now() + current_time = now.hour * 60 + now.minute + + for sleep_start, sleep_end in self.sleep_hours: + start_minutes = sleep_start[0] * 60 + sleep_start[1] + end_minutes = sleep_end[0] * 60 + sleep_end[1] + + if start_minutes <= end_minutes: # Same day + if start_minutes <= current_time <= end_minutes: + return True + else: # Crosses midnight + if current_time >= start_minutes or current_time <= end_minutes: + return True + return False + + def calculate_gun_reliability(self, player): + """Calculate gun reliability percentage""" + base_reliability = player.get('reliability', 70) + grease_bonus = 10 if player.get('grease', 0) > 0 else 0 + brush_bonus = 5 if player.get('brush', 0) > 0 else 0 + return min(base_reliability + grease_bonus + brush_bonus, 95) + + def gun_jams(self, player): + """Check if gun jams when firing""" + reliability = self.calculate_gun_reliability(player) + return random.randint(1, 100) > reliability + + async def scare_other_ducks(self, channel, shot_duck_id): + """Successful shots can scare other ducks away""" + if not self.config.get('successful_shots_scare_ducks', True): + return + + channel_ducks = self.ducks.get(channel, []) + for duck in channel_ducks: + if duck.get('alive') and duck['id'] != shot_duck_id: + # 30% chance to scare each remaining duck + if random.randint(1, 100) <= 30: + duck['scared'] = True + duck['alive'] = False + + async def scare_duck_on_miss(self, channel, target_duck): + """Duck may be scared by missed shots""" + if target_duck.get('hit_attempts', 0) >= 2: # Duck gets scared after 2+ attempts + if random.randint(1, 100) <= 40: # 40% chance to scare + target_duck['scared'] = True + target_duck['alive'] = False + self.send_message(channel, f"The duck got scared and flew away! (\\_o<) *flap flap*") + + async def find_bushes_items(self, nick, channel, player): + """Find items in bushes after killing a duck""" + if random.randint(1, 100) <= 12: # 12% chance to find something + found_items = [ + "Handful of sand", "Water bucket", "Four-leaf clover", "Mirror", + "Grease", "Brush for gun", "Spare clothes", "Sunglasses", + "Piece of bread", "Life insurance" + ] + found_item = random.choice(found_items) + + # Add item to player inventory + item_key = found_item.lower().replace(' ', '_').replace("'", "") + if 'four_leaf_clover' in item_key: + item_key = 'luck' + player['luck'] = player.get('luck', 0) + 1 + elif item_key in player: + player[item_key] = player.get(item_key, 0) + 1 + + self.send_message(channel, f"{nick} > {self.colors['cyan']}You found {found_item} in the bushes!{self.colors['reset']}") + self.save_player(f"{nick}!user@host") # Save player data + + def load_database(self): + """Load player data from JSON file""" + if os.path.exists(self.db_file): + try: + with open(self.db_file, 'r') as f: + data = json.load(f) + self.players = data.get('players', {}) + self.logger.info(f"Loaded {len(self.players)} players from {self.db_file}") + except (json.JSONDecodeError, IOError) as e: + self.logger.error(f"Error loading database: {e}") + self.players = {} + else: + self.players = {} + self.logger.info(f"Created new database: {self.db_file}") + + def save_database(self): + """Save all player data to JSON file with error handling""" + try: + # Atomic write to prevent corruption + temp_file = f"{self.db_file}.tmp" + data = { + 'players': self.players, + 'last_save': str(time.time()) + } + with open(temp_file, 'w') as f: + json.dump(data, f, indent=2) + + # Atomic rename to replace old file + import os + os.replace(temp_file, self.db_file) + + except IOError as e: + self.logger.error(f"Error saving database: {e}") + except Exception as e: + self.logger.error(f"Unexpected database save error: {e}") + + def is_admin(self, user): + """Check if user is admin by nick only""" + if '!' not in user: + return False + nick = user.split('!')[0].lower() + return nick in self.admins + + async def send_user_message(self, nick, channel, message): + """Send message to user respecting their notice/private message preferences""" + player = self.get_player(f"{nick}!*@*") + + # Default to channel notices if player not found or no settings + use_notices = True + if player and 'settings' in player: + use_notices = player['settings'].get('notices', True) + + if use_notices: + # Send to channel + self.send_message(channel, message) + else: + # Send as private message + private_msg = message.replace(f"{nick} > ", "") # Remove nick prefix for PM + self.send_message(nick, private_msg) + + def get_random_player_for_friendly_fire(self, shooter_nick): + """Get a random player (except shooter) for friendly fire""" + eligible_players = [] + shooter_lower = shooter_nick.lower() + + for nick in self.players.keys(): + if nick != shooter_lower: # Don't hit yourself + eligible_players.append(nick) + + if eligible_players: + return random.choice(eligible_players) + return None + + async def connect(self): + server = self.config['server'] + port = self.config['port'] + ssl_context = ssl.create_default_context() if self.config.get('ssl', True) else None + + self.logger.info(f"Connecting to {server}:{port} (SSL: {ssl_context is not None})") + + self.reader, self.writer = await asyncio.open_connection( + server, port, ssl=ssl_context + ) + self.logger.info("Connected successfully!") + + # Check if SASL is enabled + sasl_config = self.config.get('sasl', {}) + if sasl_config.get('enabled', False): + self.logger.info("SASL authentication enabled") + # Request SASL capability + self.send_raw('CAP LS 302') + else: + # Standard registration without SASL + await self.register_user() + + async def register_user(self): + """Register the user with the IRC server""" + self.logger.info(f"Registering as {self.config['nick']}") + self.send_raw(f'NICK {self.config["nick"]}') + self.send_raw(f'USER {self.config["nick"]} 0 * :DuckHunt Bot') + + # Send password if configured (for servers that require it) + if self.config.get('password'): + self.send_raw(f'PASS {self.config["password"]}') + + async def handle_sasl_auth(self): + """Handle SASL PLAIN authentication""" + sasl_config = self.config.get('sasl', {}) + username = sasl_config.get('username', '') + password = sasl_config.get('password', '') + + if not username or not password: + self.logger.error("SASL enabled but username/password not configured") + await self.register_user() + return + + self.logger.info(f"Authenticating via SASL as {username}") + + # SASL PLAIN authentication format: authzid \0 authcid \0 password + # For most IRC networks: "" \0 username \0 password + auth_string = f"\0{username}\0{password}" + auth_b64 = base64.b64encode(auth_string.encode()).decode() + + self.logger.debug(f"SASL auth string length: {len(auth_b64)} chars") + + self.send_raw('AUTHENTICATE PLAIN') + # Split long auth strings into 400-byte chunks as per IRC spec + if len(auth_b64) <= 400: + self.send_raw(f'AUTHENTICATE {auth_b64}') + else: + while auth_b64: + chunk = auth_b64[:400] + auth_b64 = auth_b64[400:] + self.send_raw(f'AUTHENTICATE {chunk}') + if not auth_b64: + break + + async def attempt_nickserv_auth(self): + """Attempt NickServ identification as fallback""" + sasl_config = self.config.get('sasl', {}) + username = sasl_config.get('username', '') + password = sasl_config.get('password', '') + + if username and password: + self.logger.info(f"Attempting NickServ identification for {username}") + # Try both common NickServ commands + self.send_raw(f'PRIVMSG NickServ :IDENTIFY {username} {password}') + # Some networks use just the password if nick matches + await asyncio.sleep(1) + self.send_raw(f'PRIVMSG NickServ :IDENTIFY {password}') + self.logger.info("NickServ identification commands sent") + else: + self.logger.debug("No SASL credentials available for NickServ fallback") + + async def handle_nickserv_response(self, message): + """Handle responses from NickServ""" + message_lower = message.lower() + + if any(phrase in message_lower for phrase in [ + 'you are now identified', 'password accepted', 'you are already identified', + 'authentication successful', 'you have been identified' + ]): + self.logger.info("NickServ identification successful!") + + elif any(phrase in message_lower for phrase in [ + 'invalid password', 'incorrect password', 'access denied', + 'authentication failed', 'not registered', 'nickname is not registered' + ]): + self.logger.error(f"NickServ identification failed: {message}") + + else: + self.logger.debug(f"NickServ message: {message}") + + def send_raw(self, msg): + # Skip debug logging for speed + # self.logger.debug(f"-> {msg}") + if self.writer: + self.writer.write((msg + '\r\n').encode()) + + def send_message(self, target, msg): + # Skip logging during gameplay for speed (uncomment for debugging) + # self.logger.info(f"Sending to {target}: {msg}") + self.send_raw(f'PRIVMSG {target} :{msg}') + # Remove drain() for faster responses - let TCP handle buffering + + def get_player(self, user): + """Get player data by nickname only (case insensitive)""" + if '!' not in user: + return None + + nick = user.split('!')[0].lower() # Case insensitive + + # Use nick as database key + if nick in self.players: + return self.players[nick] + + # Create new player + player_data = { + 'xp': 0, + 'coins': 0, # Add missing coins field + 'caught': 0, + 'befriended': 0, # Separate counter for befriended ducks + 'missed': 0, + 'ammo': 6, + 'max_ammo': 6, + 'chargers': 2, + 'max_chargers': 2, + 'accuracy': 65, + 'reliability': 70, # Gun reliability percentage + 'weapon': 'pistol', # Default weapon + 'gun_confiscated': False, + 'explosive_ammo': False, + 'settings': { + 'notices': True, # True for notices, False for private messages + 'private_messages': False + }, + # New advanced stats + 'golden_ducks': 0, + 'karma': 0, + 'deflection': 0, + 'defense': 0, + 'jammed': False, + 'jammed_count': 0, + 'deaths': 0, + 'neutralized': 0, + 'deflected': 0, + 'best_time': 999.9, + 'total_reflex_time': 0.0, + 'reflex_shots': 0, + 'wild_shots': 0, + 'accidents': 0, + 'total_ammo_used': 0, + 'shot_at': 0, + 'lucky_shots': 0, + # Shop items + 'luck': 0, + 'detector': 0, + 'silencer': 0, + 'sunglasses': 0, + 'clothes': 0, + 'grease': 0, + 'brush': 0, + 'mirror': 0, + 'sand': 0, + 'water': 0, + 'sabotage': 0, + 'life_insurance': 0, + 'liability': 0, + 'decoy': 0, + 'bread': 0, + 'duck_detector': 0, + 'mechanical': 0 + } + + self.players[nick] = player_data + self.save_database() # Auto-save new players + return player_data + + def save_player(self, user): + """Save player data - batch saves for performance""" + if not hasattr(self, '_save_pending'): + self._save_pending = False + + if not self._save_pending: + self._save_pending = True + # Schedule delayed save to batch multiple writes + asyncio.create_task(self._delayed_save()) + + async def _delayed_save(self): + """Batch save to reduce disk I/O""" + await asyncio.sleep(0.5) # Small delay to batch saves + self.save_database() + self._save_pending = False + + def setup_signal_handlers(self): + """Setup signal handlers for graceful shutdown""" + def signal_handler(signum): + signal_name = signal.Signals(signum).name + self.logger.info(f"Received {signal_name}, initiating graceful shutdown...") + self.shutdown_requested = True + + # Handle common shutdown signals + if hasattr(signal, 'SIGTERM'): + signal.signal(signal.SIGTERM, lambda s, f: signal_handler(s)) + if hasattr(signal, 'SIGINT'): + signal.signal(signal.SIGINT, lambda s, f: signal_handler(s)) + if hasattr(signal, 'SIGHUP'): + signal.signal(signal.SIGHUP, lambda s, f: signal_handler(s)) + + def is_rate_limited(self, user, command, cooldown=2.0): + """Check if user is rate limited for a command""" + now = time.time() + key = f"{user}:{command}" + + if key in self.command_cooldowns: + if now - self.command_cooldowns[key] < cooldown: + return True + + self.command_cooldowns[key] = now + return False + + async def handle_command(self, user, channel, message): + if not user: + return + + nick = user.split('!')[0] + nick_lower = nick.lower() + + # Check if user is ignored + if nick_lower in self.ignored_nicks: + return + + # Determine if this is a private message to the bot + is_private = channel == self.config['nick'] + + # For private messages, use the nick as the target for responses + response_target = nick if is_private else channel + + # Handle private messages (no ! prefix needed) + if is_private: + cmd = message.strip().lower() + + # Private message admin commands + if self.is_admin(user): + if cmd == 'restart': + await self.handle_restart(nick, response_target) + return + elif cmd == 'quit': + await self.handle_quit(nick, response_target) + return + elif cmd == 'launch' or cmd == 'ducklaunch': + # For private messages, launch in all channels + for chan in self.channels_joined: + await self.spawn_duck_now(chan) + self.send_message(response_target, f"{nick} > Launched ducks in all channels!") + return + elif cmd == 'golden' or cmd == 'goldenduck': + # Launch golden ducks + for chan in self.channels_joined: + await self.spawn_duck_now(chan, force_golden=True) + self.send_message(response_target, f"{nick} > Launched {self.colors['yellow']}GOLDEN DUCKS{self.colors['reset']} in all channels!") + return + elif cmd.startswith('ignore '): + target_nick = cmd[7:].strip().lower() + await self.handle_ignore(nick, response_target, target_nick) + return + elif cmd.startswith('delignore '): + target_nick = cmd[10:].strip().lower() + await self.handle_delignore(nick, response_target, target_nick) + return + else: + # Unknown private command + self.send_message(response_target, f"{nick} > Admin commands: restart, quit, launch, golden, ignore , delignore ") + return + else: + # Non-admin private message + self.send_message(response_target, f"{nick} > Private commands are admin-only. Use !help in a channel for game commands.") + return + + # Handle channel messages (must start with !) + if not message.startswith('!'): + return + + cmd = message.strip().lower() + + # Regular game commands (channel only) + # Inline common commands for speed + if cmd == '!bang': + # Rate limit shooting to prevent spam + if self.is_rate_limited(user, 'bang', 1.0): + return + + player = self.get_player(user) + if not player: + return + + # Check if gun is confiscated + if player.get('gun_confiscated', False): + self.send_message(channel, f"{nick} > {self.colors['red']}Your gun has been confiscated! Buy a new gun from the shop (item #5).{self.colors['reset']}") + return + + # Check if gun is jammed + if player.get('jammed', False): + self.send_message(channel, f"{nick} > {self.colors['red']}Your gun is jammed! Use !reload to unjam it.{self.colors['reset']}") + return + + # Check ammo + if player['ammo'] <= 0: + self.send_message(channel, f"{nick} > Your gun is empty! | Ammo: 0/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") + return + + # Check for gun jamming before shooting + if self.gun_jams(player): + player['jammed'] = True + player['jammed_count'] = player.get('jammed_count', 0) + 1 + jam_sound = "โ€ขclickโ€ข โ€ขclickโ€ข" if player.get('silencer', 0) > 0 else "*CLICK* *CLICK*" + self.send_message(channel, f"{nick} > {jam_sound} Your gun jammed! Use !reload to unjam it.") + self.save_player(user) + return + + # Get ducks in this channel + channel_ducks = self.ducks.get(channel, []) + alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] + + # Consume ammo + player['ammo'] -= 1 + player['total_ammo_used'] = player.get('total_ammo_used', 0) + 1 + + if alive_ducks: + # Target the oldest duck (first in, first out) + target_duck = alive_ducks[0] + shot_time = time.time() - target_duck['spawn_time'] + is_golden = target_duck.get('type') == 'golden' + + # Calculate hit chance (golden ducks are harder to hit) + base_accuracy = player['accuracy'] + if is_golden: + base_accuracy = max(base_accuracy - 30, 10) # Golden ducks much harder + + # Apply bonuses + if player.get('sunglasses', 0) > 0: + base_accuracy += 5 # Sunglasses help + if player.get('mirror', 0) > 0: + base_accuracy += 3 # Mirror helps + + hit_chance = min(base_accuracy, 95) # Cap at 95% + + # Record shot attempt + player['shot_at'] = player.get('shot_at', 0) + 1 + target_duck['hit_attempts'] = target_duck.get('hit_attempts', 0) + 1 + + # Check for hit + if random.randint(1, 100) <= hit_chance: + # HIT! + player['caught'] += 1 + target_duck['alive'] = False + + # Update reflex time stats + player['reflex_shots'] = player.get('reflex_shots', 0) + 1 + player['total_reflex_time'] = player.get('total_reflex_time', 0) + shot_time + if shot_time < player.get('best_time', 999.9): + player['best_time'] = shot_time + + # Calculate XP and rewards + if is_golden: + player['golden_ducks'] = player.get('golden_ducks', 0) + 1 + base_xp = 50 # Golden ducks give much more XP + self.update_karma(player, 'golden_hit') + else: + base_xp = 15 # Normal XP + self.update_karma(player, 'hit') + + # Lucky shot bonus + luck_multiplier = 1 + (player.get('luck', 0) * 0.1) # 10% per luck point + is_lucky = random.randint(1, 100) <= (5 + player.get('luck', 0)) + if is_lucky: + player['lucky_shots'] = player.get('lucky_shots', 0) + 1 + luck_multiplier *= 1.5 # 50% bonus for lucky shot + + xp_earned = int(base_xp * luck_multiplier) + player['xp'] += xp_earned + + # Sound effects based on ammo type + if player.get('explosive_ammo', False): + shot_sound = "โ€ขBOUMโ€ข" if player.get('silencer', 0) > 0 else "*BOUM*" + explosive_text = f" {self.colors['orange']}[explosive ammo]{self.colors['reset']}" + else: + shot_sound = "โ€ขbangโ€ข" if player.get('silencer', 0) > 0 else "*BANG*" + explosive_text = "" + + # Lucky shot text + lucky_text = f" {self.colors['yellow']}[lucky shot!]{self.colors['reset']}" if is_lucky else "" + + # Build hit message + level, title = self.get_player_level(player['xp']) + + if is_golden: + golden_count = player.get('golden_ducks', 0) + hit_msg = f"{nick} > {self.colors['yellow']}{shot_sound}{self.colors['reset']} You shot down the {self.colors['yellow']}โ˜… GOLDEN DUCK โ˜…{self.colors['reset']} in {shot_time:.3f}s! Total: {player['caught']} ducks ({self.colors['yellow']}{golden_count} golden{self.colors['reset']}) | Level {level}: {title} | [{self.colors['yellow']}{xp_earned} xp{self.colors['reset']}]{explosive_text}{lucky_text}" + else: + hit_msg = f"{nick} > {self.colors['green']}{shot_sound}{self.colors['reset']} You shot down the duck in {shot_time:.3f}s! Total: {player['caught']} ducks | Level {level}: {title} | [{self.colors['green']}{xp_earned} xp{self.colors['reset']}]{explosive_text}{lucky_text}" + + self.send_message(channel, hit_msg) + + # Scare other ducks if enabled (successful shots can scare ducks) + await self.scare_other_ducks(channel, target_duck['id']) + + # Find items in bushes (rare chance) + await self.find_bushes_items(nick, channel, player) + + else: + # MISS! + player['missed'] += 1 + self.update_karma(player, 'miss') + + # Calculate miss penalty based on level + miss_penalty = int(self.calculate_penalty_by_level(-2, player['xp'])) + player['xp'] += miss_penalty + + # Bullet ricochet chance (can hit other players) + ricochet_chance = 8 # 8% base chance + if player.get('explosive_ammo', False): + ricochet_chance = 15 # Higher with explosive + + ricochet_msg = "" + if random.randint(1, 100) <= ricochet_chance: + ricochet_target = self.get_random_player_for_friendly_fire(nick) + if ricochet_target: + target_player = self.players[ricochet_target] + ricochet_dmg = -3 + target_player['xp'] += ricochet_dmg + target_player['shot_at'] = target_player.get('shot_at', 0) + 1 + ricochet_msg = f" {self.colors['red']}[RICOCHET: {ricochet_target} hit for {ricochet_dmg} xp]{self.colors['reset']}" + + # Scare duck on miss + await self.scare_duck_on_miss(channel, target_duck) + + miss_sound = "โ€ขclickโ€ข" if player.get('silencer', 0) > 0 else "*CLICK*" + await self.send_user_message(nick, channel, f"{nick} > {miss_sound} You missed the duck! [miss: {miss_penalty} xp]{ricochet_msg}") + + else: + # No duck present - wild fire! + player['wild_shots'] = player.get('wild_shots', 0) + 1 + self.update_karma(player, 'wild_shot') + + # Calculate penalties based on level + miss_penalty = int(self.calculate_penalty_by_level(-2, player['xp'])) + wild_penalty = int(self.calculate_penalty_by_level(-3, player['xp'])) + player['xp'] += miss_penalty + wild_penalty + + # Confiscate gun + player['gun_confiscated'] = True + + # Higher chance of hitting other players when no duck + friendly_fire_chance = 25 # 25% when no duck + friendly_fire_msg = "" + + if random.randint(1, 100) <= friendly_fire_chance: + ff_target = self.get_random_player_for_friendly_fire(nick) + if ff_target: + target_player = self.players[ff_target] + ff_dmg = int(self.calculate_penalty_by_level(-4, target_player['xp'])) + target_player['xp'] += ff_dmg + target_player['shot_at'] = target_player.get('shot_at', 0) + 1 + player['accidents'] = player.get('accidents', 0) + 1 + self.update_karma(player, 'teamkill') + friendly_fire_msg = f" {self.colors['red']}[ACCIDENT: {ff_target} injured for {ff_dmg} xp]{self.colors['reset']}" + + wild_sound = "โ€ขBOUMโ€ข" if player.get('explosive_ammo', False) else "*BANG*" + if player.get('silencer', 0) > 0: + wild_sound = "โ€ข" + wild_sound[1:-1] + "โ€ข" + + confiscated_msg = f" {self.colors['red']}[GUN CONFISCATED]{self.colors['reset']}" + await self.send_user_message(nick, channel, f"{nick} > {wild_sound} You shot at nothing! What were you aiming at? [miss: {miss_penalty} xp] [wild fire: {wild_penalty} xp]{confiscated_msg}{friendly_fire_msg}") + + # Save after each shot + self.save_player(user) + + elif cmd == '!bef': + player = self.get_player(user) + if not player: + return + + # Get ducks in this channel + channel_ducks = self.ducks.get(channel, []) + alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] + + if alive_ducks: + # Target the oldest duck (first in, first out) + target_duck = alive_ducks[0] + bef_time = time.time() - target_duck['spawn_time'] + + # Befriend the duck - gives friendship XP and coins + player['befriended'] = player.get('befriended', 0) + 1 # Track befriended separately + player['xp'] += 8 # Less XP than shooting but still good + coins_earned = random.randint(1, 2) # 1-2 coins per befriended duck + self.add_player_coins(player, coins_earned) # Safe coins access + + # Mark duck as befriended (dead) + target_duck['alive'] = False + + # Lucky items with luck bonus (same chance as shooting) + lucky_items = ["four-leaf clover", "rabbit's foot", "horseshoe", "lucky penny", "magic feather"] + base_luck_chance = 5 + player['luck'] # 5% base + luck bonus + lucky_item = random.choice(lucky_items) if random.randint(1, 100) <= base_luck_chance else None + lucky_text = f" [{lucky_item}]" if lucky_item else "" + + remaining_ducks = len([d for d in channel_ducks if d.get('alive')]) + duck_count_text = f" | {remaining_ducks} ducks remain" if remaining_ducks > 0 else "" + + self.send_message(channel, f"{nick} > You befriended a duck in {bef_time:.3f}s! Total friends: {player['befriended']} ducks on {channel}. \\_o< *quack* [8 xp] [+{coins_earned} coins]{lucky_text}{duck_count_text}") + + # Save to database after befriending + self.save_player(user) + else: + self.send_message(channel, f"{nick} > There is no duck to befriend!") + + elif cmd == '!reload': + player = self.get_player(user) + if not player: + return + + # Check if gun is jammed (reload unjams it) + if player.get('jammed', False): + player['jammed'] = False + unjam_sound = "โ€ขclick clickโ€ข" if player.get('silencer', 0) > 0 else "*click click*" + self.send_message(channel, f"{nick} > {unjam_sound} You unjammed your gun! | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") + self.save_player(user) + return + + if player['ammo'] == player['max_ammo']: + self.send_message(channel, f"{nick} > Your gun doesn't need to be reloaded. | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") + return + + if player['chargers'] <= 0: + self.send_message(channel, f"{nick} > You don't have any chargers left! | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: 0/{player['max_chargers']}") + return + + # Calculate reload reliability + reload_reliability = self.calculate_gun_reliability(player) + + if random.randint(1, 100) <= reload_reliability: + player['chargers'] -= 1 + player['ammo'] = player['max_ammo'] + reload_sound = "โ€ขclickโ€ข" if player.get('silencer', 0) > 0 else "*click*" + self.send_message(channel, f"{nick} > {reload_sound} You reloaded your gun! | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") + else: + # Gun jams during reload + player['jammed'] = True + player['jammed_count'] = player.get('jammed_count', 0) + 1 + jam_sound = "โ€ขCLACKโ€ข โ€ขclick clickโ€ข" if player.get('silencer', 0) > 0 else "*CLACK* *click click*" + self.send_message(channel, f"{nick} > {jam_sound} Your gun jammed while reloading! Use !reload again to unjam it.") + + # Save to database after reload + self.save_player(user) + + elif cmd == '!stats': + await self.handle_stats(nick, channel, user) + elif cmd == '!help': + await self.handle_help(nick, channel) + elif cmd == '!shop': + await self.handle_shop(nick, channel, user) + elif cmd.startswith('!buy '): + item = cmd[5:].strip() + await self.handle_buy(nick, channel, item, user) + elif cmd.startswith('!trade '): + parts = cmd[7:].split() + if len(parts) >= 3: + target_nick, item, amount = parts[0], parts[1], parts[2] + await self.handle_trade(nick, channel, user, target_nick, item, amount) + else: + self.send_message(channel, f"{nick} > Usage: !trade ") + elif cmd.startswith('!steal '): + target_nick = cmd[7:].strip() + await self.handle_steal(nick, channel, user, target_nick) + elif cmd.startswith('!give '): + parts = cmd[6:].split() + if len(parts) >= 3: + target_nick, item, amount = parts[0], parts[1], parts[2] + await self.handle_give(nick, channel, user, target_nick, item, amount) + else: + self.send_message(channel, f"{nick} > Usage: !give ") + elif cmd.startswith('!rearm ') and self.is_admin(user): # Admin only + # Allow rearming other players or self + target_nick = cmd[7:].strip() + await self.handle_rearm(nick, channel, user, target_nick) + elif cmd == '!rearm' and self.is_admin(user): # Admin only + # Rearm self + await self.handle_rearm(nick, channel, user, nick) + elif cmd == '!duck' and self.is_admin(user): # Admin only + await self.spawn_duck_now(channel) + elif cmd == '!golden' and self.is_admin(user): # Admin only + await self.spawn_duck_now(channel, force_golden=True) + elif cmd == '!listplayers' and self.is_admin(user): # Admin only + await self.handle_listplayers(nick, channel) + elif cmd.startswith('!setcoins ') and self.is_admin(user): # Admin only + parts = cmd[10:].split() + if len(parts) >= 2: + target_nick, amount = parts[0], parts[1] + await self.handle_setcoins(nick, channel, target_nick, amount) + elif cmd.startswith('!ban ') and self.is_admin(user): # Admin only + target_nick = cmd[5:].strip() + await self.handle_ban(nick, channel, target_nick) + elif cmd.startswith('!reset ') and self.is_admin(user): # Admin only + target_nick = cmd[7:].strip() + await self.handle_reset(nick, channel, target_nick) + elif cmd == '!resetdb' and self.is_admin(user): # Admin only + await self.handle_reset_database(nick, channel, user) + elif cmd.startswith('!resetdb confirm ') and self.is_admin(user): # Admin only + confirmation = cmd[17:].strip() + await self.handle_reset_database_confirm(nick, channel, user, confirmation) + elif cmd == '!restart' and self.is_admin(user): # Admin only + await self.handle_restart(nick, channel) + elif cmd == '!quit' and self.is_admin(user): # Admin only + await self.handle_quit(nick, channel) + elif cmd == '!ducklaunch' and self.is_admin(user): # Admin only + await self.spawn_duck_now(channel) + elif cmd == '!ducks': + # Show duck count for all users + channel_ducks = self.ducks.get(channel, []) + alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] + dead_ducks = [duck for duck in channel_ducks if not duck.get('alive')] + + if alive_ducks: + duck_list = [] + for duck in alive_ducks: + duck_type = duck.get('type', 'normal') + spawn_time = time.time() - duck['spawn_time'] + if duck_type == 'golden': + duck_list.append(f"{self.colors['yellow']}Golden Duck{self.colors['reset']} ({spawn_time:.1f}s)") + else: + duck_list.append(f"Duck ({spawn_time:.1f}s)") + self.send_message(channel, f"{nick} > Active ducks: {', '.join(duck_list)}") + else: + self.send_message(channel, f"{nick} > No ducks currently active.") + + elif cmd == '!top' or cmd == '!leaderboard': + # Show top players by XP + if not self.players: + self.send_message(channel, f"{nick} > No players found!") + return + + # Sort players by XP + sorted_players = sorted(self.players.items(), key=lambda x: x[1]['xp'], reverse=True) + top_5 = sorted_players[:5] + + self.send_message(channel, f"{self.colors['cyan']}๐Ÿ† TOP HUNTERS LEADERBOARD ๐Ÿ†{self.colors['reset']}") + for i, (player_nick, player_data) in enumerate(top_5, 1): + level, title = self.get_player_level(player_data['xp']) + total_ducks = player_data.get('caught', 0) + player_data.get('befriended', 0) + golden = player_data.get('golden_ducks', 0) + golden_text = f" ({self.colors['yellow']}{golden} golden{self.colors['reset']})" if golden > 0 else "" + + if i == 1: + rank_color = self.colors['yellow'] # Gold + elif i == 2: + rank_color = self.colors['gray'] # Silver + elif i == 3: + rank_color = self.colors['orange'] # Bronze + else: + rank_color = self.colors['white'] + + self.send_message(channel, f"{rank_color}#{i}{self.colors['reset']} {player_nick} - Level {level}: {title} | XP: {player_data['xp']} | Ducks: {total_ducks}{golden_text}") + + elif cmd == '!levels': + # Show level progression table + self.send_message(channel, f"{self.colors['cyan']}๐ŸŽฏ LEVEL PROGRESSION SYSTEM ๐ŸŽฏ{self.colors['reset']}") + + # Show first 10 levels as example + for i in range(min(10, len(self.levels))): + level_data = self.levels[i] + next_xp = self.levels[i + 1]['xp'] if i + 1 < len(self.levels) else "MAX" + self.send_message(channel, f"Level {i + 1}: {level_data['title']} (XP: {level_data['xp']} - {next_xp})") + + if len(self.levels) > 10: + self.send_message(channel, f"... and {len(self.levels) - 10} more levels up to Level {len(self.levels)}: {self.levels[-1]['title']}") + + elif cmd.startswith('!level '): + # Show specific player's level info + target_nick = cmd[7:].strip().lower() + if target_nick in self.players: + target_player = self.players[target_nick] + level, title = self.get_player_level(target_player['xp']) + xp_for_next = self.get_xp_for_next_level(target_player['xp']) + + if xp_for_next > 0: + next_info = f"Next level in {xp_for_next} XP" + else: + next_info = "MAX LEVEL REACHED!" + + self.send_message(channel, f"{nick} > {target_nick}: Level {level} - {self.colors['cyan']}{title}{self.colors['reset']} | {next_info}") + else: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + + elif cmd == '!karma': + # Show karma leaderboard + if not self.players: + self.send_message(channel, f"{nick} > No players found!") + return + + # Sort by karma + karma_players = [(nick, data) for nick, data in self.players.items() if data.get('karma', 0) != 0] + karma_players.sort(key=lambda x: x[1].get('karma', 0), reverse=True) + + if not karma_players: + self.send_message(channel, f"{nick} > No karma data available!") + return + + self.send_message(channel, f"{self.colors['purple']}โ˜ฏ KARMA LEADERBOARD โ˜ฏ{self.colors['reset']}") + for i, (player_nick, player_data) in enumerate(karma_players[:5], 1): + karma = player_data.get('karma', 0) + karma_color = self.colors['green'] if karma >= 0 else self.colors['red'] + karma_text = "Saint" if karma >= 50 else "Good" if karma >= 10 else "Evil" if karma <= -10 else "Chaotic" if karma <= -50 else "Neutral" + + self.send_message(channel, f"#{i} {player_nick} - {karma_color}Karma: {karma}{self.colors['reset']} ({karma_text})") + + elif cmd == '!ducks': + # Show duck count for all users + channel_ducks = self.ducks.get(channel, []) + alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] + dead_ducks = [duck for duck in channel_ducks if not duck.get('alive')] + + if alive_ducks: + oldest_time = min(time.time() - duck['spawn_time'] for duck in alive_ducks) + self.send_message(channel, f"{nick} > {len(alive_ducks)} ducks in {channel} | Oldest: {oldest_time:.1f}s | Dead: {len(dead_ducks)} | Timeout: {self.duck_timeout_min}-{self.duck_timeout_max}s") + else: + self.send_message(channel, f"{nick} > No ducks in {channel} | Dead: {len(dead_ducks)}") + elif cmd == '!output' or cmd.startswith('!output '): + parts = cmd.split(maxsplit=1) + output_type = parts[1] if len(parts) > 1 else '' + await self.handle_output(nick, channel, user, output_type) + elif cmd.startswith('!ignore ') and self.is_admin(user): # Admin only + target_nick = cmd[8:].strip().lower() + await self.handle_ignore(nick, channel, target_nick) + elif cmd.startswith('!delignore ') and self.is_admin(user): # Admin only + target_nick = cmd[11:].strip().lower() + await self.handle_delignore(nick, channel, target_nick) + elif cmd.startswith('!giveitem ') and self.is_admin(user): # Admin only + parts = cmd[10:].split() + if len(parts) >= 2: + target_nick, item = parts[0], parts[1] + await self.handle_admin_giveitem(nick, channel, target_nick, item) + else: + self.send_message(channel, f"{nick} > Usage: !giveitem ") + elif cmd.startswith('!givexp ') and self.is_admin(user): # Admin only + parts = cmd[8:].split() + if len(parts) >= 2: + target_nick, amount = parts[0], parts[1] + await self.handle_admin_givexp(nick, channel, target_nick, amount) + else: + self.send_message(channel, f"{nick} > Usage: !givexp ") + + async def handle_stats(self, nick, channel, user): + player = self.get_player(user) + if not player: + self.send_message(channel, f"{nick} > Player data not found!") + return + + # Get level and title + level, title = self.get_player_level(player['xp']) + xp_for_next = self.get_xp_for_next_level(player['xp']) + + # Calculate advanced stats + total_shots = player.get('caught', 0) + player.get('missed', 0) + effective_accuracy = (player.get('caught', 0) / total_shots * 100) if total_shots > 0 else 0 + average_time = (player.get('total_reflex_time', 0) / player.get('reflex_shots', 1)) if player.get('reflex_shots', 0) > 0 else 0 + + # Gun status + gun_status = "" + if player.get('gun_confiscated', False): + gun_status += f" {self.colors['red']}[CONFISCATED]{self.colors['reset']}" + if player.get('jammed', False): + gun_status += f" {self.colors['yellow']}[JAMMED]{self.colors['reset']}" + if player.get('explosive_ammo', False): + gun_status += f" {self.colors['orange']}[EXPLOSIVE]{self.colors['reset']}" + + # Duck stats with colors + duck_stats = [] + if player.get('caught', 0) > 0: + duck_stats.append(f"Shot:{player['caught']}") + if player.get('befriended', 0) > 0: + duck_stats.append(f"Befriended:{player['befriended']}") + if player.get('golden_ducks', 0) > 0: + duck_stats.append(f"{self.colors['yellow']}Golden:{player['golden_ducks']}{self.colors['reset']}") + + duck_display = f"Ducks:({', '.join(duck_stats)})" if duck_stats else "Ducks:0" + + # Main stats line + stats_line1 = f"{nick} > {duck_display} | Level {level}: {self.colors['cyan']}{title}{self.colors['reset']} | XP: {player['xp']}" + if xp_for_next > 0: + stats_line1 += f" (next: {xp_for_next})" + + # Combat stats line + karma_color = self.colors['green'] if player.get('karma', 0) >= 0 else self.colors['red'] + karma_display = f"{karma_color}Karma:{player.get('karma', 0)}{self.colors['reset']}" + + stats_line2 = f"{nick} > {karma_display} | Accuracy: {player['accuracy']}% (effective: {effective_accuracy:.1f}%) | Reliability: {self.calculate_gun_reliability(player)}%" + + # Equipment line + weapon_name = player.get('weapon', 'pistol').replace('_', ' ').title() + stats_line3 = f"{nick} > Weapon: {weapon_name}{gun_status} | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}" + + # Advanced stats line + best_time = player.get('best_time', 999.9) + best_display = f"{best_time:.3f}s" if best_time < 999 else "none" + + stats_line4 = f"{nick} > Best time: {best_display} | Avg time: {average_time:.3f}s | Jams: {player.get('jammed_count', 0)} | Accidents: {player.get('accidents', 0)} | Lucky shots: {player.get('lucky_shots', 0)}" + + # Send all stats + await self.send_user_message(nick, channel, stats_line1) + await self.send_user_message(nick, channel, stats_line2) + await self.send_user_message(nick, channel, stats_line3) + await self.send_user_message(nick, channel, stats_line4) + + async def handle_rearm(self, nick, channel, user, target_nick): + """Rearm a player whose gun was confiscated""" + player = self.get_player(user) + target_nick_lower = target_nick.lower() + + if not player: + self.send_message(channel, f"{nick} > Player data not found!") + return + + # Check if target exists + if target_nick_lower not in self.players: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + return + + target_player = self.players[target_nick_lower] + + # Check if target's gun is confiscated + if not target_player.get('gun_confiscated', False): + self.send_message(channel, f"{nick} > {target_nick}'s gun is not confiscated!") + return + + # Admins can rearm anyone for free + is_admin = self.is_admin(user) + + if is_admin: + # Admin rearm - no cost + target_player['gun_confiscated'] = False + target_player['ammo'] = target_player['max_ammo'] # Full ammo when rearmed + if target_nick_lower == nick.lower(): + self.send_message(channel, f"{nick} > {self.colors['green']}Admin command: Gun restored with full ammo.{self.colors['reset']}") + else: + self.send_message(channel, f"{nick} > {self.colors['green']}Admin command: {target_nick}'s gun restored with full ammo.{self.colors['reset']}") + self.save_database() + elif target_nick_lower == nick.lower(): + # Regular player rearming self - costs XP + rearm_cost = 40 + if player['xp'] < rearm_cost: + self.send_message(channel, f"{nick} > You need {rearm_cost} XP to rearm yourself (you have {player['xp']} XP)") + return + + player['xp'] -= rearm_cost + player['gun_confiscated'] = False + player['ammo'] = player['max_ammo'] # Full ammo when rearmed + self.send_message(channel, f"{nick} > {self.colors['green']}You rearmed yourself! [-{rearm_cost} XP] Gun restored with full ammo.{self.colors['reset']}") + self.save_player(user) + else: + # Regular player rearming someone else - costs coins (friendly gesture) + rearm_cost_coins = 10 + current_coins = self.get_player_coins(player) + if current_coins < rearm_cost_coins: + self.send_message(channel, f"{nick} > You need {rearm_cost_coins} coins to rearm {target_nick} (you have {current_coins} coins)") + return + + self.deduct_player_coins(player, rearm_cost_coins) + target_player['gun_confiscated'] = False + target_player['ammo'] = target_player['max_ammo'] # Full ammo when rearmed + self.send_message(channel, f"{nick} > {self.colors['green']}You rearmed {target_nick}! [-{rearm_cost_coins} coins] {target_nick}'s gun restored with full ammo.{self.colors['reset']}") + self.save_player(user) + self.save_database() + + async def handle_help(self, nick, channel): + help_lines = [ + f"{nick} > {self.colors['cyan']}๐Ÿฆ† DUCKHUNT ๐Ÿฆ†{self.colors['reset']} !bang !bef !reload !stats !top !shop !buy !trade ", + f"{nick} > {self.colors['yellow']}Golden ducks: 50 XP{self.colors['reset']} | {self.colors['red']}Gun jamming & ricochets ON{self.colors['reset']} | Timeout: {self.duck_timeout_min}-{self.duck_timeout_max}s" + ] + if self.is_admin(f"{nick}!*@*"): # Check if admin + help_lines.append(f"{nick} > {self.colors['red']}Admin:{self.colors['reset']} !duck !golden !ban !reset !resetdb !rearm !setcoins !giveitem !givexp | /msg {self.config['nick']} restart|quit") + for line in help_lines: + self.send_message(channel, line) + + async def handle_output(self, nick, channel, user, output_type): + """Handle output mode setting (PRIVMSG or NOTICE)""" + player = self.get_player(user) + if not player: + self.send_message(channel, f"{nick} > Player data not found!") + return + + # Ensure player has settings (for existing players) + if 'settings' not in player: + player['settings'] = { + 'notices': True + } + + output_type = output_type.upper() + + if output_type == 'PRIVMSG': + player['settings']['notices'] = False + self.save_database() + self.send_message(channel, f"{nick} > Output mode set to {self.colors['cyan']}PRIVMSG{self.colors['reset']} (private messages)") + + elif output_type == 'NOTICE': + player['settings']['notices'] = True + self.save_database() + self.send_message(channel, f"{nick} > Output mode set to {self.colors['cyan']}NOTICE{self.colors['reset']} (channel notices)") + + else: + current_mode = 'NOTICE' if player['settings']['notices'] else 'PRIVMSG' + self.send_message(channel, f"{nick} > Current output mode: {self.colors['cyan']}{current_mode}{self.colors['reset']} | Usage: !output PRIVMSG or !output NOTICE") + + async def handle_shop(self, nick, channel, user): + player = self.get_player(user) + if not player: + self.send_message(channel, f"{nick} > Player data not found!") + return + + # Show compact shop in eggdrop style + shop_msg = f"[Duck Hunt] Purchasable items: 1-Extra bullet(7xp) 2-Extra clip(20xp) 3-AP ammo(15xp) 4-Explosive ammo(25xp) 5-Repurchase gun(40xp) 6-Grease(8xp) 7-Sight(6xp) 8-Infrared detector(15xp) 9-Silencer(5xp) 10-Four-leaf clover(13xp) 11-Shotgun(100xp) 12-Assault rifle(200xp) 13-Sniper rifle(350xp) 14-Auto shotgun(500xp) 15-Sand(7xp) 16-Water bucket(10xp) 17-Sabotage(14xp) 18-Life insurance(10xp) 19-Liability insurance(5xp) 20-Decoy(80xp) 21-Bread(50xp) 22-Duck detector(50xp) 23-Mechanical duck(50xp) | Syntax: !shop [id [target]]" + self.send_message(channel, f"{nick} > {shop_msg}") + self.send_message(channel, f"{nick} > Your XP: {player['xp']} | Use !buy to purchase") + + async def handle_buy(self, nick, channel, item, user): + player = self.get_player(user) + if not player: + self.send_message(channel, f"{nick} > Player data not found!") + return + + # Eggdrop-style shop items with XP costs + shop_items = { + '1': {'name': 'Extra bullet', 'cost': 7, 'effect': 'ammo'}, + '2': {'name': 'Extra clip', 'cost': 20, 'effect': 'max_ammo'}, + '3': {'name': 'AP ammo', 'cost': 15, 'effect': 'accuracy'}, + '4': {'name': 'Explosive ammo', 'cost': 25, 'effect': 'explosive'}, + '5': {'name': 'Repurchase confiscated gun', 'cost': 40, 'effect': 'gun'}, + '6': {'name': 'Grease', 'cost': 8, 'effect': 'reliability'}, + '7': {'name': 'Sight', 'cost': 6, 'effect': 'accuracy'}, + '8': {'name': 'Infrared detector', 'cost': 15, 'effect': 'detector'}, + '9': {'name': 'Silencer', 'cost': 5, 'effect': 'silencer'}, + '10': {'name': 'Four-leaf clover', 'cost': 13, 'effect': 'luck'}, + '11': {'name': 'Shotgun', 'cost': 100, 'effect': 'shotgun'}, + '12': {'name': 'Assault rifle', 'cost': 200, 'effect': 'rifle'}, + '13': {'name': 'Sniper rifle', 'cost': 350, 'effect': 'sniper'}, + '14': {'name': 'Automatic shotgun', 'cost': 500, 'effect': 'auto_shotgun'}, + '15': {'name': 'Handful of sand', 'cost': 7, 'effect': 'sand'}, + '16': {'name': 'Water bucket', 'cost': 10, 'effect': 'water'}, + '17': {'name': 'Sabotage', 'cost': 14, 'effect': 'sabotage'}, + '18': {'name': 'Life insurance', 'cost': 10, 'effect': 'life_insurance'}, + '19': {'name': 'Liability insurance', 'cost': 5, 'effect': 'liability'}, + '20': {'name': 'Decoy', 'cost': 80, 'effect': 'decoy'}, + '21': {'name': 'Piece of bread', 'cost': 50, 'effect': 'bread'}, + '22': {'name': 'Ducks detector', 'cost': 50, 'effect': 'duck_detector'}, + '23': {'name': 'Mechanical duck', 'cost': 50, 'effect': 'mechanical'} + } + + if item not in shop_items: + self.send_message(channel, f"{nick} > Invalid item ID. Use !shop to see available items.") + return + + shop_item = shop_items[item] + cost = shop_item['cost'] + + if player['xp'] < cost: + self.send_message(channel, f"{nick} > Not enough XP! You need {cost} XP but only have {player['xp']}.") + return + + # Purchase the item + player['xp'] -= cost + effect = shop_item['effect'] + + # Apply item effects + if effect == 'ammo': + player['ammo'] = min(player['max_ammo'], player['ammo'] + 1) + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +1 ammo") + elif effect == 'max_ammo': + player['max_ammo'] += 2 + player['ammo'] = player['max_ammo'] # Fill ammo + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +2 max ammo") + elif effect == 'accuracy': + player['accuracy'] = min(95, player['accuracy'] + 3) + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +3% accuracy") + elif effect == 'explosive': + player['explosive_ammo'] = True + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Explosive rounds loaded") + elif effect == 'gun': + player['gun_confiscated'] = False + player['jammed'] = False + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Gun restored and loaded") + elif effect == 'reliability': + player['reliability'] = min(95, player['reliability'] + 5) + player['grease'] = player.get('grease', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +5% reliability") + elif effect == 'detector': + player['detector'] = player.get('detector', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Infrared detection enabled") + elif effect == 'silencer': + player['silencer'] = player.get('silencer', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Silent shooting enabled") + elif effect == 'luck': + player['luck'] = player.get('luck', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +1 luck point") + elif effect == 'shotgun': + player['weapon'] = 'shotgun' + player['accuracy'] = min(95, player['accuracy'] + 10) + player['max_ammo'] = max(player['max_ammo'], 8) + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +10% accuracy, 8 ammo capacity") + elif effect == 'rifle': + player['weapon'] = 'rifle' + player['accuracy'] = min(95, player['accuracy'] + 15) + player['max_ammo'] = max(player['max_ammo'], 12) + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +15% accuracy, 12 ammo capacity") + elif effect == 'sniper': + player['weapon'] = 'sniper' + player['accuracy'] = min(95, player['accuracy'] + 25) + player['max_ammo'] = max(player['max_ammo'], 6) + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +25% accuracy, 6 ammo capacity") + elif effect == 'auto_shotgun': + player['weapon'] = 'auto_shotgun' + player['accuracy'] = min(95, player['accuracy'] + 20) + player['max_ammo'] = max(player['max_ammo'], 15) + player['ammo'] = player['max_ammo'] + player['explosive_ammo'] = True + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +20% accuracy, 15 ammo, explosive rounds!") + elif effect == 'sunglasses': + player['sunglasses'] = player.get('sunglasses', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +5% accuracy in bright conditions") + elif effect == 'clothes': + player['clothes'] = player.get('clothes', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Backup outfit equipped") + elif effect == 'brush': + player['reliability'] = min(95, player['reliability'] + 3) + player['brush'] = player.get('brush', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +3% reliability") + elif effect == 'mirror': + player['mirror'] = player.get('mirror', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +3% accuracy") + elif effect == 'sand': + player['sand'] = player.get('sand', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Pocket sand ready") + elif effect == 'water': + player['water'] = player.get('water', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Fire extinguisher ready") + elif effect == 'sabotage': + player['sabotage'] = player.get('sabotage', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Sabotage kit ready") + elif effect == 'life_insurance': + player['life_insurance'] = player.get('life_insurance', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Death protection active") + elif effect == 'liability': + player['liability'] = player.get('liability', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Accident coverage active") + elif effect == 'decoy': + player['decoy'] = player.get('decoy', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Decoy duck ready") + elif effect == 'bread': + player['bread'] = player.get('bread', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Duck attractant ready") + elif effect == 'duck_detector': + player['duck_detector'] = player.get('duck_detector', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Advanced duck detection enabled") + elif effect == 'mechanical': + player['mechanical'] = player.get('mechanical', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Mechanical duck ready") + elif effect == 'water': + # Utility item + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Emergency water ready") + elif effect == 'sabotage': + # Offensive against others + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Sabotage kit ready") + elif effect == 'life_insurance': + # Protection from death + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Protected from consequences") + elif effect == 'liability': + # Protection from accidents + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Accident coverage active") + elif effect == 'decoy': + # Special duck interaction + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Duck decoy deployed") + elif effect == 'bread': + # Attract ducks + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Duck attraction increased") + elif effect == 'duck_detector': + # Advanced detection + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Enhanced duck detection") + elif effect == 'mechanical': + # Mechanical duck + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Robotic companion acquired") + + # Save to database after purchase + self.save_player(user) + + async def handle_trade(self, nick, channel, user, target_nick, item, amount): + """Trade items with other players""" + player = self.get_player(user) + if not player: + return + + try: + amount = int(amount) + except ValueError: + self.send_message(channel, f"{nick} > Amount must be a number!") + return + + if amount <= 0: + self.send_message(channel, f"{nick} > Amount must be positive!") + return + + if amount > 10000: # Prevent excessive amounts + self.send_message(channel, f"{nick} > Amount too large! Maximum: 10,000") + return + + # Find target player (simplified - would need to track online users in real implementation) + if item == 'coins': + current_coins = self.get_player_coins(player) + if current_coins < amount: + self.send_message(channel, f"{nick} > You don't have {amount} coins!") + return + self.deduct_player_coins(player, amount) + self.send_message(channel, f"{nick} > Offering {amount} coins to {target_nick}. They can !accept or !decline.") + # In real implementation, store pending trade + + elif item == 'ammo': + if player['ammo'] < amount: + self.send_message(channel, f"{nick} > You don't have {amount} ammo!") + return + self.send_message(channel, f"{nick} > Offering {amount} ammo to {target_nick}.") + + elif item == 'chargers': + if player['chargers'] < amount: + self.send_message(channel, f"{nick} > You don't have {amount} chargers!") + return + self.send_message(channel, f"{nick} > Offering {amount} chargers to {target_nick}.") + + else: + self.send_message(channel, f"{nick} > Can't trade '{item}'. Use: coins, ammo, or chargers") + + self.save_player(user) + + async def handle_steal(self, nick, channel, user, target_nick): + """Attempt to steal from another player""" + player = self.get_player(user) + if not player: + return + + # Cooldown check (simplified) + steal_chance = random.randint(1, 100) + + if steal_chance <= 30: # 30% success rate + stolen_coins = random.randint(1, 10) + self.add_player_coins(player, stolen_coins) + player['xp'] -= 2 # Penalty for stealing + self.send_message(channel, f"{nick} > You successfully stole {stolen_coins} coins from {target_nick}! [-2 xp for being a thief]") + else: + penalty = random.randint(5, 15) + self.deduct_player_coins(player, penalty) + player['xp'] -= 5 + self.send_message(channel, f"{nick} > You got caught stealing! Lost {penalty} coins and 5 xp!") + + self.save_player(user) + + async def handle_give(self, nick, channel, user, target_nick, item, amount): + """Give items to other players""" + player = self.get_player(user) + if not player: + return + + try: + amount = int(amount) + except ValueError: + self.send_message(channel, f"{nick} > Amount must be a number!") + return + + if amount <= 0: + self.send_message(channel, f"{nick} > Amount must be positive!") + return + + if item == 'coins': + current_coins = self.get_player_coins(player) + if current_coins < amount: + self.send_message(channel, f"{nick} > You don't have {amount} coins!") + return + self.deduct_player_coins(player, amount) + self.send_message(channel, f"{nick} > Gave {amount} coins to {target_nick}! [+1 xp for generosity]") + player['xp'] += 1 + + elif item == 'ammo': + if player['ammo'] < amount: + self.send_message(channel, f"{nick} > You don't have {amount} ammo!") + return + player['ammo'] -= amount + self.send_message(channel, f"{nick} > Gave {amount} ammo to {target_nick}!") + + elif item == 'chargers': + if player['chargers'] < amount: + self.send_message(channel, f"{nick} > You don't have {amount} chargers!") + return + player['chargers'] -= amount + self.send_message(channel, f"{nick} > Gave {amount} chargers to {target_nick}!") + + else: + self.send_message(channel, f"{nick} > Can't give '{item}'. Use: coins, ammo, or chargers") + + self.save_player(user) + + async def handle_listplayers(self, nick, channel): + """Admin command to list all players""" + if not self.players: + self.send_message(channel, f"{nick} > No players in database.") + return + + player_list = [] + for nick_key, data in self.players.items(): + shot_count = data['caught'] + befriended_count = data.get('befriended', 0) + total_ducks = shot_count + befriended_count + player_list.append(f"{nick_key}(Ducks:{total_ducks},Shot:{shot_count},Befriended:{befriended_count},Coins:{data['coins']})") + + players_str = " | ".join(player_list[:10]) # Limit to first 10 + if len(self.players) > 10: + players_str += f" ... and {len(self.players) - 10} more" + + self.send_message(channel, f"{nick} > Players: {players_str}") + + async def handle_setcoins(self, nick, channel, target_nick, amount): + """Admin command to set player's coins""" + try: + amount = int(amount) + except ValueError: + self.send_message(channel, f"{nick} > Amount must be a number!") + return + + target_nick_lower = target_nick.lower() + if target_nick_lower in self.players: + self.players[target_nick_lower]['coins'] = amount + self.send_message(channel, f"{nick} > Set {target_nick}'s coins to {amount}") + self.save_database() + else: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + + async def handle_ban(self, nick, channel, target_nick): + """Admin command to ban a player""" + target_nick_lower = target_nick.lower() + if target_nick_lower in self.players: + del self.players[target_nick_lower] + self.send_message(channel, f"{nick} > Banned and reset {target_nick}") + self.save_database() + else: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + + async def handle_reset(self, nick, channel, target_nick): + """Admin command to reset a player's stats""" + target_nick_lower = target_nick.lower() + if target_nick_lower in self.players: + # Reset to defaults + self.players[target_nick_lower] = { + 'coins': 100, 'caught': 0, 'ammo': 10, 'max_ammo': 10, + 'chargers': 2, 'max_chargers': 2, 'xp': 0, + 'accuracy': 85, 'reliability': 90, 'gun_level': 1, + 'luck': 0, 'gun_type': 'pistol' + } + self.send_message(channel, f"{nick} > Reset {target_nick}'s stats to defaults") + self.save_database() + else: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + + async def handle_reset_database(self, nick, channel, user): + """Admin command to reset entire database - requires confirmation""" + self.send_message(channel, f"{nick} > {self.colors['red']}โš ๏ธ DATABASE RESET WARNING โš ๏ธ{self.colors['reset']}") + self.send_message(channel, f"{nick} > This will DELETE ALL player data, statistics, and progress!") + self.send_message(channel, f"{nick} > {self.colors['yellow']}Players affected: {len(self.players)}{self.colors['reset']}") + self.send_message(channel, f"{nick} > To confirm, type: {self.colors['cyan']}!resetdb confirm DESTROY_ALL_DATA{self.colors['reset']}") + self.send_message(channel, f"{nick} > {self.colors['red']}This action CANNOT be undone!{self.colors['reset']}") + + async def handle_reset_database_confirm(self, nick, channel, user, confirmation): + """Confirm and execute database reset""" + if confirmation != "DESTROY_ALL_DATA": + self.send_message(channel, f"{nick} > {self.colors['red']}Incorrect confirmation code. Database reset cancelled.{self.colors['reset']}") + return + + # Log the reset action + self.logger.warning(f"DATABASE RESET initiated by admin {nick} - All player data will be destroyed") + + # Backup current database + import shutil + backup_name = f"duckhunt_backup_{int(time.time())}.json" + try: + shutil.copy2(self.db_file, backup_name) + self.send_message(channel, f"{nick} > {self.colors['cyan']}Database backed up to: {backup_name}{self.colors['reset']}") + except Exception as e: + self.logger.error(f"Failed to create backup: {e}") + self.send_message(channel, f"{nick} > {self.colors['red']}Warning: Could not create backup!{self.colors['reset']}") + + # Clear all data + player_count = len(self.players) + self.players.clear() + self.ducks.clear() + self.ignored_nicks.clear() + + # Save empty database + self.save_database() + + # Confirmation messages + self.send_message(channel, f"{nick} > {self.colors['green']}โœ… DATABASE RESET COMPLETE{self.colors['reset']}") + self.send_message(channel, f"{nick} > {self.colors['yellow']}{player_count} player records deleted{self.colors['reset']}") + self.send_message(channel, f"{nick} > All ducks cleared, fresh start initiated") + self.logger.warning(f"Database reset completed by {nick} - {player_count} players deleted") + + async def handle_restart(self, nick, channel): + """Admin command to restart the bot""" + self.send_message(channel, f"{nick} > Restarting bot...") + self.logger.info(f"Bot restart requested by {nick}") + + # Close connections gracefully + if self.writer: + self.writer.close() + await self.writer.wait_closed() + + # Save any pending data + self.save_database() + + # Restart the Python process + self.logger.info("Restarting Python process...") + python = sys.executable + script = sys.argv[0] + args = sys.argv[1:] + + # Use subprocess to restart + subprocess.Popen([python, script] + args) + + # Exit current process + sys.exit(0) + + async def handle_quit(self, nick, channel): + """Admin command to quit the bot""" + self.send_message(channel, f"{nick} > Shutting down bot...") + self.logger.info(f"Bot shutdown requested by {nick}") + # Close connections gracefully + if self.writer: + self.writer.close() + await self.writer.wait_closed() + # Exit with code 0 for normal shutdown + import sys + sys.exit(0) + + async def handle_ignore(self, nick, channel, target_nick): + """Admin command to ignore a user""" + if target_nick in self.ignored_nicks: + self.send_message(channel, f"{nick} > {target_nick} is already ignored!") + return + + self.ignored_nicks.add(target_nick) + self.send_message(channel, f"{nick} > Now ignoring {target_nick}. Total ignored: {len(self.ignored_nicks)}") + self.logger.info(f"{nick} added {target_nick} to ignore list") + + async def handle_delignore(self, nick, channel, target_nick): + """Admin command to stop ignoring a user""" + if target_nick not in self.ignored_nicks: + self.send_message(channel, f"{nick} > {target_nick} is not ignored!") + return + + self.ignored_nicks.remove(target_nick) + self.send_message(channel, f"{nick} > No longer ignoring {target_nick}. Total ignored: {len(self.ignored_nicks)}") + self.logger.info(f"{nick} removed {target_nick} from ignore list") + + async def handle_admin_giveitem(self, nick, channel, target_nick, item): + """Admin command to give an item to a player""" + target_nick_lower = target_nick.lower() + + # Check if target exists + if target_nick_lower not in self.players: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + return + + # Shop items reference for item names + shop_items = { + '1': {'name': 'Extra bullet', 'effect': 'ammo'}, + '2': {'name': 'Extra clip', 'effect': 'max_ammo'}, + '3': {'name': 'AP ammo', 'effect': 'accuracy'}, + '4': {'name': 'Explosive ammo', 'effect': 'explosive'}, + '5': {'name': 'Repurchase confiscated gun', 'effect': 'gun'}, + '6': {'name': 'Grease', 'effect': 'reliability'}, + '7': {'name': 'Sight', 'effect': 'accuracy'}, + '8': {'name': 'Infrared detector', 'effect': 'detector'}, + '9': {'name': 'Silencer', 'effect': 'silencer'}, + '10': {'name': 'Four-leaf clover', 'effect': 'luck'}, + '11': {'name': 'Sunglasses', 'effect': 'sunglasses'}, + '12': {'name': 'Spare clothes', 'effect': 'clothes'}, + '13': {'name': 'Brush for gun', 'effect': 'brush'}, + '14': {'name': 'Mirror', 'effect': 'mirror'}, + '15': {'name': 'Handful of sand', 'effect': 'sand'}, + '16': {'name': 'Water bucket', 'effect': 'water'}, + '17': {'name': 'Sabotage', 'effect': 'sabotage'}, + '18': {'name': 'Life insurance', 'effect': 'life_insurance'}, + '19': {'name': 'Liability insurance', 'effect': 'liability'}, + '20': {'name': 'Decoy', 'effect': 'decoy'}, + '21': {'name': 'Piece of bread', 'effect': 'bread'}, + '22': {'name': 'Ducks detector', 'effect': 'duck_detector'}, + '23': {'name': 'Mechanical duck', 'effect': 'mechanical'} + } + + if item not in shop_items: + self.send_message(channel, f"{nick} > Invalid item ID '{item}'. Use item IDs 1-23.") + return + + target_player = self.players[target_nick_lower] + shop_item = shop_items[item] + effect = shop_item['effect'] + + # Apply the item effect + if effect == 'ammo': + target_player['ammo'] = min(target_player['ammo'] + 1, target_player['max_ammo']) + elif effect == 'max_ammo': + target_player['max_ammo'] += 1 + target_player['ammo'] = target_player['max_ammo'] # Fill ammo + elif effect == 'accuracy': + target_player['accuracy'] = min(target_player['accuracy'] + 5, 100) + elif effect == 'explosive': + target_player['explosive_ammo'] = True + elif effect == 'gun': + target_player['gun_confiscated'] = False + target_player['ammo'] = target_player['max_ammo'] + elif effect == 'reliability': + target_player['reliability'] = min(target_player['reliability'] + 5, 100) + elif effect == 'luck': + target_player['luck'] = target_player.get('luck', 0) + 1 + # Add other effects as needed + + self.send_message(channel, f"{nick} > {self.colors['green']}Gave {shop_item['name']} to {target_nick}!{self.colors['reset']}") + self.save_database() + + async def handle_admin_givexp(self, nick, channel, target_nick, amount): + """Admin command to give XP to a player""" + target_nick_lower = target_nick.lower() + + # Check if target exists + if target_nick_lower not in self.players: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + return + + try: + xp_amount = int(amount) + except ValueError: + self.send_message(channel, f"{nick} > Amount must be a number!") + return + + if abs(xp_amount) > 50000: # Prevent excessive XP changes + self.send_message(channel, f"{nick} > XP amount too large! Maximum: ยฑ50,000") + return + + target_player = self.players[target_nick_lower] + old_xp = target_player['xp'] + target_player['xp'] = max(0, target_player['xp'] + xp_amount) # Prevent negative XP + + color = self.colors['green'] if xp_amount >= 0 else self.colors['red'] + sign = '+' if xp_amount >= 0 else '' + self.send_message(channel, f"{nick} > {color}Gave {sign}{xp_amount} XP to {target_nick}! (Total: {target_player['xp']} XP){self.colors['reset']}") + self.save_database() + + def get_duck_spawn_message(self): + """Get a random duck spawn message with different types""" + duck_types = [ + {"msg": "-.,ยธยธ.-ยทยฐ'`'ยฐยท-.,ยธยธ.-ยทยฐ'`'ยฐยท \\_O< QUACK", "type": "normal"}, # Normal duck + {"msg": "-._..-'`'ยฐ-,_,.-'`'ยฐ-,_,.-'`'ยฐ-,_,.-ยฐ \\_o< A duck waddles by! QUACK QUACK", "type": "normal"}, # Waddling duck + {"msg": "~~~ยฐ*ยฐ~~~ยฐ*ยฐ~~~ยฐ*ยฐ~~~ \\_O< SPLASH! A duck lands in the water! QUACK!", "type": "normal"}, # Water duck + {"msg": "***GOLDEN*** \\_O< *** A golden duck appears! *** QUACK QUACK! ***GOLDEN***", "type": "golden"}, # Golden duck (rare) + {"msg": "ยฐ~ยฐ*ยฐ~ยฐ*ยฐ~ยฐ \\_o< Brrr! A winter duck appears! QUACK!", "type": "normal"}, # Winter duck + {"msg": ".,ยธยธ.-ยทยฐ'`'ยฐยท-.,ยธยธ.-ยทยฐ'`'ยฐยท \\_O< A spring duck blooms into view! QUACK!", "type": "normal"}, # Spring duck + {"msg": "***ZAP*** \\_O< BZZT! An electric duck sparks to life! QUACK! ***ZAP***", "type": "normal"}, # Electric duck + {"msg": "~*~*~*~ \\_o< A sleepy night duck appears... *yawn* quack...", "type": "normal"}, # Night duck + ] + + # Golden duck is rare (5% chance) + if random.random() < 0.05: + golden_duck = [d for d in duck_types if d["type"] == "golden"][0] + return golden_duck + else: + # Choose from normal duck types + normal_ducks = [d for d in duck_types if d["type"] == "normal"] + return random.choice(normal_ducks) + + async def spawn_duck_now(self, channel, force_golden=False): + """Admin command to spawn a duck immediately""" + # Create duck with unique ID and type + duck_id = str(uuid.uuid4())[:8] # Short ID for easier tracking + + if force_golden: + # Force spawn a golden duck + duck_info = { + "msg": f"{self.colors['yellow']}***GOLDEN***{self.colors['reset']} \\_$< {self.colors['yellow']}*** A golden duck appears! ***{self.colors['reset']} QUACK QUACK! {self.colors['yellow']}***GOLDEN***{self.colors['reset']}", + "type": "golden" + } + else: + duck_info = self.get_duck_spawn_message() + + duck_timeout = random.randint(self.duck_timeout_min, self.duck_timeout_max) + duck = { + 'alive': True, + 'spawn_time': time.time(), + 'id': duck_id, + 'type': duck_info['type'], + 'message': duck_info['msg'], + 'timeout': duck_timeout + } + + # Initialize channel duck list if needed + if channel not in self.ducks: + self.ducks[channel] = [] + + # Add duck to channel + self.ducks[channel].append(duck) + + # Send spawn message + self.send_message(channel, duck_info['msg']) + self.logger.info(f"Admin spawned {duck_info['type']} duck {duck_id} in {channel}") + return True + return True # Return True to indicate duck was spawned + + async def spawn_ducks(self): + # Spawn first duck immediately after joining + await asyncio.sleep(5) # Brief delay for players to see the bot joined + for channel in self.channels_joined: + await self.spawn_duck_now(channel) + + # Start duck timeout checker + asyncio.create_task(self.duck_timeout_checker()) + + while not self.shutdown_requested: + wait_time = random.randint(self.duck_spawn_min, self.duck_spawn_max) + self.logger.info(f"Waiting {wait_time//60}m {wait_time%60}s for next duck") + + # Sleep in chunks to check shutdown flag + for _ in range(wait_time): + if self.shutdown_requested: + self.logger.info("Duck spawning stopped due to shutdown request") + return + await asyncio.sleep(1) + + # Spawn only one duck per channel if no alive ducks exist + for channel in self.channels_joined: + if self.shutdown_requested: + return + + # Check if there are any alive ducks in this channel + channel_ducks = self.ducks.get(channel, []) + alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] + + # Only spawn if no ducks are alive (one duck at a time naturally) + if not alive_ducks: + await self.spawn_duck_now(channel) + break # Only spawn in the first available channel + + async def duck_timeout_checker(self): + """Remove ducks that have been around too long""" + while not self.shutdown_requested: + await asyncio.sleep(10) # Check every 10 seconds + current_time = time.time() + + for channel in list(self.ducks.keys()): + if channel in self.ducks: + ducks_to_remove = [] + for i, duck in enumerate(self.ducks[channel]): + duck_timeout = duck.get('timeout', 60) # Use individual timeout or default to 60 + if duck['alive'] and (current_time - duck['spawn_time']) > duck_timeout: + # Duck wandered off + ducks_to_remove.append(i) + self.send_message(channel, f"A duck wandered off... *quack quack* (timeout after {duck_timeout}s)") + self.logger.info(f"Duck {duck['id']} timed out in {channel}") + + # Remove timed out ducks (in reverse order to maintain indices) + for i in reversed(ducks_to_remove): + del self.ducks[channel][i] + + async def listen(self): + """Listen for IRC messages with shutdown handling""" + while not self.shutdown_requested: + try: + if not self.reader: + self.logger.error("No reader available") + break + + # Use timeout to allow checking shutdown flag + try: + line = await asyncio.wait_for(self.reader.readline(), timeout=1.0) + except asyncio.TimeoutError: + continue # Check shutdown flag + + if not line: + self.logger.warning("Connection closed by server") + break + + line = line.decode(errors='ignore').strip() + if not line: + continue + + self.logger.debug(f"<- {line}") + + if line.startswith('PING'): + self.send_raw('PONG ' + line.split()[1]) + continue + + prefix, command, params, trailing = parse_message(line) + + except Exception as e: + self.logger.error(f"Error in listen loop: {e}") + await asyncio.sleep(1) # Brief pause before retry + continue + + # Handle SASL authentication responses + if command == 'CAP': + if len(params) >= 2 and params[1] == 'LS': + # Check if SASL is available + caps = trailing.split() if trailing else [] + if 'sasl' in caps: + self.logger.info("SASL capability available") + self.send_raw('CAP REQ :sasl') + else: + self.logger.warning("SASL not available, proceeding without authentication") + self.send_raw('CAP END') + await self.register_user() + elif len(params) >= 2 and params[1] == 'ACK': + # SASL capability acknowledged + if 'sasl' in trailing: + self.logger.info("SASL capability acknowledged") + await self.handle_sasl_auth() + elif len(params) >= 2 and params[1] == 'NAK': + # SASL capability not acknowledged + self.logger.warning("SASL capability denied, proceeding without authentication") + self.send_raw('CAP END') + await self.register_user() + + elif command == 'AUTHENTICATE': + if params and params[0] == '+': + # Server is ready for authentication + self.logger.info("Server ready for SASL authentication") + + elif command == '903': # SASL auth successful + self.sasl_authenticated = True + self.logger.info("SASL authentication successful!") + self.send_raw('CAP END') + await self.register_user() + + elif command == '904': # SASL auth failed + self.logger.error("SASL authentication failed! (904 - Invalid credentials or account not found)") + self.logger.info("Falling back to NickServ identification...") + self.logger.error(f"Attempted username: {self.config.get('sasl', {}).get('username', 'N/A')}") + self.send_raw('CAP END') + await self.register_user() + # Will attempt NickServ auth after registration + + elif command == '905': # SASL auth too long + self.logger.error("SASL authentication string too long! (905)") + self.logger.info("Falling back to NickServ identification...") + self.send_raw('CAP END') + await self.register_user() + + elif command == '906': # SASL auth aborted + self.logger.error("SASL authentication aborted! (906)") + self.logger.info("Falling back to NickServ identification...") + self.send_raw('CAP END') + await self.register_user() + + elif command == '907': # SASL auth already completed + self.logger.info("SASL authentication already completed") + self.send_raw('CAP END') + await self.register_user() + + elif command == '001': # Welcome + self.registered = True + auth_status = " (SASL authenticated)" if self.sasl_authenticated else "" + self.logger.info(f"Successfully registered!{auth_status}") + + # If SASL failed, try NickServ identification + if not self.sasl_authenticated: + await self.attempt_nickserv_auth() + + for chan in self.config['channels']: + self.logger.info(f"Joining {chan}") + self.send_raw(f'JOIN {chan}') + + elif command == 'JOIN' and prefix and prefix.startswith(self.config['nick']): + channel = trailing or (params[0] if params else '') + if channel: + self.channels_joined.add(channel) + self.logger.info(f"Successfully joined {channel}") + + elif command == 'PRIVMSG' and trailing: + target = params[0] if params else '' + sender = prefix.split('!')[0] if prefix else '' + + # Handle NickServ responses + if sender.lower() == 'nickserv': + await self.handle_nickserv_response(trailing) + elif trailing == 'VERSION': + self.send_raw(f'NOTICE {sender} :VERSION DuckHunt Bot v1.0') + else: + await self.handle_command(prefix, target, trailing) + + async def cleanup(self): + """Enhanced cleanup with graceful shutdown""" + self.logger.info("Starting cleanup process...") + + try: + # Cancel all running tasks + for task in self.running_tasks.copy(): + if not task.done(): + self.logger.debug(f"Cancelling task: {task.get_name()}") + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception as e: + self.logger.error(f"Error cancelling task: {e}") + + # Send goodbye message to all channels + if self.writer and not self.writer.is_closing(): + for channel in self.channels_joined: + self.send_message(channel, "๐Ÿฆ† DuckHunt Bot shutting down. Thanks for playing! ๐Ÿฆ†") + await asyncio.sleep(0.1) # Brief delay between messages + + self.send_raw('QUIT :DuckHunt Bot shutting down gracefully') + await asyncio.sleep(1.0) # Give time for QUIT and messages to send + + self.writer.close() + await self.writer.wait_closed() + self.logger.info("IRC connection closed") + + # Final database save with verification + self.save_database() + self.logger.info(f"Final database save completed - {len(self.players)} players saved") + + # Clear in-memory data + self.players.clear() + self.ducks.clear() + self.command_cooldowns.clear() + + self.logger.info("Cleanup completed successfully") + + except Exception as e: + self.logger.error(f"Error during cleanup: {e}") + import traceback + traceback.print_exc() + + async def run(self): + """Main bot entry point with enhanced shutdown handling""" + try: + # Setup signal handlers + self.setup_signal_handlers() + + self.logger.info("Starting DuckHunt Bot...") + self.load_database() + await self.connect() + + # Create and track main tasks + listen_task = asyncio.create_task(self.listen(), name="listen") + duck_task = asyncio.create_task(self.wait_and_spawn_ducks(), name="duck_spawner") + + self.running_tasks.add(listen_task) + self.running_tasks.add(duck_task) + + # Main execution loop with shutdown monitoring + done, pending = await asyncio.wait( + [listen_task, duck_task], + return_when=asyncio.FIRST_COMPLETED + ) + + # If we get here, one task completed (likely due to error or shutdown) + if self.shutdown_requested: + self.logger.info("Shutdown requested, stopping all tasks...") + else: + self.logger.warning("A main task completed unexpectedly") + + # Cancel remaining tasks + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + except KeyboardInterrupt: + self.logger.info("Keyboard interrupt received") + self.shutdown_requested = True + except Exception as e: + self.logger.error(f"Fatal error in main loop: {e}") + import traceback + traceback.print_exc() + finally: + await self.cleanup() + + async def wait_and_spawn_ducks(self): + """Duck spawning with shutdown handling""" + # Wait for registration and channel joins + while not self.registered or not self.channels_joined and not self.shutdown_requested: + await asyncio.sleep(1) + + if self.shutdown_requested: + return + + self.logger.info("Starting duck spawning...") + await self.spawn_ducks() + +def main(): + """Enhanced main entry point with better shutdown handling""" + bot = None + try: + # Load configuration + with open('config.json') as f: + config = json.load(f) + + # Create bot instance + bot = SimpleIRCBot(config) + bot.logger.info("DuckHunt Bot initializing...") + + # Run bot with graceful shutdown + try: + asyncio.run(bot.run()) + except KeyboardInterrupt: + bot.logger.info("Keyboard interrupt received in main") + except Exception as e: + bot.logger.error(f"Runtime error: {e}") + import traceback + traceback.print_exc() + + bot.logger.info("DuckHunt Bot shutdown complete") + + except KeyboardInterrupt: + print("\n๐Ÿฆ† DuckHunt Bot stopped by user") + except FileNotFoundError: + print("โŒ Error: config.json not found") + print("Please create a config.json file with your IRC server settings") + except json.JSONDecodeError as e: + print(f"โŒ Error: Invalid config.json - {e}") + print("Please check your config.json file syntax") + except Exception as e: + print(f"๐Ÿ’ฅ Unexpected error: {e}") + import traceback + traceback.print_exc() + finally: + # Ensure final message + print("๐Ÿฆ† Thanks for using DuckHunt Bot!") + +if __name__ == '__main__': + main() diff --git a/src/__pycache__/auth.cpython-312.pyc b/src/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..5d4ec72 Binary files /dev/null and b/src/__pycache__/auth.cpython-312.pyc differ diff --git a/src/__pycache__/db.cpython-312.pyc b/src/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000..bc7ff26 Binary files /dev/null and b/src/__pycache__/db.cpython-312.pyc differ diff --git a/src/__pycache__/duckhuntbot.cpython-312.pyc b/src/__pycache__/duckhuntbot.cpython-312.pyc new file mode 100644 index 0000000..707f459 Binary files /dev/null and b/src/__pycache__/duckhuntbot.cpython-312.pyc differ diff --git a/src/__pycache__/game.cpython-312.pyc b/src/__pycache__/game.cpython-312.pyc new file mode 100644 index 0000000..f55da69 Binary files /dev/null and b/src/__pycache__/game.cpython-312.pyc differ diff --git a/src/__pycache__/items.cpython-312.pyc b/src/__pycache__/items.cpython-312.pyc new file mode 100644 index 0000000..ba90264 Binary files /dev/null and b/src/__pycache__/items.cpython-312.pyc differ diff --git a/src/__pycache__/logging_utils.cpython-312.pyc b/src/__pycache__/logging_utils.cpython-312.pyc new file mode 100644 index 0000000..a5ce8b2 Binary files /dev/null and b/src/__pycache__/logging_utils.cpython-312.pyc differ diff --git a/src/__pycache__/utils.cpython-312.pyc b/src/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..aa982b7 Binary files /dev/null and b/src/__pycache__/utils.cpython-312.pyc differ diff --git a/src/auth.py b/src/auth.py new file mode 100644 index 0000000..5c78905 --- /dev/null +++ b/src/auth.py @@ -0,0 +1,60 @@ +import hashlib +import secrets +from typing import Optional +from src.db import DuckDB + +class AuthSystem: + def __init__(self, db: DuckDB): + self.db = db + self.authenticated_users = {} # nick -> account_name + self.pending_registrations = {} # nick -> temp_data + + def hash_password(self, password: str, salt: Optional[str] = None) -> tuple: + if salt is None: + salt = secrets.token_hex(16) + hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) + return hashed.hex(), salt + + def verify_password(self, password: str, hashed: str, salt: str) -> bool: + test_hash, _ = self.hash_password(password, salt) + return test_hash == hashed + + def register_account(self, username: str, password: str, nick: str, hostmask: str) -> bool: + # Check if account exists + existing = self.db.load_account(username) + if existing: + return False + + hashed_pw, salt = self.hash_password(password) + account_data = { + 'username': username, + 'password_hash': hashed_pw, + 'salt': salt, + 'primary_nick': nick, + 'hostmask': hostmask, + 'created_at': None, # Set by DB + 'auth_method': 'password' # 'password', 'nickserv', 'hostmask' + } + + self.db.save_account(username, account_data) + return True + + def authenticate(self, username: str, password: str, nick: str) -> bool: + account = self.db.load_account(username) + if not account: + return False + + if self.verify_password(password, account['password_hash'], account['salt']): + self.authenticated_users[nick] = username + return True + return False + + def get_account_for_nick(self, nick: str) -> str: + return self.authenticated_users.get(nick, "") + + def is_authenticated(self, nick: str) -> bool: + return nick in self.authenticated_users + + def logout(self, nick: str): + if nick in self.authenticated_users: + del self.authenticated_users[nick] diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..771b9af --- /dev/null +++ b/src/db.py @@ -0,0 +1,97 @@ +import sqlite3 +import json +import datetime + +class DuckDB: + def __init__(self, db_path='duckhunt.db'): + self.conn = sqlite3.connect(db_path) + self.create_tables() + + def create_tables(self): + with self.conn: + # Player data table + self.conn.execute('''CREATE TABLE IF NOT EXISTS players ( + nick TEXT PRIMARY KEY, + data TEXT + )''') + + # Account system table + self.conn.execute('''CREATE TABLE IF NOT EXISTS accounts ( + username TEXT PRIMARY KEY, + data TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )''') + + # Leaderboards table + self.conn.execute('''CREATE TABLE IF NOT EXISTS leaderboard ( + account TEXT, + stat_type TEXT, + value INTEGER, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (account, stat_type) + )''') + + # Trading table + self.conn.execute('''CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_account TEXT, + to_account TEXT, + trade_data TEXT, + status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )''') + + def save_player(self, nick, data): + with self.conn: + self.conn.execute('''INSERT OR REPLACE INTO players (nick, data) VALUES (?, ?)''', + (nick, json.dumps(data))) + + def load_player(self, nick): + cur = self.conn.cursor() + cur.execute('SELECT data FROM players WHERE nick=?', (nick,)) + row = cur.fetchone() + return json.loads(row[0]) if row else None + + def get_all_players(self): + cur = self.conn.cursor() + cur.execute('SELECT nick, data FROM players') + return {nick: json.loads(data) for nick, data in cur.fetchall()} + + def save_account(self, username, data): + with self.conn: + self.conn.execute('''INSERT OR REPLACE INTO accounts (username, data) VALUES (?, ?)''', + (username, json.dumps(data))) + + def load_account(self, username): + cur = self.conn.cursor() + cur.execute('SELECT data FROM accounts WHERE username=?', (username,)) + row = cur.fetchone() + return json.loads(row[0]) if row else None + + def update_leaderboard(self, account, stat_type, value): + with self.conn: + self.conn.execute('''INSERT OR REPLACE INTO leaderboard (account, stat_type, value) VALUES (?, ?, ?)''', + (account, stat_type, value)) + + def get_leaderboard(self, stat_type, limit=10): + cur = self.conn.cursor() + cur.execute('SELECT account, value FROM leaderboard WHERE stat_type=? ORDER BY value DESC LIMIT ?', + (stat_type, limit)) + return cur.fetchall() + + def save_trade(self, from_account, to_account, trade_data): + with self.conn: + cur = self.conn.cursor() + cur.execute('''INSERT INTO trades (from_account, to_account, trade_data) VALUES (?, ?, ?)''', + (from_account, to_account, json.dumps(trade_data))) + return cur.lastrowid + + def get_pending_trades(self, account): + cur = self.conn.cursor() + cur.execute('''SELECT id, from_account, trade_data FROM trades + WHERE to_account=? AND status='pending' ''', (account,)) + return [(trade_id, from_acc, json.loads(data)) for trade_id, from_acc, data in cur.fetchall()] + + def complete_trade(self, trade_id): + with self.conn: + self.conn.execute('UPDATE trades SET status=? WHERE id=?', ('completed', trade_id)) diff --git a/src/duckhuntbot.py b/src/duckhuntbot.py new file mode 100644 index 0000000..e69de29 diff --git a/src/game.py b/src/game.py new file mode 100644 index 0000000..ffd02f5 --- /dev/null +++ b/src/game.py @@ -0,0 +1,566 @@ +import asyncio +import random +from src.items import DuckTypes, WeaponTypes, AmmoTypes, Attachments +from src.auth import AuthSystem + +class DuckGame: + def __init__(self, bot, db): + self.bot = bot + self.config = bot.config + self.logger = getattr(bot, 'logger', None) + self.db = db + self.auth = AuthSystem(db) + self.duck_spawn_min = self.config.get('duck_spawn_min', 30) + self.duck_spawn_max = self.config.get('duck_spawn_max', 120) + self.ducks = {} # channel: duck dict or None + self.players = {} # nick: player dict + self.duck_alerts = set() # nicks who want duck alerts + + def get_player(self, nick): + if nick in self.players: + return self.players[nick] + data = self.db.load_player(nick) + if data: + data['friends'] = set(data.get('friends', [])) + self.players[nick] = data + return data + default = { + 'ammo': 1, 'max_ammo': 1, 'friends': set(), 'caught': 0, 'coins': 100, + 'accuracy': 70, 'reliability': 80, 'gun_oil': 0, 'scope': False, + 'silencer': False, 'lucky_charm': False, 'xp': 0, 'level': 1, + 'bank_account': 0, 'insurance': {'active': False, 'claims': 0}, + 'weapon': 'basic_gun', 'weapon_durability': 100, 'ammo_type': 'standard', + 'attachments': [], 'hunting_license': {'active': False, 'expires': None}, + 'duck_alerts': False, 'auth_method': 'nick' # 'nick', 'hostmask', 'account' + } + self.players[nick] = default + return default + + def save_player(self, nick, data): + self.players[nick] = data + data_to_save = dict(data) + data_to_save['friends'] = list(data_to_save.get('friends', [])) + self.db.save_player(nick, data_to_save) + + async def spawn_ducks_loop(self): + while True: + wait_time = random.randint(self.duck_spawn_min, self.duck_spawn_max) + if self.logger: + self.logger.info(f"Waiting {wait_time}s before next duck spawn.") + await asyncio.sleep(wait_time) + for chan in self.bot.channels: + duck = self.ducks.get(chan) + if not (duck and duck.get('alive')): + duck_type = DuckTypes.get_random_duck() + self.ducks[chan] = { + 'alive': True, + 'type': duck_type, + 'health': duck_type['health'], + 'max_health': duck_type['health'] + } + if self.logger: + self.logger.info(f"{duck_type['name']} spawned in {chan}") + + spawn_msg = f'\033[93m{duck_type["emoji"]} A {duck_type["name"]} appears! Type !bang, !catch, !bef, or !reload!\033[0m' + await self.bot.send_message(chan, spawn_msg) + + # Alert subscribed players + if self.duck_alerts: + alert_msg = f"๐Ÿฆ† DUCK ALERT: {duck_type['name']} in {chan}!" + for alert_nick in self.duck_alerts: + try: + await self.bot.send_message(alert_nick, alert_msg) + except: + pass # User might be offline + + async def handle_command(self, user, channel, message): + nick = user.split('!')[0] if user else 'unknown' + hostmask = user if user else 'unknown' + cmd = message.strip().lower() + if self.logger: + self.logger.info(f"{nick}@{channel}: {cmd}") + + # Handle private message commands + if channel == self.bot.nick: # Private message + if cmd.startswith('identify '): + parts = cmd.split(' ', 2) + if len(parts) == 3: + await self.handle_identify(nick, parts[1], parts[2]) + else: + await self.bot.send_message(nick, "Usage: identify ") + return + elif cmd == 'register': + await self.bot.send_message(nick, "To register: /msg me register ") + return + elif cmd.startswith('register '): + parts = cmd.split(' ', 2) + if len(parts) == 3: + await self.handle_register(nick, hostmask, parts[1], parts[2]) + else: + await self.bot.send_message(nick, "Usage: register ") + return + + # Public channel commands + if cmd == '!bang': + await self.handle_bang(nick, channel) + elif cmd == '!reload': + await self.handle_reload(nick, channel) + elif cmd == '!bef': + await self.handle_bef(nick, channel) + elif cmd == '!catch': + await self.handle_catch(nick, channel) + elif cmd == '!shop': + await self.handle_shop(nick, channel) + elif cmd == '!duckstats': + await self.handle_duckstats(nick, channel) + elif cmd.startswith('!buy '): + item_num = cmd.split(' ', 1)[1] + await self.handle_buy(nick, channel, item_num) + elif cmd.startswith('!sell '): + item_num = cmd.split(' ', 1)[1] + await self.handle_sell(nick, channel, item_num) + elif cmd == '!stats': + await self.handle_stats(nick, channel) + elif cmd == '!help': + await self.handle_help(nick, channel) + elif cmd == '!leaderboard' or cmd == '!top': + await self.handle_leaderboard(nick, channel) + elif cmd == '!bank': + await self.handle_bank(nick, channel) + elif cmd == '!license': + await self.handle_license(nick, channel) + elif cmd == '!alerts': + await self.handle_alerts(nick, channel) + elif cmd.startswith('!trade '): + parts = cmd.split(' ', 2) + if len(parts) >= 2: + await self.handle_trade(nick, channel, parts[1:]) + elif cmd.startswith('!sabotage '): + target = cmd.split(' ', 1)[1] + await self.handle_sabotage(nick, channel, target) + + async def handle_bang(self, nick, channel): + player = self.get_player(nick) + duck = self.ducks.get(channel) + if player['ammo'] <= 0: + await self.bot.send_message(channel, f'\033[91m{nick}, you need to !reload!\033[0m') + return + if duck and duck.get('alive'): + player['ammo'] -= 1 + + # Calculate hit chance based on accuracy and upgrades + base_accuracy = player['accuracy'] + if player['scope']: + base_accuracy += 15 + if player['lucky_charm']: + base_accuracy += 10 + + hit_roll = random.randint(1, 100) + if hit_roll <= base_accuracy: + player['caught'] += 1 + coins_earned = 1 + if player['silencer']: + coins_earned += 1 # Bonus for silencer + player['coins'] += coins_earned + self.ducks[channel] = {'alive': False} + await self.bot.send_message(channel, f'\033[92m{nick} shot the duck! (+{coins_earned} coin{"s" if coins_earned > 1 else ""})\033[0m') + if self.logger: + self.logger.info(f"{nick} shot a duck in {channel}") + else: + await self.bot.send_message(channel, f'\033[93m{nick} missed the duck!\033[0m') + else: + await self.bot.send_message(channel, f'No duck to shoot, {nick}!') + self.save_player(nick, player) + + async def handle_reload(self, nick, channel): + player = self.get_player(nick) + + # Check gun reliability - can fail to reload + reliability = player['reliability'] + if player['gun_oil'] > 0: + reliability += 15 + player['gun_oil'] -= 1 # Gun oil gets used up + + reload_roll = random.randint(1, 100) + if reload_roll <= reliability: + player['ammo'] = player['max_ammo'] + await self.bot.send_message(channel, f'\033[94m{nick} reloaded successfully!\033[0m') + else: + await self.bot.send_message(channel, f'\033[91m{nick}\'s gun jammed while reloading! Try again.\033[0m') + + self.save_player(nick, player) + + async def handle_bef(self, nick, channel): + player = self.get_player(nick) + duck = self.ducks.get(channel) + if duck and duck.get('alive'): + player['friends'].add('duck') + self.ducks[channel] = {'alive': False} + await self.bot.send_message(channel, f'\033[96m{nick} befriended the duck!\033[0m') + if self.logger: + self.logger.info(f"{nick} befriended a duck in {channel}") + else: + await self.bot.send_message(channel, f'No duck to befriend, {nick}!') + self.save_player(nick, player) + + async def handle_catch(self, nick, channel): + player = self.get_player(nick) + duck = self.ducks.get(channel) + if duck and duck.get('alive'): + player['caught'] += 1 + self.ducks[channel] = {'alive': False} + await self.bot.send_message(channel, f'\033[92m{nick} caught the duck!\033[0m') + if self.logger: + self.logger.info(f"{nick} caught a duck in {channel}") + else: + await self.bot.send_message(channel, f'No duck to catch, {nick}!') + self.save_player(nick, player) + + async def handle_shop(self, nick, channel): + player = self.get_player(nick) + coins = player['coins'] + + shop_items = [ + "๐Ÿ”ซ Scope - Improves accuracy by 15% (Cost: 5 coins)", + "๐Ÿ”‡ Silencer - Bonus coin on successful shots (Cost: 8 coins)", + "๐Ÿ›ข๏ธ Gun Oil - Improves reload reliability for 3 reloads (Cost: 3 coins)", + "๐Ÿ€ Lucky Charm - Improves accuracy by 10% (Cost: 10 coins)", + "๐Ÿ“ฆ Ammo Upgrade - Increases max ammo capacity by 1 (Cost: 12 coins)", + "๐ŸŽฏ Accuracy Training - Permanently increases accuracy by 5% (Cost: 15 coins)", + "๐Ÿ”ง Gun Maintenance - Permanently increases reliability by 10% (Cost: 20 coins)" + ] + + shop_msg = f"\033[95m{nick}'s Shop (Coins: {coins}):\033[0m\n" + for i, item in enumerate(shop_items, 1): + shop_msg += f"{i}. {item}\n" + shop_msg += "Use !buy to purchase an item!\n" + shop_msg += "Use !sell to sell upgrades for coins!" + + await self.bot.send_message(channel, shop_msg) + async def handle_duckstats(self, nick, channel): + player = self.get_player(nick) + stats = f"\033[95m{nick}'s Duck Stats:\033[0m\n" + stats += f"Caught: {player['caught']}\n" + stats += f"Coins: {player['coins']}\n" + stats += f"Accuracy: {player['accuracy']}%\n" + stats += f"Reliability: {player['reliability']}%\n" + stats += f"Max Ammo: {player['max_ammo']}\n" + stats += f"Gun Oil: {player['gun_oil']} uses left\n" + upgrades = [] + if player['scope']: upgrades.append("Scope") + if player['silencer']: upgrades.append("Silencer") + if player['lucky_charm']: upgrades.append("Lucky Charm") + stats += f"Upgrades: {', '.join(upgrades) if upgrades else 'None'}\n" + stats += f"Friends: {', '.join(player['friends']) if player['friends'] else 'None'}\n" + await self.bot.send_message(channel, stats) + + async def handle_buy(self, nick, channel, item_num): + player = self.get_player(nick) + + try: + item_id = int(item_num) + except ValueError: + await self.bot.send_message(channel, f'{nick}, please specify a valid item number!') + return + + shop_items = { + 1: ("scope", 5, "Scope"), + 2: ("silencer", 8, "Silencer"), + 3: ("gun_oil", 3, "Gun Oil"), + 4: ("lucky_charm", 10, "Lucky Charm"), + 5: ("ammo_upgrade", 12, "Ammo Upgrade"), + 6: ("accuracy_training", 15, "Accuracy Training"), + 7: ("gun_maintenance", 20, "Gun Maintenance") + } + + if item_id not in shop_items: + await self.bot.send_message(channel, f'{nick}, invalid item number!') + return + + item_key, cost, item_name = shop_items[item_id] + + if player['coins'] < cost: + await self.bot.send_message(channel, f'\033[91m{nick}, you need {cost} coins for {item_name}! (You have {player["coins"]})\033[0m') + return + + # Process purchase + player['coins'] -= cost + + if item_key == "scope": + if player['scope']: + await self.bot.send_message(channel, f'{nick}, you already have a scope!') + player['coins'] += cost # Refund + return + player['scope'] = True + elif item_key == "silencer": + if player['silencer']: + await self.bot.send_message(channel, f'{nick}, you already have a silencer!') + player['coins'] += cost + return + player['silencer'] = True + elif item_key == "gun_oil": + player['gun_oil'] += 3 + elif item_key == "lucky_charm": + if player['lucky_charm']: + await self.bot.send_message(channel, f'{nick}, you already have a lucky charm!') + player['coins'] += cost + return + player['lucky_charm'] = True + elif item_key == "ammo_upgrade": + player['max_ammo'] += 1 + elif item_key == "accuracy_training": + player['accuracy'] = min(95, player['accuracy'] + 5) # Cap at 95% + elif item_key == "gun_maintenance": + player['reliability'] = min(95, player['reliability'] + 10) # Cap at 95% + + await self.bot.send_message(channel, f'\033[92m{nick} purchased {item_name}!\033[0m') + self.save_player(nick, player) + + async def handle_sell(self, nick, channel, item_num): + player = self.get_player(nick) + + try: + item_id = int(item_num) + except ValueError: + await self.bot.send_message(channel, f'{nick}, please specify a valid item number!') + return + + sellable_items = { + 1: ("scope", 3, "Scope"), + 2: ("silencer", 5, "Silencer"), + 3: ("gun_oil", 1, "Gun Oil (per use)"), + 4: ("lucky_charm", 6, "Lucky Charm") + } + + if item_id not in sellable_items: + await self.bot.send_message(channel, f'{nick}, invalid item number! Sellable items: 1-4') + return + + item_key, sell_price, item_name = sellable_items[item_id] + + if item_key == "scope": + if not player['scope']: + await self.bot.send_message(channel, f'{nick}, you don\'t have a scope to sell!') + return + player['scope'] = False + player['coins'] += sell_price + elif item_key == "silencer": + if not player['silencer']: + await self.bot.send_message(channel, f'{nick}, you don\'t have a silencer to sell!') + return + player['silencer'] = False + player['coins'] += sell_price + elif item_key == "gun_oil": + if player['gun_oil'] <= 0: + await self.bot.send_message(channel, f'{nick}, you don\'t have any gun oil to sell!') + return + player['gun_oil'] -= 1 + player['coins'] += sell_price + elif item_key == "lucky_charm": + if not player['lucky_charm']: + await self.bot.send_message(channel, f'{nick}, you don\'t have a lucky charm to sell!') + return + player['lucky_charm'] = False + player['coins'] += sell_price + + await self.bot.send_message(channel, f'\033[94m{nick} sold {item_name} for {sell_price} coins!\033[0m') + self.save_player(nick, player) + + async def handle_stats(self, nick, channel): + player = self.get_player(nick) + + # Calculate effective accuracy and reliability + effective_accuracy = player['accuracy'] + if player['scope']: + effective_accuracy += 15 + if player['lucky_charm']: + effective_accuracy += 10 + effective_accuracy = min(100, effective_accuracy) + + effective_reliability = player['reliability'] + if player['gun_oil'] > 0: + effective_reliability += 15 + effective_reliability = min(100, effective_reliability) + + stats = f"\033[96m{nick}'s Combat Stats:\033[0m\n" + stats += f"๐ŸŽฏ Base Accuracy: {player['accuracy']}% (Effective: {effective_accuracy}%)\n" + stats += f"๐Ÿ”ง Base Reliability: {player['reliability']}% (Effective: {effective_reliability}%)\n" + stats += f"๐Ÿ”ซ Ammo: {player['ammo']}/{player['max_ammo']}\n" + stats += f"๐Ÿ’ฐ Coins: {player['coins']}\n" + stats += f"๐Ÿฆ† Ducks Caught: {player['caught']}\n" + stats += f"๐Ÿ›ข๏ธ Gun Oil: {player['gun_oil']} uses\n" + + upgrades = [] + if player['scope']: upgrades.append("๐Ÿ”ญ Scope") + if player['silencer']: upgrades.append("๐Ÿ”‡ Silencer") + if player['lucky_charm']: upgrades.append("๐Ÿ€ Lucky Charm") + stats += f"โšก Active Upgrades: {', '.join(upgrades) if upgrades else 'None'}\n" + + friends = list(player['friends']) + stats += f"๐Ÿค Friends: {', '.join(friends) if friends else 'None'}" + + await self.bot.send_message(channel, stats) + + async def handle_register(self, nick, hostmask, username, password): + if self.auth.register_account(username, password, nick, hostmask): + await self.bot.send_message(nick, f"โœ… Account '{username}' registered successfully! Use 'identify {username} {password}' to login.") + else: + await self.bot.send_message(nick, f"โŒ Account '{username}' already exists!") + + async def handle_identify(self, nick, username, password): + if self.auth.authenticate(username, password, nick): + await self.bot.send_message(nick, f"โœ… Authenticated as '{username}'!") + # Transfer nick-based data to account if exists + nick_data = self.db.load_player(nick) + if nick_data: + account_data = self.db.load_player(username) + if not account_data: + self.db.save_player(username, nick_data) + await self.bot.send_message(nick, "๐Ÿ“Š Your progress has been transferred to your account!") + else: + await self.bot.send_message(nick, "โŒ Invalid username or password!") + + async def handle_help(self, nick, channel): + help_text = """ +๐Ÿฆ† **DuckHunt Bot Commands** ๐Ÿฆ† + +**๐ŸŽฏ Hunting:** +โ€ข !bang - Shoot at a duck (requires ammo) +โ€ข !reload - Reload your weapon (can fail based on reliability) +โ€ข !catch - Catch a duck with your hands +โ€ข !bef - Befriend a duck instead of shooting + +**๐Ÿ›’ Economy:** +โ€ข !shop - View available items for purchase +โ€ข !buy - Purchase an item from the shop +โ€ข !sell - Sell equipment for coins +โ€ข !bank - Access banking services (deposits, loans) +โ€ข !trade - Trade with other players + +**๐Ÿ“Š Stats & Info:** +โ€ข !stats - View detailed combat statistics +โ€ข !duckstats - View personal hunting statistics +โ€ข !leaderboard - View top players +โ€ข !license - Manage hunting license + +**โš™๏ธ Settings:** +โ€ข !alerts - Toggle duck spawn notifications +โ€ข !register - Register an account (via /msg) +โ€ข identify - Login to account (via /msg) + +**๐ŸŽฎ Advanced:** +โ€ข !sabotage - Attempt to sabotage another hunter +โ€ข !help - Show this help message + +๐Ÿ’ก **Tips:** +- Different duck types give different rewards +- Weapon durability affects performance +- Insurance protects your equipment +- Level up to unlock better gear! + """ + await self.bot.send_message(nick, help_text) + + async def handle_leaderboard(self, nick, channel): + leaderboard_data = self.db.get_leaderboard('caught', 10) + if not leaderboard_data: + await self.bot.send_message(channel, "No leaderboard data available yet!") + return + + msg = "๐Ÿ† **Duck Hunting Leaderboard** ๐Ÿ†\n" + for i, (account, caught) in enumerate(leaderboard_data, 1): + emoji = "๐Ÿฅ‡" if i == 1 else "๐Ÿฅˆ" if i == 2 else "๐Ÿฅ‰" if i == 3 else f"{i}." + msg += f"{emoji} {account}: {caught} ducks\n" + + await self.bot.send_message(channel, msg) + + async def handle_bank(self, nick, channel): + player = self.get_player(nick) + bank_msg = f""" +๐Ÿฆ **{nick}'s Bank Account** ๐Ÿฆ +๐Ÿ’ฐ Cash on hand: {player['coins']} coins +๐Ÿ›๏ธ Bank balance: {player['bank_account']} coins +๐Ÿ“ˆ Total wealth: {player['coins'] + player['bank_account']} coins + +**Commands:** +โ€ข !bank deposit - Deposit coins (earns 2% daily interest) +โ€ข !bank withdraw - Withdraw coins +โ€ข !bank loan - Take a loan (10% interest) + """ + await self.bot.send_message(nick, bank_msg) + + async def handle_license(self, nick, channel): + player = self.get_player(nick) + license_active = player['hunting_license']['active'] + + if license_active: + expires = player['hunting_license']['expires'] + msg = f"๐ŸŽซ Your hunting license is active until {expires}\n" + msg += "Licensed hunters get +25% coins and access to rare equipment!" + else: + msg = "๐ŸŽซ You don't have a hunting license.\n" + msg += "Purchase one for 50 coins to get:\n" + msg += "โ€ข +25% coin rewards\n" + msg += "โ€ข Access to premium shop items\n" + msg += "โ€ข Reduced insurance costs\n" + msg += "Type '!buy license' to purchase" + + await self.bot.send_message(channel, msg) + + async def handle_alerts(self, nick, channel): + if nick in self.duck_alerts: + self.duck_alerts.remove(nick) + await self.bot.send_message(channel, f"๐Ÿ”• {nick}: Duck alerts disabled") + else: + self.duck_alerts.add(nick) + await self.bot.send_message(channel, f"๐Ÿ”” {nick}: Duck alerts enabled! You'll be notified when ducks spawn.") + + async def handle_trade(self, nick, channel, args): + if len(args) < 3: + await self.bot.send_message(channel, f"{nick}: Usage: !trade ") + return + + target, item, amount = args[0], args[1], args[2] + player = self.get_player(nick) + + try: + amount = int(amount) + except ValueError: + await self.bot.send_message(channel, f"{nick}: Amount must be a number!") + return + + if item == "coins": + if player['coins'] < amount: + await self.bot.send_message(channel, f"{nick}: You don't have enough coins!") + return + + trade_data = { + 'type': 'coins', + 'amount': amount, + 'from_nick': nick + } + + trade_id = self.db.save_trade(nick, target, trade_data) + await self.bot.send_message(channel, f"๐Ÿ’ธ Trade offer sent to {target}: {amount} coins") + await self.bot.send_message(target, f"๐Ÿ’ฐ {nick} wants to trade you {amount} coins. Type '!accept {trade_id}' to accept!") + else: + await self.bot.send_message(channel, f"{nick}: Only coin trading is available currently!") + + async def handle_sabotage(self, nick, channel, target): + player = self.get_player(nick) + target_player = self.get_player(target) + + if player['coins'] < 5: + await self.bot.send_message(channel, f"{nick}: Sabotage costs 5 coins!") + return + + success_chance = 60 + (player['level'] * 5) + if random.randint(1, 100) <= success_chance: + player['coins'] -= 5 + target_player['weapon_durability'] = max(0, target_player['weapon_durability'] - 10) + await self.bot.send_message(channel, f"๐Ÿ˜ˆ {nick} successfully sabotaged {target}'s weapon!") + self.save_player(nick, player) + self.save_player(target, target_player) + else: + player['coins'] -= 5 + await self.bot.send_message(channel, f"๐Ÿ˜… {nick}'s sabotage attempt failed!") + self.save_player(nick, player) diff --git a/src/items.py b/src/items.py new file mode 100644 index 0000000..f579bf1 --- /dev/null +++ b/src/items.py @@ -0,0 +1,124 @@ +import random + +class DuckTypes: + COMMON = { + 'name': 'Common Duck', + 'emoji': '๐Ÿฆ†', + 'rarity': 70, + 'coins': 1, + 'xp': 10, + 'health': 1 + } + + RARE = { + 'name': 'Rare Duck', + 'emoji': '๐Ÿฆ†โœจ', + 'rarity': 20, + 'coins': 3, + 'xp': 25, + 'health': 1 + } + + GOLDEN = { + 'name': 'Golden Duck', + 'emoji': '๐Ÿฅ‡๐Ÿฆ†', + 'rarity': 8, + 'coins': 10, + 'xp': 50, + 'health': 2 + } + + ARMORED = { + 'name': 'Armored Duck', + 'emoji': '๐Ÿ›ก๏ธ๐Ÿฆ†', + 'rarity': 2, + 'coins': 15, + 'xp': 75, + 'health': 3 + } + + @classmethod + def get_random_duck(cls): + roll = random.randint(1, 100) + if roll <= cls.COMMON['rarity']: + return cls.COMMON + elif roll <= cls.COMMON['rarity'] + cls.RARE['rarity']: + return cls.RARE + elif roll <= cls.COMMON['rarity'] + cls.RARE['rarity'] + cls.GOLDEN['rarity']: + return cls.GOLDEN + else: + return cls.ARMORED + +class WeaponTypes: + BASIC_GUN = { + 'name': 'Basic Gun', + 'accuracy_bonus': 0, + 'durability': 100, + 'max_durability': 100, + 'repair_cost': 5, + 'attachment_slots': 1 + } + + SHOTGUN = { + 'name': 'Shotgun', + 'accuracy_bonus': -10, + 'durability': 80, + 'max_durability': 80, + 'repair_cost': 8, + 'attachment_slots': 2, + 'spread_shot': True # Can hit multiple ducks + } + + RIFLE = { + 'name': 'Rifle', + 'accuracy_bonus': 20, + 'durability': 120, + 'max_durability': 120, + 'repair_cost': 12, + 'attachment_slots': 3 + } + +class AmmoTypes: + STANDARD = { + 'name': 'Standard Ammo', + 'damage': 1, + 'accuracy_modifier': 0, + 'cost': 1 + } + + RUBBER = { + 'name': 'Rubber Bullets', + 'damage': 0, # Non-lethal, for catching + 'accuracy_modifier': 5, + 'cost': 2, + 'special': 'stun' + } + + EXPLOSIVE = { + 'name': 'Explosive Rounds', + 'damage': 2, + 'accuracy_modifier': -5, + 'cost': 5, + 'special': 'area_damage' + } + +class Attachments: + LASER_SIGHT = { + 'name': 'Laser Sight', + 'accuracy_bonus': 10, + 'cost': 15, + 'durability_cost': 2 # Uses weapon durability faster + } + + EXTENDED_MAG = { + 'name': 'Extended Magazine', + 'ammo_bonus': 2, + 'cost': 20 + } + + BIPOD = { + 'name': 'Bipod', + 'accuracy_bonus': 15, + 'reliability_bonus': 5, + 'cost': 25 + } diff --git a/src/logging_utils.py b/src/logging_utils.py new file mode 100644 index 0000000..439c6d6 --- /dev/null +++ b/src/logging_utils.py @@ -0,0 +1,28 @@ +import logging +import sys +from functools import partial + +class ColorFormatter(logging.Formatter): + COLORS = { + 'DEBUG': '\033[94m', + 'INFO': '\033[92m', + 'WARNING': '\033[93m', + 'ERROR': '\033[91m', + 'CRITICAL': '\033[95m', + 'ENDC': '\033[0m', + } + def format(self, record): + color = self.COLORS.get(record.levelname, '') + endc = self.COLORS['ENDC'] + msg = super().format(record) + return f"{color}{msg}{endc}" + +def setup_logger(name='DuckHuntBot', level=logging.INFO): + logger = logging.getLogger(name) + handler = logging.StreamHandler(sys.stdout) + formatter = ColorFormatter('[%(asctime)s] %(levelname)s: %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(level) + logger.propagate = False + return logger diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..6b04233 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,11 @@ +def parse_message(line): + prefix = '' + trailing = '' + if line.startswith(':'): + prefix, line = line[1:].split(' ', 1) + if ' :' in line: + line, trailing = line.split(' :', 1) + parts = line.split() + command = parts[0] if parts else '' + params = parts[1:] if len(parts) > 1 else [] + return prefix, command, params, trailing diff --git a/test_bot.py b/test_bot.py new file mode 100644 index 0000000..0515ae4 --- /dev/null +++ b/test_bot.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Test script for DuckHunt Bot +Run this to test the bot locally +""" + +import asyncio +import json +import sys +import os + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from duckhuntbot import IRCBot + +async def test_bot(): + """Test the bot initialization and basic functionality""" + try: + # Load config + with open('config.json') as f: + config = json.load(f) + + # Create bot instance + bot = IRCBot(config) + print("โœ… Bot initialized successfully!") + + # Test database + bot.db.save_player("testuser", {"coins": 100, "caught": 5}) + data = bot.db.load_player("testuser") + if data and data['coins'] == 100: + print("โœ… Database working!") + else: + print("โŒ Database test failed!") + + # Test game logic + player = bot.game.get_player("testuser") + if player and 'coins' in player: + print("โœ… Game logic working!") + else: + print("โŒ Game logic test failed!") + + print("๐Ÿฆ† DuckHunt Bot is ready to deploy!") + return True + + except Exception as e: + print(f"โŒ Error: {e}") + return False + +if __name__ == '__main__': + success = asyncio.run(test_bot()) + if not success: + sys.exit(1) diff --git a/test_connection.py b/test_connection.py new file mode 100644 index 0000000..e69de29