Prepare for GitHub release

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

5
.gitignore vendored
View File

@@ -130,8 +130,9 @@ duckhunt.log*
*.tmp
*.backup
# Database files (optional - uncomment if you want to ignore database)
# duckhunt.json
# Runtime files (do not commit)
duckhunt.json
config.json
# IDE files
.vscode/

233
README.md
View File

@@ -1,201 +1,90 @@
# DuckHunt IRC Bot
A feature-rich IRC bot that brings the classic duck hunting game to your IRC channels. Players can shoot, befriend, and collect various types of ducks while managing their equipment and competing for high scores.
DuckHunt is an asyncio-based IRC bot that runs a classic duck hunting” mini-game in IRC channels.
## ✨ Features
## Credits
- **Multiple Duck Types**: Normal, Golden (high HP), and Fast (quick timeout) ducks
- 🎯 **Accuracy System**: Dynamic accuracy that improves with hits and degrades with misses
- **Weapon Management**: Magazines, bullets, and gun jamming mechanics
- 🛒 **Shop System**: Buy equipment and items with XP (currency)
- 🎒 **Inventory System**: Collect and use various items (bread, grease, sights, etc.)
- 👥 **Player Statistics**: Track shots, hits, misses, and best times
- **Fully Configurable**: Every game parameter can be customized via config
- 🔐 **Authentication**: Support for both server passwords and SASL/NickServ auth
- 📊 **Admin Commands**: Comprehensive bot management and player administration
- Originally written by **Computertech**
- New features, fixes, and maintenance added by **End3r**
## 🚀 Quick Start
## Features
### Prerequisites
- Per-channel stats (same nick has separate stats per channel)
- Multiple duck types (normal / golden / fast)
- Shop + inventory items
- Admin commands (rearm/disarm/ignore, spawn ducks, join/leave channels)
- `!globalducks` totals across channels
- JSON persistence to disk (`duckhunt.json`)
## Quick start
### Requirements
- Python 3.8+
- Virtual environment (recommended)
### Installation
### Run
1. **Clone the repository:**
```bash
git clone https://github.com/your-username/duckhunt-bot.git
cd duckhunt-bot
```
From the repo root:
2. **Set up virtual environment:**
```bash
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# or
.venv\Scripts\activate # Windows
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Configure the bot:**
- Copy `config.json.example` to `config.json` (if available)
- Edit `config.json` with your IRC server settings
- See [CONFIG.md](CONFIG.md) for detailed configuration guide
5. **Run the bot:**
```bash
python duckhunt.py
```
## ⚙️ Configuration
The bot uses a nested JSON configuration system. Key settings include:
### Connection Settings
```json
{
"connection": {
"server": "irc.your-server.net",
"port": 6697,
"nick": "DuckHunt",
"channels": ["#your-channel"],
"ssl": true
}
}
```bash
python3 duckhunt.py
```
### Duck Types & Rewards
```json
{
"duck_types": {
"normal": { "xp": 10, "timeout": 60 },
"golden": { "chance": 0.15, "min_hp": 3, "max_hp": 5, "xp": 15 },
"fast": { "chance": 0.25, "timeout": 20, "xp": 12 }
}
}
```
## Configuration
**📖 See [CONFIG.md](CONFIG.md) for complete configuration documentation.**
Edit `config.json`:
## 🎮 Game Commands
- `connection.server`, `connection.port`, `connection.nick`
- `connection.channels` (list of channels to join on connect)
- `connection.ssl` and optional password/SASL settings
### Player Commands
- `!shoot` - Shoot at a duck
- `!reload` - Reload your weapon
- `!befriend` - Try to befriend a duck
- `!stats [player]` - View player statistics
- `!shop` - View the shop
- `!buy <item>` - Purchase an item
- `!inventory` - Check your inventory
- `!use <item>` - Use an item from inventory
Security note: dont commit real IRC passwords/tokens in `config.json`.
### Admin Commands
- `!spawn` - Manually spawn a duck
- `!give <player> <item> <quantity>` - Give items to players
- `!setstat <player> <stat> <value>` - Modify player stats
- `!reload_config` - Reload configuration without restart
## Persistence
## Duck Types
Player stats are saved to `duckhunt.json`.
| Type | Spawn Rate | HP | Timeout | XP Reward |
|------|------------|----|---------|-----------|
| Normal | 60% | 1 | 60s | 10 |
| Golden | 15% | 3-5 | 60s | 15 |
| Fast | 25% | 1 | 20s | 12 |
- Stats are stored per channel.
- If you run `!join` / `!leave`, the bot updates `config.json` so channel changes persist across restarts.
## 🛒 Shop Items
## Commands
- **Bread** - Attracts more ducks
- **Gun Grease** - Reduces jam chance
- **Sight** - Improves accuracy
- **Silencer** - Enables stealth shooting
- **Explosive Ammo** - Extra damage
- **Lucky Charm** - Increases rewards
- **Duck Detector** - Reveals duck locations
### Player commands
## 📁 Project Structure
- `!bang`
- `!reload`
- `!shop`
- `!buy <item_id>`
- `!use <item_id> [target]`
- `!duckstats [player]`
- `!topduck`
- `!give <item_id> <player>`
- `!globalducks [player]` (totals across all configured channels)
- `!duckhelp` (sends a PM with examples)
### Admin commands
- `!rearm <player|all>`
- `!disarm <player>`
- `!ignore <player>` / `!unignore <player>`
- `!ducklaunch [duck_type]` (in-channel)
- `!ducklaunch <#channel> [duck_type]` (in PM)
- `!join <#channel>` / `!leave <#channel>` (persists to `config.json`)
## Repo layout
```
duckhunt/
├── duckhunt.py # Main bot entry point
├── duckhunt.py # Entry point
├── config.json # Bot configuration
├── CONFIG.md # Configuration documentation
── src/
├── duckhuntbot.py # Core bot IRC functionality
├── game.py # Duck game mechanics
├── db.py # Player database management
├── shop.py # Shop system
├── levels.py # Player leveling system
── sasl.py # SASL authentication
│ └── utils.py # Utility functions
├── shop.json # Shop item definitions
├── levels.json # Level progression data
├── messages.json # Bot response messages
└── duckhunt.json # Player database
├── duckhunt.json # Player database (generated/updated at runtime)
── src/
├── duckhuntbot.py # IRC bot + command routing
├── game.py # Duck game logic
├── db.py # Persistence layer
├── shop.py # Shop/inventory
├── levels.py # Level system
── ...
```
## Development
### Adding New Features
The bot is designed with modularity in mind:
1. **Game mechanics** are in `src/game.py`
2. **IRC functionality** is in `src/duckhuntbot.py`
3. **Database operations** are in `src/db.py`
4. **Configuration** uses dot notation: `bot.get_config('duck_types.normal.xp')`
### Testing Configuration
Use the built-in config tester:
```bash
python test_config.py
```
## 🛠️ Troubleshooting
### Common Issues
1. **Connection fails**: Check server, port, and SSL settings in config
2. **SASL authentication fails**: Verify username/password and ensure nick is registered
3. **Bot doesn't respond**: Check channel permissions and admin list
4. **Config errors**: Validate JSON syntax and see CONFIG.md for proper values
### Debug Mode
Enable detailed logging by setting log level in the code or add verbose output.
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes
4. Add tests if applicable
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
## 📝 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- Inspired by classic IRC duck hunting bots
- Built with Python's asyncio for modern async IRC handling
- Thanks to all contributors and testers
## 📞 Support
- 📖 **Documentation**: See [CONFIG.md](CONFIG.md) for configuration help
- 🐛 **Issues**: Report bugs via GitHub Issues
- 💬 **Discussion**: Join our IRC channel for help and discussion
---
**Happy Duck Hunting!**

View File

@@ -1,124 +0,0 @@
{
"connection": {
"server": "irc.rizon.net",
"port": 6697,
"nick": "DickHunt",
"channels": [
"#ct"
],
"ssl": true,
"password": "duckyhunt789",
"max_retries": 3,
"retry_delay": 5,
"timeout": 30,
"auto_rejoin": {
"enabled": true,
"retry_interval": 20,
"max_rejoin_attempts": 100
}
},
"sasl": {
"enabled": false,
"username": "duckhunt",
"password": "duckhunt//789//"
},
"admins": [
"peorth",
"computertech",
"colby"
],
"duck_spawning": {
"spawn_min": 1200,
"spawn_max": 3600,
"timeout": 60,
"rearm_on_duck_shot": true
},
"duck_types": {
"normal": {
"xp": 10,
"timeout": 60,
"drop_chance": 0.15
},
"golden": {
"chance": 0.15,
"min_hp": 3,
"max_hp": 5,
"xp": 15,
"timeout": 60,
"drop_chance": 0.50
},
"fast": {
"chance": 0.25,
"timeout": 20,
"xp": 12,
"drop_chance": 0.25
}
},
"item_drops": {
"normal_duck_drops": [
{"item_id": 1, "weight": 40},
{"item_id": 2, "weight": 25},
{"item_id": 4, "weight": 20},
{"item_id": 3, "weight": 15}
],
"fast_duck_drops": [
{"item_id": 1, "weight": 30},
{"item_id": 2, "weight": 25},
{"item_id": 4, "weight": 20},
{"item_id": 8, "weight": 15},
{"item_id": 3, "weight": 10}
],
"golden_duck_drops": [
{"item_id": 5, "weight": 25},
{"item_id": 6, "weight": 20},
{"item_id": 7, "weight": 20},
{"item_id": 2, "weight": 15},
{"item_id": 9, "weight": 10},
{"item_id": 1, "weight": 10}
]
},
"player_defaults": {
"accuracy": 75,
"magazines": 3,
"bullets_per_magazine": 6,
"jam_chance": 15,
"xp": 0
},
"gameplay": {
"befriend_success_rate": 75,
"befriend_xp": 5,
"accuracy_gain_on_hit": 1,
"accuracy_loss_on_miss": 2,
"min_accuracy": 10,
"max_accuracy": 100,
"min_befriend_success_rate": 5,
"max_befriend_success_rate": 95,
"wet_clothes_duration": 300
},
"features": {
"shop_enabled": true,
"inventory_enabled": true,
"auto_rearm_enabled": true
},
"limits": {
"max_inventory_items": 20,
"max_temp_effects": 20
},
"debug": {
"_comment_enabled": "Whether debug logging is enabled at all (true=debug mode, false=minimal logging)",
"enabled": true,
"_comment_log_level": "Overall logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
"log_level": "DEBUG",
"_comment_console_level": "Console output level - what shows in terminal (DEBUG, INFO, WARNING, ERROR)",
"console_log_level": "INFO",
"_comment_file_level": "File logging level - what gets written to log files (DEBUG, INFO, WARNING, ERROR)",
"file_log_level": "DEBUG",
"_comment_log_everything": "If true, logs ALL events. If false, logs only important events",
"log_everything": true,
"_comment_log_performance": "Whether to enable performance/metrics logging to performance.log",
"log_performance": true,
"_comment_unified_format": "If true, console and file logs use same format. If false, console has colors, file is plain",
"unified_format": true
}
}

121
config.json.example Normal file
View File

@@ -0,0 +1,121 @@
{
"connection": {
"server": "irc.example.net",
"port": 6697,
"nick": "Quackbot",
"channels": [
"#duckhunt"
],
"ssl": true,
"password": "",
"max_retries": 3,
"retry_delay": 5,
"timeout": 30,
"auto_rejoin": {
"enabled": true,
"retry_interval": 20,
"max_rejoin_attempts": 100
}
},
"sasl": {
"enabled": false,
"username": "",
"password": ""
},
"admins": [
"yourNickHere"
],
"duck_spawning": {
"spawn_min": 1200,
"spawn_max": 3600,
"timeout": 60,
"rearm_on_duck_shot": true
},
"duck_types": {
"normal": {
"xp": 10,
"timeout": 60,
"drop_chance": 0.15
},
"golden": {
"chance": 0.15,
"min_hp": 3,
"max_hp": 5,
"xp": 15,
"timeout": 60,
"drop_chance": 0.5
},
"fast": {
"chance": 0.25,
"timeout": 20,
"xp": 12,
"drop_chance": 0.25
}
},
"item_drops": {
"normal_duck_drops": [
{"item_id": 1, "weight": 40},
{"item_id": 2, "weight": 25},
{"item_id": 4, "weight": 20},
{"item_id": 3, "weight": 15}
],
"fast_duck_drops": [
{"item_id": 1, "weight": 30},
{"item_id": 2, "weight": 25},
{"item_id": 4, "weight": 20},
{"item_id": 8, "weight": 15},
{"item_id": 3, "weight": 10}
],
"golden_duck_drops": [
{"item_id": 5, "weight": 25},
{"item_id": 6, "weight": 20},
{"item_id": 7, "weight": 20},
{"item_id": 2, "weight": 15},
{"item_id": 9, "weight": 10},
{"item_id": 1, "weight": 10}
]
},
"player_defaults": {
"accuracy": 75,
"magazines": 3,
"bullets_per_magazine": 6,
"jam_chance": 15,
"xp": 0
},
"gameplay": {
"befriend_success_rate": 75,
"befriend_xp": 5,
"accuracy_gain_on_hit": 1,
"accuracy_loss_on_miss": 2,
"min_accuracy": 10,
"max_accuracy": 100,
"min_befriend_success_rate": 5,
"max_befriend_success_rate": 95,
"wet_clothes_duration": 300
},
"features": {
"shop_enabled": true,
"inventory_enabled": true,
"auto_rearm_enabled": true
},
"limits": {
"max_inventory_items": 20,
"max_temp_effects": 20
},
"debug": {
"_comment_enabled": "Whether debug logging is enabled at all (true=debug mode, false=minimal logging)",
"enabled": true,
"_comment_log_level": "Overall logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
"log_level": "DEBUG",
"_comment_console_level": "Console output level - what shows in terminal (DEBUG, INFO, WARNING, ERROR)",
"console_log_level": "INFO",
"_comment_file_level": "File logging level - what gets written to log files (DEBUG, INFO, WARNING, ERROR)",
"file_log_level": "DEBUG",
"_comment_log_everything": "If true, logs ALL events. If false, logs only important events",
"log_everything": true,
"_comment_log_performance": "Whether to enable performance/metrics logging to performance.log",
"log_performance": true,
"_comment_unified_format": "If true, console and file logs use same format. If false, console has colors, file is plain",
"unified_format": true
}
}

238
src/db.py
View File

@@ -8,6 +8,7 @@ import logging
import time
import os
from datetime import datetime
from typing import Optional
from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input
@@ -15,8 +16,16 @@ class DuckDB:
"""Simplified database management"""
def __init__(self, db_file="duckhunt.json", bot=None):
self.db_file = db_file
# Resolve relative paths against the project root (repo root), not the process CWD.
# This prevents "stats wiped" symptoms when the bot is launched from a different working dir.
if os.path.isabs(db_file):
self.db_file = db_file
else:
project_root = os.path.dirname(os.path.dirname(__file__))
self.db_file = os.path.join(project_root, db_file)
self.bot = bot
# Channel-scoped player storage:
# {"#channel": {"nick": {player_data}}, ...}
self.players = {}
self.logger = logging.getLogger('DuckHuntBot.DB')
@@ -24,7 +33,63 @@ class DuckDB:
self.error_recovery = ErrorRecovery()
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
self.load_database()
data = self.load_database()
self._hydrate_from_data(data)
def _default_channel(self) -> str:
"""Pick a reasonable default channel context."""
if self.bot:
channels = self.bot.get_config('connection.channels', []) or []
if isinstance(channels, list) and channels:
first = channels[0]
if isinstance(first, str) and first.strip():
return first.strip()
return "#duckhunt"
def _normalize_channel(self, channel: Optional[str]) -> str:
if isinstance(channel, str) and channel.strip().startswith(('#', '&')):
return channel.strip()
return self._default_channel()
def _hydrate_from_data(self, data: dict) -> None:
"""Load in-memory channel->players structure from parsed JSON."""
try:
players_by_channel: dict = {}
if isinstance(data, dict) and isinstance(data.get('channels'), dict):
# New format
for ch, ch_data in data['channels'].items():
if not isinstance(ch, str):
continue
if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict):
players = ch_data.get('players', {})
elif isinstance(ch_data, dict):
# Support legacy "channels: {"#c": {nick: {...}}}" shape
players = ch_data
else:
continue
# Keep only dict players
clean_players = {}
for nick, pdata in players.items():
if isinstance(nick, str) and isinstance(pdata, dict):
clean_players[nick.lower()] = pdata
if clean_players:
players_by_channel[ch] = clean_players
elif isinstance(data, dict) and isinstance(data.get('players'), dict):
# Old format: single global player dictionary
default_channel = self._default_channel()
migrated = {}
for nick, pdata in data['players'].items():
if isinstance(nick, str) and isinstance(pdata, dict):
migrated[nick.lower()] = pdata
players_by_channel[default_channel] = migrated
self.players = players_by_channel if isinstance(players_by_channel, dict) else {}
except Exception as e:
self.logger.error(f"Error hydrating database in-memory state: {e}")
self.players = {}
def load_database(self) -> dict:
"""Load the database, creating it if it doesn't exist"""
@@ -54,14 +119,28 @@ class DuckDB:
'last_modified': datetime.now().isoformat()
}
# Initialize players section if missing
if 'players' not in data:
data['players'] = {}
# Initialize channels section if missing
if 'channels' not in data:
# If old format has players, keep it for migration.
data.setdefault('players', {})
else:
if not isinstance(data.get('channels'), dict):
data['channels'] = {}
# Update last_modified
data['metadata']['last_modified'] = datetime.now().isoformat()
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
try:
if isinstance(data.get('channels'), dict):
total_players = 0
for ch_data in data['channels'].values():
if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict):
total_players += len(ch_data.get('players', {}))
self.logger.info(f"Successfully loaded database with {total_players} total players across {len(data.get('channels', {}))} channels")
else:
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
except Exception:
self.logger.info("Successfully loaded database")
return data
except (json.JSONDecodeError, ValueError) as e:
@@ -75,9 +154,9 @@ class DuckDB:
"""Create a new default database file with proper structure"""
try:
default_data = {
"players": {},
"channels": {},
"last_save": str(time.time()),
"version": "1.0",
"version": "2.0",
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"description": "DuckHunt Bot Player Database"
}
@@ -92,9 +171,9 @@ class DuckDB:
self.logger.error(f"Failed to create default database: {e}")
# Return a minimal valid structure even if file creation fails
return {
"players": {},
"channels": {},
"last_save": str(time.time()),
"version": "1.0",
"version": "2.0",
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
"description": "DuckHunt Bot Player Database"
}
@@ -201,26 +280,40 @@ class DuckDB:
try:
# Prepare data with validation
data = {
'players': {},
'channels': {},
'last_save': str(time.time()),
'version': '1.0'
'version': '2.0'
}
# Validate and clean player data before saving
valid_count = 0
for nick, player_data in self.players.items():
if isinstance(nick, str) and isinstance(player_data, dict):
try:
sanitized_nick = sanitize_user_input(nick, max_length=50)
data['players'][sanitized_nick] = self._sanitize_player_data(player_data)
valid_count += 1
except Exception as e:
self.logger.warning(f"Error processing player {nick} during save: {e}")
else:
self.logger.warning(f"Skipping invalid player data during save: {nick}")
for channel_name, channel_players in self.players.items():
if not isinstance(channel_name, str) or not isinstance(channel_players, dict):
continue
if valid_count == 0:
raise ValueError("No valid player data to save")
safe_channel = sanitize_user_input(
channel_name,
max_length=100,
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
)
if not safe_channel or not (safe_channel.startswith('#') or safe_channel.startswith('&')):
continue
data['channels'].setdefault(safe_channel, {'players': {}})
for nick, player_data in channel_players.items():
if isinstance(nick, str) and isinstance(player_data, dict):
try:
sanitized_nick = sanitize_user_input(nick, max_length=50)
if not sanitized_nick:
continue
data['channels'][safe_channel]['players'][sanitized_nick.lower()] = self._sanitize_player_data(player_data)
valid_count += 1
except Exception as e:
self.logger.warning(f"Error processing player {nick} in {safe_channel} during save: {e}")
# Saving an empty database is valid (e.g., first run or after admin wipes).
# Previously this raised and prevented the file from being written/updated.
# Write to temporary file first (atomic write)
with open(temp_file, 'w', encoding='utf-8') as f:
@@ -257,7 +350,79 @@ class DuckDB:
except Exception:
pass
def get_player(self, nick):
def get_players_for_channel(self, channel: Optional[str]) -> dict:
"""Get the mutable player dict for a channel, creating the channel bucket if needed."""
ch = self._normalize_channel(channel)
if ch not in self.players or not isinstance(self.players.get(ch), dict):
self.players[ch] = {}
return self.players[ch]
def get_player_if_exists(self, nick: str, channel: Optional[str]) -> Optional[dict]:
"""Get an existing player record for a channel without creating one."""
try:
if not isinstance(nick, str) or not nick.strip():
return None
nick_clean = sanitize_user_input(
nick,
max_length=50,
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
)
nick_lower = (nick_clean or '').lower().strip()
if not nick_lower:
return None
ch = self._normalize_channel(channel)
channel_players = self.players.get(ch)
if not isinstance(channel_players, dict):
return None
player = channel_players.get(nick_lower)
if isinstance(player, dict):
return player
return None
except Exception:
return None
def get_global_duck_totals(self, nick: str, channels: list) -> dict:
"""Sum ducks_shot/ducks_befriended for a user across the provided channels."""
total_shot = 0
total_bef = 0
channels_counted = 0
for ch in channels or []:
if not isinstance(ch, str):
continue
player = self.get_player_if_exists(nick, ch)
if not player:
continue
channels_counted += 1
try:
total_shot += int(player.get('ducks_shot', 0) or 0)
except Exception:
pass
try:
total_bef += int(player.get('ducks_befriended', 0) or 0)
except Exception:
pass
return {
'nick': nick,
'ducks_shot': total_shot,
'ducks_befriended': total_bef,
'total_ducks': total_shot + total_bef,
'channels_counted': channels_counted,
}
def iter_all_players(self):
"""Yield (channel, nick, player_dict) for all players."""
for ch, players in (self.players or {}).items():
if not isinstance(players, dict):
continue
for nick, pdata in players.items():
if isinstance(nick, str) and isinstance(pdata, dict):
yield ch, nick, pdata
def get_player(self, nick, channel: Optional[str] = None):
"""Get player data, creating if doesn't exist with comprehensive validation"""
try:
# Validate and sanitize nick
@@ -278,14 +443,16 @@ class DuckDB:
self.logger.warning(f"Empty nick after sanitization: {nick}")
return self.create_player('Unknown')
if nick_lower not in self.players:
self.players[nick_lower] = self.create_player(nick_clean)
channel_players = self.get_players_for_channel(channel)
if nick_lower not in channel_players:
channel_players[nick_lower] = self.create_player(nick_clean)
else:
# Ensure existing players have all required fields
player = self.players[nick_lower]
player = channel_players[nick_lower]
if not isinstance(player, dict):
self.logger.warning(f"Invalid player data for {nick_lower}, recreating")
self.players[nick_lower] = self.create_player(nick_clean)
channel_players[nick_lower] = self.create_player(nick_clean)
else:
# Migrate and validate existing player data with error recovery
validated = self.error_recovery.safe_execute(
@@ -293,9 +460,9 @@ class DuckDB:
fallback=self.create_player(nick_clean),
logger=self.logger
)
self.players[nick_lower] = validated
channel_players[nick_lower] = validated
return self.players[nick_lower]
return channel_players[nick_lower]
except Exception as e:
self.logger.error(f"Critical error getting player {nick}: {e}")
@@ -409,11 +576,16 @@ class DuckDB:
}
def get_leaderboard(self, category='xp', limit=3):
"""Get top players by specified category"""
"""Get top players by specified category (default channel)."""
return self.get_leaderboard_for_channel(self._default_channel(), category=category, limit=limit)
def get_leaderboard_for_channel(self, channel: Optional[str], category='xp', limit=3):
"""Get top players for a channel by specified category"""
try:
leaderboard = []
for nick, player_data in self.players.items():
channel_players = self.get_players_for_channel(channel)
for nick, player_data in channel_players.items():
sanitized_data = self._sanitize_player_data(player_data)
if category == 'xp':

View File

@@ -39,6 +39,9 @@ class DuckHuntBot:
self.sasl_handler = SASLHandler(self, config)
# Config file path for persisting runtime config changes (e.g., admin join/leave).
self.config_file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json')
# Set up health checks
self._setup_health_checks()
@@ -58,7 +61,7 @@ class DuckHuntBot:
# Database health check
self.health_checker.add_check(
'database',
lambda: self.db is not None and len(self.db.players) >= 0,
lambda: self.db is not None and sum(1 for _ in self.db.iter_all_players()) >= 0,
critical=True
)
@@ -130,10 +133,8 @@ class DuckHuntBot:
return False
target = args[0].lower()
player = self.db.get_player(target)
if player is None:
player = self.db.create_player(target)
self.db.players[target] = player
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
player = self.db.get_player(target, channel_ctx)
action_func(player)
message = self.messages.get(success_message_key, target=target, admin=nick)
@@ -147,29 +148,21 @@ class DuckHuntBot:
Returns (player, error_message) - if error_message is not None, command should return early.
"""
is_private_msg = not channel.startswith('#')
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
if not is_private_msg:
if target_nick.lower() == nick.lower():
target_nick = target_nick.lower()
player = self.db.get_player(target_nick)
if player is None:
player = self.db.create_player(target_nick)
self.db.players[target_nick] = player
player = self.db.get_player(target_nick, channel_ctx)
return player, None
else:
is_valid, player, error_msg = self.validate_target_player(target_nick, channel)
if not is_valid:
return None, error_msg
target_nick = target_nick.lower()
if target_nick not in self.db.players:
self.db.players[target_nick] = player
return player, None
else:
target_nick = target_nick.lower()
player = self.db.get_player(target_nick)
if player is None:
player = self.db.create_player(target_nick)
self.db.players[target_nick] = player
player = self.db.get_player(target_nick, channel_ctx)
return player, None
def _get_validated_target_player(self, nick, channel, target_nick):
@@ -566,8 +559,9 @@ class DuckHuntBot:
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
# Get player data with error recovery
channel_ctx = safe_channel if isinstance(safe_channel, str) and safe_channel.startswith('#') else None
player = self.error_recovery.safe_execute(
lambda: self.db.get_player(nick),
lambda: self.db.get_player(nick, channel_ctx),
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
logger=self.logger
)
@@ -580,7 +574,6 @@ class DuckHuntBot:
try:
player['last_activity_channel'] = safe_channel
player['last_activity_time'] = time.time()
self.db.players[nick.lower()] = player
except Exception as e:
self.logger.warning(f"Error updating player activity for {nick}: {e}")
@@ -672,6 +665,13 @@ class DuckHuntBot:
fallback=None,
logger=self.logger
)
elif cmd == "globalducks":
command_executed = True
await self.error_recovery.safe_execute_async(
lambda: self.handle_globalducks(nick, channel, safe_args, user),
fallback=None,
logger=self.logger
)
elif cmd == "rearm" and self.is_admin(user):
command_executed = True
await self.error_recovery.safe_execute_async(
@@ -707,6 +707,20 @@ class DuckHuntBot:
fallback=None,
logger=self.logger
)
elif cmd == "join" and self.is_admin(user):
command_executed = True
await self.error_recovery.safe_execute_async(
lambda: self.handle_join_channel(nick, channel, safe_args),
fallback=None,
logger=self.logger
)
elif (cmd == "leave" or cmd == "part") and self.is_admin(user):
command_executed = True
await self.error_recovery.safe_execute_async(
lambda: self.handle_leave_channel(nick, channel, safe_args),
fallback=None,
logger=self.logger
)
# If no command was executed, it might be an unknown command
if not command_executed:
@@ -744,7 +758,8 @@ class DuckHuntBot:
if not target_nick:
return False, None, "Invalid target nickname"
player = self.db.get_player(target_nick)
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
player = self.db.get_player(target_nick, channel_ctx)
if not player:
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
@@ -768,7 +783,8 @@ class DuckHuntBot:
We assume if someone has been active recently, they're still in the channel.
"""
try:
player = self.db.get_player(nick)
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
player = self.db.get_player(nick, channel_ctx)
if not player:
return False
@@ -887,7 +903,8 @@ class DuckHuntBot:
"""Handle !duckstats command"""
if args and len(args) > 0:
target_nick = args[0]
target_player = self.db.get_player(target_nick)
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
target_player = self.db.get_player(target_nick, channel_ctx)
if not target_player:
message = f"{nick} > Player '{target_nick}' not found."
self.send_message(channel, message)
@@ -980,11 +997,13 @@ class DuckHuntBot:
bold = self.messages.messages.get('colours', {}).get('bold', '')
reset = self.messages.messages.get('colours', {}).get('reset', '')
# Get top 3 by XP
top_xp = self.db.get_leaderboard('xp', 3)
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
# Get top 3 by ducks shot
top_ducks = self.db.get_leaderboard('ducks_shot', 3)
# Get top 3 by XP (channel-scoped)
top_xp = self.db.get_leaderboard_for_channel(channel_ctx, 'xp', 3)
# Get top 3 by ducks shot (channel-scoped)
top_ducks = self.db.get_leaderboard_for_channel(channel_ctx, 'ducks_shot', 3)
# Format XP leaderboard as single line
if top_xp:
@@ -1013,19 +1032,216 @@ class DuckHuntBot:
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
async def handle_duckhelp(self, nick, channel, _player):
"""Handle !duckhelp command"""
"""Handle !duckhelp command
Sends help to the user via private message (PM/DM) with examples.
"""
dm_target = nick # PRIVMSG target for a PM is the nick
help_lines = [
self.messages.get('help_header'),
self.messages.get('help_user_commands'),
self.messages.get('help_help_command')
"DuckHunt Commands (sent via PM)",
"Player commands:",
"- !bang — shoot when a duck appears. Example: !bang",
"- !reload — reload your weapon. Example: !reload",
"- !shop — list shop items. Example: !shop",
"- !buy <item_id> — buy from shop. Example: !buy 3",
"- !use <item_id> [target] — use an inventory item. Example: !use 7 OR !use 9 SomeNick",
"- !duckstats [player] — view stats/inventory. Example: !duckstats OR !duckstats SomeNick",
"- !topduck — show leaderboards. Example: !topduck",
"- !give <item_id> <player> — gift an owned item. Example: !give 2 SomeNick",
"- !globalducks [player] — duck totals across all configured channels. Example: !globalducks OR !globalducks SomeNick",
"- !duckhelp — show this help. Example: !duckhelp",
]
# Add admin commands if user is admin
if self.is_admin(f"{nick}!user@host"):
help_lines.append(self.messages.get('help_admin_commands'))
# Include admin commands only for admins.
# (Using nick list avoids relying on hostmask parsing.)
if nick.lower() in self.admins:
help_lines.extend([
"Admin commands:",
"- !rearm <player|all> — rearm a player. Example: !rearm SomeNick OR !rearm all",
"- !disarm <player> — confiscate gun. Example: !disarm SomeNick",
"- !ignore <player> — ignore a player. Example: !ignore SomeNick",
"- !unignore <player> — unignore a player. Example: !unignore SomeNick",
"- !ducklaunch [duck_type] — force spawn. Example: !ducklaunch golden",
"- (PM) !ducklaunch <#channel> [duck_type] — Example: !ducklaunch #ct fast",
"- !join <#channel> — make the bot join a channel. Example: !join #ct",
"- !leave <#channel> — make the bot leave a channel. Example: !leave #ct",
])
for line in help_lines:
self.send_message(channel, line)
self.send_message(dm_target, line)
# If invoked in a channel, add a brief confirmation to check PMs.
if isinstance(channel, str) and channel.startswith('#'):
self.send_message(channel, f"{nick} > I sent you a PM with commands and examples. Please check your PM window.")
async def handle_globalducks(self, nick, channel, args, user):
"""User: !globalducks [player] — totals across all configured channels.
Non-admins can query themselves only. Admins can query other nicks.
"""
try:
channels = self.get_config('connection.channels', []) or []
if not isinstance(channels, list):
channels = []
target_nick = nick
if args and len(args) >= 1:
requested = sanitize_user_input(
str(args[0]),
max_length=50,
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
)
if requested:
target_nick = requested
# Anyone can query anyone via !globalducks <player>
totals = self.db.get_global_duck_totals(target_nick, channels)
shot = totals.get('ducks_shot', 0)
bef = totals.get('ducks_befriended', 0)
total = totals.get('total_ducks', shot + bef)
counted = totals.get('channels_counted', 0)
if not channels:
self.send_message(channel, f"{nick} > No configured channels to total.")
return
self.send_message(
channel,
f"{nick} > Global totals for {target_nick} across configured channels: {shot} shot, {bef} befriended ({total} total) [{counted}/{len(channels)} channels have stats]."
)
except Exception as e:
self.logger.error(f"Error in handle_globalducks: {e}")
self.send_message(channel, f"{nick} > Error calculating global totals.")
def _sanitize_channel_name(self, channel_name: str) -> str:
"""Validate/sanitize an IRC channel name."""
if not isinstance(channel_name, str):
return ""
safe = sanitize_user_input(
channel_name.strip(),
max_length=100,
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
)
if not safe:
return ""
if not (safe.startswith('#') or safe.startswith('&')):
return ""
return safe
def _config_channels_list(self):
"""Return the mutable in-memory config channel list, creating it if needed."""
if not isinstance(self.config, dict):
self.config = {}
connection = self.config.get('connection')
if not isinstance(connection, dict):
connection = {}
self.config['connection'] = connection
channels = connection.get('channels')
if not isinstance(channels, list):
channels = []
connection['channels'] = channels
return channels
def _persist_config(self) -> bool:
"""Persist current config to disk (best-effort, atomic write)."""
try:
config_dir = os.path.dirname(self.config_file_path)
os.makedirs(config_dir, exist_ok=True)
tmp_path = f"{self.config_file_path}.tmp"
with open(tmp_path, 'w', encoding='utf-8') as f:
# Keep it stable/human-readable.
import json
json.dump(self.config, f, indent=4, ensure_ascii=False)
f.write("\n")
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, self.config_file_path)
return True
except Exception as e:
self.logger.error(f"Failed to persist config to disk: {e}")
return False
async def handle_join_channel(self, nick, channel, args):
"""Admin: !join <#channel> (supports PM and channel invocation)."""
if not args:
self.send_message(channel, f"{nick} > Usage: !join <#channel>")
return
target_channel = self._sanitize_channel_name(args[0])
if not target_channel:
self.send_message(channel, f"{nick} > Invalid channel. Usage: !join <#channel>")
return
if target_channel in self.channels_joined:
self.send_message(channel, f"{nick} > I'm already in {target_channel}.")
return
if not self.send_raw(f"JOIN {target_channel}"):
self.send_message(channel, f"{nick} > Couldn't send JOIN (not connected?).")
return
# Track it immediately (we also reconcile on actual JOIN server message).
self.channels_joined.add(target_channel)
# Update in-memory config so reconnects keep the new channel.
channels = self._config_channels_list()
if target_channel not in channels:
channels.append(target_channel)
# Persist across restarts
if not self._persist_config():
self.send_message(channel, f"{nick} > Joined {target_channel}, but failed to write config.json (check permissions).")
return
self.send_message(channel, f"{nick} > Joining {target_channel}.")
async def handle_leave_channel(self, nick, channel, args):
"""Admin: !leave <#channel> / !part <#channel> (supports PM and channel invocation)."""
if not args:
self.send_message(channel, f"{nick} > Usage: !leave <#channel>")
return
target_channel = self._sanitize_channel_name(args[0])
if not target_channel:
self.send_message(channel, f"{nick} > Invalid channel. Usage: !leave <#channel>")
return
# Cancel any pending rejoin attempts and forget state.
if target_channel in self.rejoin_tasks:
try:
self.rejoin_tasks[target_channel].cancel()
except Exception:
pass
del self.rejoin_tasks[target_channel]
if target_channel in self.rejoin_attempts:
del self.rejoin_attempts[target_channel]
self.channels_joined.discard(target_channel)
# Update in-memory config so reconnects do not rejoin the channel.
channels = self._config_channels_list()
try:
while target_channel in channels:
channels.remove(target_channel)
except Exception:
pass
# Persist across restarts
if not self._persist_config():
self.send_message(channel, f"{nick} > Removed {target_channel} from my channel list, but failed to write config.json (check permissions).")
# Continue attempting PART anyway.
# Send PART even if we don't think we're in it (server will ignore or error).
if not self.send_raw(f"PART {target_channel} :Requested by {nick}"):
self.send_message(channel, f"{nick} > Couldn't send PART (not connected?).")
return
self.send_message(channel, f"{nick} > Leaving {target_channel}.")
async def handle_use(self, nick, channel, player, args):
"""Handle !use command"""
@@ -1212,7 +1428,8 @@ class DuckHuntBot:
# Check if admin wants to rearm all players
if target_nick.lower() == 'all':
rearmed_count = 0
for player_nick, player in self.db.players.items():
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
for player_nick, player in self.db.get_players_for_channel(channel_ctx).items():
if player.get('gun_confiscated', False):
player['gun_confiscated'] = False
self.levels.update_player_magazines(player)
@@ -1251,10 +1468,8 @@ class DuckHuntBot:
return
# Rearm the admin themselves (only in channels)
player = self.db.get_player(nick)
if player is None:
player = self.db.create_player(nick)
self.db.players[nick.lower()] = player
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
player = self.db.get_player(nick, channel_ctx)
player['gun_confiscated'] = False
@@ -1317,10 +1532,8 @@ class DuckHuntBot:
return
target = args[0].lower()
player = self.db.get_player(target)
if player is None:
player = self.db.create_player(target)
self.db.players[target] = player
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
player = self.db.get_player(target, channel_ctx)
action_func(player)

View File

@@ -34,12 +34,20 @@ class DuckGame:
"""Duck spawning loop with responsive shutdown"""
try:
while True:
# Pick a target channel first so spawn multipliers are per-channel
channels = list(self.bot.channels_joined)
if not channels:
await asyncio.sleep(1)
continue
channel = random.choice(channels)
# Wait random time between spawns, but in small chunks for responsiveness
min_wait = self.bot.get_config('duck_spawning.spawn_min', 300) # 5 minutes
max_wait = self.bot.get_config('duck_spawning.spawn_max', 900) # 15 minutes
# Check for active bread effects to modify spawn timing
spawn_multiplier = self._get_active_spawn_multiplier()
spawn_multiplier = self._get_active_spawn_multiplier(channel)
if spawn_multiplier > 1.0:
# Reduce wait time when bread is active
min_wait = int(min_wait / spawn_multiplier)
@@ -51,10 +59,8 @@ class DuckGame:
for _ in range(wait_time):
await asyncio.sleep(1)
# Spawn duck in random channel
channels = list(self.bot.channels_joined)
if channels:
channel = random.choice(channels)
# Spawn duck in the chosen channel (if still joined)
if channel in self.bot.channels_joined:
await self.spawn_duck(channel)
except asyncio.CancelledError:
@@ -284,7 +290,7 @@ class DuckGame:
# If config option enabled, rearm all disarmed players when duck is shot
if self.bot.get_config('duck_spawning.rearm_on_duck_shot', False):
self._rearm_all_disarmed_players()
self._rearm_all_disarmed_players(channel)
# Check for item drops
dropped_item = self._check_item_drop(player, duck_type)
@@ -324,7 +330,7 @@ class DuckGame:
if random.random() < friendly_fire_chance:
# Get other armed players in the same channel
armed_players = []
for other_nick, other_player in self.db.players.items():
for other_nick, other_player in self.db.get_players_for_channel(channel).items():
if (other_nick.lower() != nick.lower() and
not other_player.get('gun_confiscated', False) and
other_player.get('current_ammo', 0) > 0):
@@ -424,7 +430,7 @@ class DuckGame:
# If config option enabled, rearm all disarmed players when duck is befriended
if self.bot.get_config('rearm_on_duck_shot', False):
self._rearm_all_disarmed_players()
self._rearm_all_disarmed_players(channel)
self.db.save_database()
return {
@@ -494,11 +500,11 @@ class DuckGame:
}
}
def _rearm_all_disarmed_players(self):
"""Rearm all players who have been disarmed (gun confiscated)"""
def _rearm_all_disarmed_players(self, channel):
"""Rearm all players who have been disarmed (gun confiscated) in a channel"""
try:
rearmed_count = 0
for player_name, player_data in self.db.players.items():
for player_name, player_data in self.db.get_players_for_channel(channel).items():
if player_data.get('gun_confiscated', False):
player_data['gun_confiscated'] = False
# Update magazines based on player level
@@ -511,14 +517,14 @@ class DuckGame:
except Exception as e:
self.logger.error(f"Error in _rearm_all_disarmed_players: {e}")
def _get_active_spawn_multiplier(self):
"""Get the current spawn rate multiplier from active bread effects"""
def _get_active_spawn_multiplier(self, channel):
"""Get the current spawn rate multiplier from active bread effects in a channel"""
import time
max_multiplier = 1.0
current_time = time.time()
try:
for player_name, player_data in self.db.players.items():
for player_name, player_data in self.db.get_players_for_channel(channel).items():
effects = player_data.get('temporary_effects', [])
for effect in effects:
if (effect.get('type') == 'attract_ducks' and
@@ -566,7 +572,7 @@ class DuckGame:
current_time = time.time()
try:
for player_name, player_data in self.db.players.items():
for _channel, player_name, player_data in self.db.iter_all_players():
effects = player_data.get('temporary_effects', [])
active_effects = []

View File

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