Compare commits

...

39 Commits

Author SHA1 Message Date
3nd3r
77ed3f95ad Update bot 2026-01-01 10:45:59 -06:00
3nd3r
68a0a1fc83 Update DuckHunt bot 2026-01-01 10:42:12 -06:00
3nd3r
292db0c95e PM-only admin reload restarts bot; raise clover price 2025-12-30 23:21:48 -06:00
3nd3r
b944e234d6 Fix file mode (shop.json non-executable) 2025-12-30 23:19:45 -06:00
3nd3r
735c46b8c2 Add clover luck item and admin restart command 2025-12-30 23:19:37 -06:00
3nd3r
38d9159f50 Label legacy bucket in globaltop 2025-12-30 22:56:15 -06:00
3nd3r
a1afd25053 Simplify globaltop output 2025-12-30 22:54:07 -06:00
3nd3r
1e4e1e31ba Format globaltop channel labels 2025-12-30 22:53:12 -06:00
3nd3r
9bff554a07 Fix file mode (non-executable) 2025-12-30 22:49:02 -06:00
3nd3r
2372075195 Add globaltop leaderboard command 2025-12-30 22:49:02 -06:00
3nd3r
2ef81cdd26 Fix file modes (non-executable) 2025-12-30 22:43:30 -06:00
3nd3r
67bf6957a7 Separate player stats per channel 2025-12-30 22:43:23 -06:00
3nd3r
214d1ed263 Fix file modes 2025-12-29 23:36:18 -06:00
3nd3r
f47778e608 Load messages.json from repo root 2025-12-29 23:35:38 -06:00
3nd3r
7147f5f30c Add more duck spawn quacks 2025-12-29 23:32:04 -06:00
3nd3r
51c212e8cd Fix JOIN confirmation tracking for auto-rejoin 2025-12-28 23:11:21 -06:00
3nd3r
5efe1e70bf Fix rejoin loop crash when pending_joins missing 2025-12-28 23:08:04 -06:00
3nd3r
a8b4196cf2 Fix auto-rejoin after KICK to rejoin immediately 2025-12-28 23:04:37 -06:00
3nd3r
5e4bbb4309 Update README to reflect current features
- Removed references to removed duck types (concrete, diamond, explosive, etc.)
- Removed references to removed items (sniper rifle, duck radar, etc.)
- Updated to show only 3 duck types: normal, golden, fast
- Documented current commands and features
- Added Recent Updates section
- Cleaned up and organized for clarity
2025-12-28 17:58:01 -06:00
3nd3r
53a66f5202 Update duckhelp to send PM with detailed commands 2025-12-28 17:52:02 -06:00
3nd3r
b71f8f4ec6 Add join and part admin commands for channel management 2025-12-28 17:49:31 -06:00
3nd3r
5db4ce0ab3 Revert to original working code - clean slate
- Start from original code that was known to work
- Will add features back incrementally
- This ensures we know exactly what breaks/works
2025-12-28 17:43:11 -06:00
3nd3r
90b604ba72 Add force_spawn_duck for admin ducklaunch - fixes missing method error 2025-12-28 16:51:26 -06:00
3nd3r
dd06c9377f Revert to simple version: Remove new ducks and items
- Removed new duck types (concrete, diamond, holy_grail, explosive, poisonous, etc.)
- Removed new shop items (sniper rifle, duck radar, bread, splash water, etc.)
- Removed status effects system (eliminated, poisoned, wet, etc.)
- Removed item drops and temporary effects
- Kept only original 3 duck types: normal, golden, fast
- Kept original simple shop
- KEPT BUG FIX: Golden duck XP now awarded on each hit
- KEPT BUG FIX: Message sanitization preserves IRC codes
- Simpler, more stable bot with core improvements
2025-12-28 16:48:56 -06:00
3nd3r
eb907e1e2c Add original/backup code for reference
- Original working code before recent modifications
- Kept for comparison and debugging purposes
- Helps identify what changed and what broke
2025-12-28 16:29:47 -06:00
3nd3r
f6a9f4592a Fix critical bug: Messages being stripped by over-aggressive sanitization
- Bot messages containing IRC color codes were being completely stripped
- sanitize_user_input() without allowed_chars was removing all formatting
- Changed to only remove CR/LF from messages while preserving formatting codes
- This was causing silent failures where no messages were sent to channel
2025-12-28 16:03:42 -06:00
3nd3r
6069240553 Fix critical bug: Award XP on multi-HP duck hits
- Fixed issue where players weren't getting XP when hitting (but not killing) multi-HP ducks
- XP was calculated but never added to player's total
- This bug could cause bot failures with new multi-HP duck types
- Now properly awards XP on each hit and saves to database
2025-12-28 16:00:40 -06:00
3nd3r
f3f251a391 Add explicit error replies for core commands 2025-12-28 15:49:07 -06:00
3nd3r
3e7436840e Fix: remove blocking sleep, unreachable code, and unused admin helper 2025-12-28 15:40:59 -06:00
3nd3r
9bd51a24cc Accept leading-whitespace commands; log spawn send failures 2025-12-28 15:33:17 -06:00
3nd3r
d5654e9783 Normalize channel names for join tracking and commands 2025-12-28 15:30:17 -06:00
3nd3r
ba9beae82f Harden temporary effect parsing to prevent silent command failures 2025-12-28 15:24:46 -06:00
3nd3r
7d85f83faa Fix JOIN parsing for trailing channel and rejoin success handling 2025-12-28 15:19:49 -06:00
3nd3r
02c055d7e3 Log IRC join failures and confirm joins 2025-12-28 15:12:30 -06:00
3nd3r
617d9560e6 Restrict PM spawns to normal/fast/golden 2025-12-28 14:34:14 -06:00
3nd3r
3b72a853ae Allow ducklaunch for new duck types 2025-12-28 14:31:45 -06:00
3nd3r
ffe8bdfaf2 Update README for new duck types and items 2025-12-28 13:50:08 -06:00
3nd3r
b256b9a9f6 Add new duck types and items 2025-12-28 13:36:41 -06:00
3nd3r
4d17ae8f04 Prepare for GitHub release 2025-12-28 13:16:55 -06:00
12 changed files with 972 additions and 419 deletions

6
.gitignore vendored
View File

@@ -130,8 +130,10 @@ 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
duckhunt.json.bak
# IDE files
.vscode/

45
MULTI_CHANNEL_PLAN.md Normal file
View File

@@ -0,0 +1,45 @@
# Multi-Channel Support Implementation
## What Multi-Channel Means
- Players have **separate stats in each channel**
- Nick "Bob" in #channel1 has different XP than "Bob" in #channel2
- Database structure: `channels -> #channel1 -> players -> bob`
## Changes Needed
### 1. Database Structure (db.py)
```python
{
"channels": {
"#channel1": {
"players": {
"bob": { "xp": 100, ... },
"alice": { "xp": 50, ... }
}
},
"#channel2": {
"players": {
"bob": { "xp": 20, ... } # Different stats!
}
}
}
}
```
### 2. Database Methods
- `get_player(nick, channel)` - Get player in specific channel
- `get_players_for_channel(channel)` - Get all players in a channel
- `iter_all_players()` - Iterate over all channels and players
### 3. Command Changes (duckhuntbot.py)
- Pass `channel` parameter when calling `db.get_player(nick, channel)`
- Channel normalization (case-insensitive)
### 4. Stats Commands
- `!duckstats` shows stats for current channel
- `!globalducks` shows combined stats across all channels
## Benefits
- Fair: Can't bring channel1 XP into channel2
- Better: Each channel has own leaderboard
- Clean: Stats don't mix between channels

285
README.md
View File

@@ -1,201 +1,164 @@
# 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
- **Multi-channel support** - Bot can be in multiple channels
- **Global player stats** - Same player stats across all channels
- **Three duck types** - Normal, Golden (multi-HP), and Fast ducks
- **Shop system** - Buy items to improve your hunting
- **Leveling system** - Gain XP and increase your level
- **Admin commands** - Join/leave channels, spawn ducks, manage players
- **JSON persistence** - All stats saved to disk
- **Auto-save** - Progress saved automatically after each action
## Quick Start
### Requirements
- Python 3.8+
- Virtual environment (recommended)
### Installation
### Run
From the repo root:
1. **Clone the repository:**
```bash
git clone https://github.com/your-username/duckhunt-bot.git
cd duckhunt-bot
python3 duckhunt.py
```
2. **Set up virtual environment:**
## Configuration
Copy the example config and edit it:
```bash
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# or
.venv\Scripts\activate # Windows
cp config.json.example config.json
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
Then edit `config.json`:
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
- `connection.server`, `connection.port`, `connection.nick`
- `connection.channels` (list of channels to join on connect)
- `connection.ssl` and optional password/SASL settings
- `admins` (list of admin nicks or nick+hostmask patterns)
5. **Run the bot:**
```bash
python duckhunt.py
```
**Security note:** `config.json` is ignored by git - don't commit real IRC passwords/tokens.
## ⚙️ Configuration
### Duck Types
The bot uses a nested JSON configuration system. Key settings include:
Three duck types with different behaviors:
### Connection Settings
```json
{
"connection": {
"server": "irc.your-server.net",
"port": 6697,
"nick": "DuckHunt",
"channels": ["#your-channel"],
"ssl": true
}
}
```
- **Normal** - Standard duck, 1 HP, base XP
- **Golden** - Multi-HP duck (3-5 HP), high XP, awards XP per hit
- **Fast** - Quick duck, 1 HP, flies away faster
### 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 }
}
}
```
Duck spawn rates configured in `config.json`:
- `golden_duck_chance` - Probability of golden duck (default: 0.15)
- `fast_duck_chance` - Probability of fast duck (default: 0.25)
**📖 See [CONFIG.md](CONFIG.md) for complete configuration documentation.**
## Persistence
## 🎮 Game Commands
Player stats are saved to `duckhunt.json`:
- **Global stats** - Players have one set of stats across all channels
- **Auto-save** - Database saved after each action (shoot, reload, shop, etc.)
- **Atomic writes** - Safe file handling prevents database corruption
- **Retry logic** - Automatic retry on save failures
## Commands
### 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
- `!bang` - Shoot at a duck
- `!bef` or `!befriend` - Try to befriend a duck
- `!reload` - Reload your gun
- `!shop` - View available items
- `!shop buy <item_id>` - Purchase an item from the shop
- `!duckstats [player]` - View hunting statistics
- `!topduck` - View leaderboard (top hunters)
- `!duckhelp` - Get detailed command list via PM
### 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
## Duck Types
- `!rearm <player|all>` - Give player a gun
- `!disarm <player>` - Confiscate player's gun
- `!ignore <player>` / `!unignore <player>` - Ignore/unignore commands
- `!ducklaunch [duck_type]` - Force spawn a duck (normal, golden, fast)
- `!join <#channel>` - Make bot join a channel
- `!part <#channel>` - Make bot leave a channel
| Type | Spawn Rate | HP | Timeout | XP Reward |
|------|------------|----|---------|-----------|
| Normal | 60% | 1 | 60s | 10 |
| Golden | 15% | 3-5 | 60s | 15 |
| Fast | 25% | 1 | 20s | 12 |
Admin commands work in PM or in-channel.
## 🛒 Shop Items
## Shop Items
- **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
Basic shop items available (use `!shop` to see current inventory):
## 📁 Project Structure
- **Bullets** - Ammunition refills
- **Magazines** - Extra ammo capacity
- **Gun Improvements** - Better accuracy, less jamming
- **Gun License** - Buy back your confiscated gun
- **Insurance** - Protection from penalties
Use `!shop buy <id>` to purchase.
## Gameplay
### How to Play
1. Wait for a duck to spawn (appears randomly in channel)
2. Type `!bang` to shoot it
3. Earn XP for successful hits
4. Level up to improve your stats
5. Buy items from `!shop` to enhance your hunting
### Duck Behavior
- **Normal ducks** - Standard targets, 1 shot to kill
- **Golden ducks** - Tougher! Multiple HP, gives XP per hit
- **Fast ducks** - Quick! They fly away faster than normal
### Stats Tracked
- XP (experience points)
- Ducks shot
- Ducks befriended
- Shots fired
- Accuracy percentage
- Current level
## Repo Layout
```
duckhunt/
├── duckhunt.py # Main bot 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.py # Entry point
├── config.json # Bot configuration (ignored by git)
├── config.json.example # Safe template to copy
├── duckhunt.json # Player database (auto-generated)
├── levels.json # Level definitions
├── shop.json # Shop item catalog
├── messages.json # Bot messages
└── src/
├── duckhuntbot.py # IRC bot + command routing
├── game.py # Duck game logic
── db.py # Database persistence
├── shop.py # Shop/inventory system
├── levels.py # Leveling system
├── sasl.py # SASL authentication
├── error_handling.py # Error recovery
└── utils.py # Utility functions
```
## Development
## Recent Updates
### Adding New Features
- ✅ Fixed golden duck XP bug (now awards XP on each hit)
- ✅ Added `!join` and `!part` admin commands
- ✅ Improved `!duckhelp` with detailed PM
- ✅ Simplified to 3 core duck types for stability
- ✅ Enhanced database save reliability
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!**
**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
}
}

171
config.json.example Normal file
View File

@@ -0,0 +1,171 @@
{
"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
},
"concrete": {
"chance": 0.08,
"hp": 3,
"xp": 3,
"timeout": 90,
"drop_chance": 0.15
},
"holy_grail": {
"chance": 0.03,
"hp": 8,
"xp": 10,
"timeout": 120,
"drop_chance": 0.35
},
"diamond": {
"chance": 0.01,
"hp": 10,
"xp": 15,
"timeout": 150,
"drop_chance": 0.5
},
"explosive": {
"chance": 0.02,
"hp": 1,
"xp": 20,
"timeout": 60,
"drop_chance": 0.25
},
"poisonous": {
"chance": 0.02,
"hp": 1,
"xp": 8,
"timeout": 60,
"drop_chance": 0.25
},
"radioactive": {
"chance": 0.005,
"hp": 1,
"xp": 15,
"timeout": 60,
"drop_chance": 0.35
},
"couple": {
"chance": 0.03,
"timeout": 60
},
"family": {
"chance": 0.015,
"timeout": 60
}
},
"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
}
}

View File

@@ -4,7 +4,15 @@
"{light_grey}・゜゜・。。・゜゜{reset}\\_O< {light_grey}QUACK!{reset}",
"・゜゜・。。・゜゜{black}\\_O< QUACK!{reset}",
"・゜゜・。。・゜゜\\_o< quack~",
"・゜゜・。。・゜゜\\_O> *flap flap*"
"・゜゜・。。・゜゜\\_O> *flap flap*",
"-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°·\u000f \u0002\\_O<\u0002 \u000314QUACK\u000f",
"{light_grey}·.¸¸.·´¯`·.¸¸.·´¯`·.{reset} {bold}\\_O<{reset} {light_grey}QUACK!{reset}",
"{cyan}≋≋≋{reset} {bold}\\_O<{reset} {cyan}≋≋≋{reset} {light_grey}QUACK!{reset}",
"{yellow}⟦{reset}{bold}\\_O<{reset}{yellow}⟧{reset} {light_grey}Q U A C K{reset}",
"{bold}\\_O<{reset} {light_grey}*q u a c k*{reset} {grey}(respawned){reset}",
"{light_grey}..:::{reset} {bold}\\_O<{reset} {light_grey}:::..{reset} {light_grey}QUACK!{reset}",
"{bold}\\_O<{reset} {light_grey}≫{reset} {green}Q U A C K{reset} {light_grey}≪{reset}",
"{light_grey}~{reset}{bold}\\_O<{reset}{light_grey}~{reset} {light_grey}QUACK!{reset} {light_grey}~{reset}"
],
"duck_flies_away": [
"The duck flies away. ·°'`'°-.,¸¸.·°'`",

1
original Submodule

Submodule original added at f8c46980de

View File

@@ -61,6 +61,15 @@
"price": 30,
"description": "Change into dry clothes - allows shooting again after being soaked",
"type": "dry_clothes"
},
"10": {
"name": "4-Leaf Clover",
"price": 250,
"description": "Crazy luck for 10 minutes - greatly boosts hit & befriend success",
"type": "clover_luck",
"duration": 600,
"min_hit_chance": 0.95,
"min_befriend_chance": 0.95
}
}
}

192
src/db.py
View File

@@ -15,16 +15,55 @@ class DuckDB:
"""Simplified database management"""
def __init__(self, db_file="duckhunt.json", bot=None):
# 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
self.players = {}
# Channel-scoped data: {"#channel": {"players": {"nick": player_dict}}}
self.channels = {}
self.logger = logging.getLogger('DuckHuntBot.DB')
# Error recovery configuration
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()
# Hydrate in-memory state from disk.
if isinstance(data, dict) and isinstance(data.get('channels'), dict):
self.channels = data['channels']
else:
self.channels = {}
@staticmethod
def _normalize_channel(channel: str) -> str:
"""Normalize channel keys (case-insensitive). Non-channel contexts go to a reserved bucket."""
if not isinstance(channel, str):
return '__unknown__'
channel = channel.strip()
if not channel:
return '__unknown__'
if channel.startswith('#') or channel.startswith('&'):
return channel.lower()
return '__pm__'
@property
def players(self):
"""Backward-compatible flattened view of all players across channels."""
flattened = {}
try:
for _channel_key, channel_data in (self.channels or {}).items():
players = channel_data.get('players', {}) if isinstance(channel_data, dict) else {}
if isinstance(players, dict):
for nick, player in players.items():
# Last-write-wins if the same nick exists in multiple channels.
flattened[nick] = player
except Exception:
return {}
return flattened
def load_database(self) -> dict:
"""Load the database, creating it if it doesn't exist"""
@@ -54,14 +93,39 @@ class DuckDB:
'last_modified': datetime.now().isoformat()
}
# Initialize players section if missing
if 'players' not in data:
data['players'] = {}
# Migrate legacy flat structure (players) -> channels
if 'channels' not in data or not isinstance(data.get('channels'), dict):
legacy_players = data.get('players') if isinstance(data.get('players'), dict) else {}
channels = {}
if isinstance(legacy_players, dict):
for legacy_nick, legacy_player in legacy_players.items():
try:
last_channel = legacy_player.get('last_activity_channel') if isinstance(legacy_player, dict) else None
channel_key = self._normalize_channel(last_channel) if last_channel else '__global__'
channels.setdefault(channel_key, {'players': {}})
if isinstance(channels[channel_key].get('players'), dict):
channels[channel_key]['players'][str(legacy_nick).lower()] = legacy_player
except Exception:
continue
data['channels'] = channels
data['metadata']['version'] = '2.0'
# Ensure channels structure exists
if 'channels' not in data:
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")
total_players = 0
try:
for _c, cdata in data.get('channels', {}).items():
if isinstance(cdata, dict) and isinstance(cdata.get('players'), dict):
total_players += len(cdata['players'])
except Exception:
total_players = 0
self.logger.info(f"Successfully loaded database with {total_players} players across {len(data.get('channels', {}))} channels")
return data
except (json.JSONDecodeError, ValueError) as e:
@@ -75,9 +139,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 +156,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"
}
@@ -123,6 +187,14 @@ class DuckDB:
sanitized['accuracy'] = max(0, min(max_accuracy, int(float(player_data.get('accuracy', default_accuracy)))))
sanitized['gun_confiscated'] = bool(player_data.get('gun_confiscated', False))
# Activity / admin flags
sanitized['last_activity_channel'] = str(player_data.get('last_activity_channel', ''))[:100]
try:
sanitized['last_activity_time'] = float(player_data.get('last_activity_time', 0.0))
except (ValueError, TypeError):
sanitized['last_activity_time'] = 0.0
sanitized['ignored'] = bool(player_data.get('ignored', False))
# Ammo system with validation
sanitized['current_ammo'] = max(0, min(50, int(float(player_data.get('current_ammo', default_bullets_per_mag)))))
sanitized['magazines'] = max(0, min(20, int(float(player_data.get('magazines', default_magazines)))))
@@ -201,26 +273,33 @@ 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():
for channel_key, channel_data in (self.channels or {}).items():
if not isinstance(channel_key, str) or not isinstance(channel_data, dict):
continue
players = channel_data.get('players', {})
if not isinstance(players, dict):
continue
out_channel_key = str(channel_key)
data['channels'].setdefault(out_channel_key, {'players': {}})
for nick, player_data in 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)
data['channels'][out_channel_key]['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}")
self.logger.warning(f"Error processing player {nick} in {out_channel_key} during save: {e}")
if valid_count == 0:
raise ValueError("No valid player data to save")
# 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,8 +336,52 @@ class DuckDB:
except Exception:
pass
def get_player(self, nick):
"""Get player data, creating if doesn't exist with comprehensive validation"""
def get_players_for_channel(self, channel: str) -> dict:
"""Get the players dict for a channel, creating the channel bucket if needed."""
channel_key = self._normalize_channel(channel)
bucket = self.channels.setdefault(channel_key, {'players': {}})
if not isinstance(bucket, dict):
bucket = {'players': {}}
self.channels[channel_key] = bucket
if 'players' not in bucket or not isinstance(bucket.get('players'), dict):
bucket['players'] = {}
return bucket['players']
def iter_all_players(self):
"""Yield (channel_key, nick, player_dict) for all players in all channels."""
for channel_key, channel_data in (self.channels or {}).items():
if not isinstance(channel_data, dict):
continue
players = channel_data.get('players', {})
if not isinstance(players, dict):
continue
for nick, player in players.items():
yield channel_key, nick, player
def get_player_if_exists(self, nick, channel: str):
"""Return player dict for nick+channel if present; does not create records."""
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.lower().strip()
if not nick_lower:
return None
channel_key = self._normalize_channel(channel)
channel_data = self.channels.get(channel_key)
if not isinstance(channel_data, dict):
return None
players = channel_data.get('players')
if not isinstance(players, dict):
return None
player = players.get(nick_lower)
return player if isinstance(player, dict) else None
except Exception:
return None
def get_player(self, nick, channel: str):
"""Get player data for a specific channel, creating if doesn't exist with comprehensive validation"""
try:
# Validate and sanitize nick
if not isinstance(nick, str) or not nick.strip():
@@ -278,14 +401,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)
players = self.get_players_for_channel(channel)
if nick_lower not in players:
players[nick_lower] = self.create_player(nick_clean)
else:
# Ensure existing players have all required fields
player = self.players[nick_lower]
player = 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)
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 +418,9 @@ class DuckDB:
fallback=self.create_player(nick_clean),
logger=self.logger
)
self.players[nick_lower] = validated
players[nick_lower] = validated
return self.players[nick_lower]
return players[nick_lower]
except Exception as e:
self.logger.error(f"Critical error getting player {nick}: {e}")
@@ -363,6 +488,9 @@ class DuckDB:
'confiscated_magazines': 0,
'inventory': {},
'temporary_effects': [],
'last_activity_channel': '',
'last_activity_time': 0.0,
'ignored': False,
# Additional fields to prevent KeyErrors
'best_time': 0.0,
'worst_time': 0.0,
@@ -395,6 +523,9 @@ class DuckDB:
'confiscated_magazines': 0,
'inventory': {},
'temporary_effects': [],
'last_activity_channel': '',
'last_activity_time': 0.0,
'ignored': False,
'best_time': 0.0,
'worst_time': 0.0,
'total_time_hunting': 0.0,
@@ -408,12 +539,13 @@ class DuckDB:
'chargers': 2
}
def get_leaderboard(self, category='xp', limit=3):
"""Get top players by specified category"""
def get_leaderboard(self, channel: str, category='xp', limit=3):
"""Get top players by specified category for a given channel"""
try:
leaderboard = []
for nick, player_data in self.players.items():
players = self.get_players_for_channel(channel)
for nick, player_data in players.items():
sanitized_data = self._sanitize_player_data(player_data)
if category == 'xp':

View File

@@ -1,5 +1,6 @@
import asyncio
import os
import sys
import ssl
import time
import signal
@@ -23,7 +24,11 @@ class DuckHuntBot:
self.writer: Optional[asyncio.StreamWriter] = None
self.registered = False
self.channels_joined = set()
# Track requested joins / pending server confirmation.
# Used by auto-rejoin and (in newer revisions) admin join/leave reporting.
self.pending_joins = {}
self.shutdown_requested = False
self.restart_requested = False
self.rejoin_attempts = {} # Track rejoin attempts per channel
self.rejoin_tasks = {} # Track active rejoin tasks
@@ -35,7 +40,8 @@ class DuckHuntBot:
self.db = DuckDB(bot=self)
self.game = DuckGame(self, self.db)
self.messages = MessageManager()
messages_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'messages.json')
self.messages = MessageManager(messages_file)
self.sasl_handler = SASLHandler(self, config)
@@ -58,7 +64,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 hasattr(self.db, 'channels'),
critical=True
)
@@ -90,6 +96,15 @@ class DuckHuntBot:
return default
return value
def _channel_key(self, channel: str) -> str:
"""Normalize channel for internal comparisons (IRC channels are case-insensitive)."""
if not isinstance(channel, str):
return ""
channel = channel.strip()
if channel.startswith('#') or channel.startswith('&'):
return channel.lower()
return channel
def is_admin(self, user):
if '!' not in user:
return False
@@ -129,11 +144,8 @@ class DuckHuntBot:
self.send_message(channel, message)
return False
target = args[0].lower()
player = self.db.get_player(target)
if player is None:
player = self.db.create_player(target)
self.db.players[target] = player
target = args[0]
player = self.db.get_player(target, channel)
action_func(player)
message = self.messages.get(success_message_key, target=target, admin=nick)
@@ -151,25 +163,16 @@ class DuckHuntBot:
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)
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)
return player, None
def _get_validated_target_player(self, nick, channel, target_nick):
@@ -273,6 +276,10 @@ class DuckHuntBot:
async def schedule_rejoin(self, channel):
"""Schedule automatic rejoin attempts for a channel after being kicked"""
try:
# Backward/forward compatibility: ensure attribute exists.
if not hasattr(self, 'pending_joins') or not isinstance(self.pending_joins, dict):
self.pending_joins = {}
# Cancel any existing rejoin task for this channel
if channel in self.rejoin_tasks:
self.rejoin_tasks[channel].cancel()
@@ -305,29 +312,27 @@ class DuckHuntBot:
self.logger.info(f"Rejoin attempt {self.rejoin_attempts[channel]}/{max_attempts} for {channel}")
# Wait before attempting rejoin
await asyncio.sleep(retry_interval)
# Check if we're still connected and registered
if not self.registered or not self.writer or self.writer.is_closing():
self.logger.warning(f"Cannot rejoin {channel}: not connected to server")
await asyncio.sleep(retry_interval)
continue
# Attempt to rejoin
if self.send_raw(f"JOIN {channel}"):
self.channels_joined.add(channel)
self.logger.info(f"Successfully rejoined {channel}")
# Reset attempt counter and remove task
self.rejoin_attempts[channel] = 0
if channel in self.rejoin_tasks:
del self.rejoin_tasks[channel]
return
self.pending_joins[channel] = None
self.logger.info(f"Sent JOIN for {channel} (waiting for server confirmation)")
else:
self.logger.warning(f"Failed to send JOIN command for {channel}")
# Wait before next attempt (if needed)
await asyncio.sleep(retry_interval)
# If we've exceeded max attempts or channel was successfully joined
if self.rejoin_attempts[channel] >= max_attempts:
if channel in self.channels_joined:
self.rejoin_attempts[channel] = 0
self.logger.info(f"Rejoin confirmed for {channel}")
elif self.rejoin_attempts[channel] >= max_attempts:
self.logger.error(f"Exhausted all {max_attempts} rejoin attempts for {channel}")
# Clean up
@@ -461,29 +466,66 @@ class DuckHuntBot:
for channel in channels:
try:
self.send_raw(f"JOIN {channel}")
self.channels_joined.add(channel)
# Wait for server JOIN confirmation before marking joined.
if not hasattr(self, 'pending_joins') or not isinstance(self.pending_joins, dict):
self.pending_joins = {}
self.pending_joins[self._channel_key(channel)] = None
except Exception as e:
self.logger.error(f"Error joining channel {channel}: {e}")
# JOIN failures (numeric replies)
elif command in {"403", "405", "437", "471", "473", "474", "475", "477", "438", "439"}:
# Common formats:
# 471 <me> <#chan> :Cannot join channel (+l)
# 474 <me> <#chan> :Cannot join channel (+b)
# 477 <me> <#chan> :You need to be identified...
our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt'
if params and len(params) >= 2 and params[0].lower() == our_nick.lower():
failed_channel = params[1]
reason = trailing or "Join rejected"
failed_key = self._channel_key(failed_channel)
self.channels_joined.discard(failed_key)
if hasattr(self, 'pending_joins') and isinstance(self.pending_joins, dict):
self.pending_joins.pop(failed_key, None)
self.logger.warning(f"Failed to join {failed_channel}: ({command}) {reason}")
return
elif command == "JOIN":
if len(params) >= 1 and prefix:
if prefix:
# Some servers send either:
# :nick!user@host JOIN #chan
# or
# :nick!user@host JOIN :#chan
channel = None
if len(params) >= 1:
channel = params[0]
elif trailing and isinstance(trailing, str) and trailing.startswith('#'):
channel = trailing
if not channel:
return
channel_key = self._channel_key(channel)
joiner_nick = prefix.split('!')[0] if '!' in prefix else prefix
our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt'
# Check if we successfully joined (or rejoined) a channel
if joiner_nick and joiner_nick.lower() == our_nick.lower():
self.channels_joined.add(channel)
self.channels_joined.add(channel_key)
self.logger.info(f"Successfully joined channel {channel}")
# Clear pending join marker
if hasattr(self, 'pending_joins') and isinstance(self.pending_joins, dict):
self.pending_joins.pop(channel_key, None)
# Cancel any pending rejoin attempts for this channel
if channel in self.rejoin_tasks:
self.rejoin_tasks[channel].cancel()
del self.rejoin_tasks[channel]
if channel_key in self.rejoin_tasks:
self.rejoin_tasks[channel_key].cancel()
del self.rejoin_tasks[channel_key]
# Reset rejoin attempts counter
if channel in self.rejoin_attempts:
self.rejoin_attempts[channel] = 0
if channel_key in self.rejoin_attempts:
self.rejoin_attempts[channel_key] = 0
elif command == "PRIVMSG":
if len(params) >= 1:
@@ -504,11 +546,12 @@ class DuckHuntBot:
self.logger.warning(f"Kicked from {channel} by {kicker}: {reason}")
# Remove from joined channels
self.channels_joined.discard(channel)
channel_key = self._channel_key(channel)
self.channels_joined.discard(channel_key)
# Schedule rejoin if auto-rejoin is enabled
if self.get_config('connection.auto_rejoin.enabled', True):
asyncio.create_task(self.schedule_rejoin(channel))
asyncio.create_task(self.schedule_rejoin(channel_key))
elif command == "PING":
try:
@@ -567,7 +610,7 @@ class DuckHuntBot:
# Get player data with error recovery
player = self.error_recovery.safe_execute(
lambda: self.db.get_player(nick),
lambda: self.db.get_player(nick, safe_channel),
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
logger=self.logger
)
@@ -580,7 +623,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}")
@@ -609,6 +651,19 @@ class DuckHuntBot:
# Execute command with error recovery
command_executed = False
# Special case: admin PM-only bot restart uses !reload.
# In channels, !reload remains the gameplay reload command.
if cmd == "reload" and not channel.startswith('#') and self.is_admin(user):
command_executed = True
await self.error_recovery.safe_execute_async(
lambda: self.handle_reloadbot(nick, channel),
fallback=None,
logger=self.logger
)
if command_executed:
return
if cmd == "bang":
command_executed = True
await self.error_recovery.safe_execute_async(
@@ -651,6 +706,13 @@ class DuckHuntBot:
fallback=None,
logger=self.logger
)
elif cmd == "globaltop":
command_executed = True
await self.error_recovery.safe_execute_async(
lambda: self.handle_globaltop(nick, channel),
fallback=None,
logger=self.logger
)
elif cmd == "use":
command_executed = True
await self.error_recovery.safe_execute_async(
@@ -707,6 +769,21 @@ 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 == "part" and self.is_admin(user):
command_executed = True
await self.error_recovery.safe_execute_async(
lambda: self.handle_part_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,9 +821,9 @@ class DuckHuntBot:
if not target_nick:
return False, None, "Invalid target nickname"
player = self.db.get_player(target_nick)
player = self.db.get_player_if_exists(target_nick, channel)
if not player:
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
return False, None, f"Player '{target_nick}' not found in {channel}. They need to participate in this channel first."
has_activity = (
player.get('xp', 0) > 0 or
@@ -768,7 +845,7 @@ class DuckHuntBot:
We assume if someone has been active recently, they're still in the channel.
"""
try:
player = self.db.get_player(nick)
player = self.db.get_player_if_exists(nick, channel)
if not player:
return False
@@ -887,9 +964,9 @@ class DuckHuntBot:
"""Handle !duckstats command"""
if args and len(args) > 0:
target_nick = args[0]
target_player = self.db.get_player(target_nick)
target_player = self.db.get_player_if_exists(target_nick, channel)
if not target_player:
message = f"{nick} > Player '{target_nick}' not found."
message = f"{nick} > Player '{target_nick}' not found in {channel}."
self.send_message(channel, message)
return
display_nick = target_nick
@@ -981,10 +1058,10 @@ class DuckHuntBot:
reset = self.messages.messages.get('colours', {}).get('reset', '')
# Get top 3 by XP
top_xp = self.db.get_leaderboard('xp', 3)
top_xp = self.db.get_leaderboard(channel, 'xp', 3)
# Get top 3 by ducks shot
top_ducks = self.db.get_leaderboard('ducks_shot', 3)
top_ducks = self.db.get_leaderboard(channel, 'ducks_shot', 3)
# Format XP leaderboard as single line
if top_xp:
@@ -1012,20 +1089,139 @@ class DuckHuntBot:
self.logger.error(f"Error in handle_topduck: {e}")
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
async def handle_globaltop(self, nick, channel):
"""Handle !globaltop command - show top players across all channels (by XP)."""
try:
bold = self.messages.messages.get('colours', {}).get('bold', '')
reset = self.messages.messages.get('colours', {}).get('reset', '')
def _display_channel_key(channel_key: str) -> str:
"""Convert internal channel keys to a user-friendly label."""
if not isinstance(channel_key, str) or not channel_key:
return "unknown"
if channel_key.startswith('#') or channel_key.startswith('&'):
return channel_key
if channel_key == '__global__':
return "legacy"
if channel_key == '__pm__':
return "pm"
if channel_key == '__unknown__':
return "unknown"
return channel_key
entries = [] # (xp, player_nick, channel_key)
for channel_key, player_nick, player_data in self.db.iter_all_players():
if not isinstance(player_data, dict):
continue
try:
xp = int(player_data.get('xp', 0) or 0)
except (ValueError, TypeError):
xp = 0
if xp <= 0:
continue
entries.append((xp, str(player_nick), str(channel_key)))
if not entries:
self.send_message(channel, f"{nick} > No global XP data available yet!")
return
entries.sort(key=lambda t: t[0], reverse=True)
top5 = entries[:5]
parts = []
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
for idx, (xp, player_nick, channel_key) in enumerate(top5, 1):
prefix = medals.get(idx, f"#{idx}")
channel_label = _display_channel_key(channel_key)
parts.append(f"{prefix} {player_nick} {xp}XP {channel_label}")
line = f"Top XP: {bold}{reset} " + " | ".join(parts)
self.send_message(channel, line)
except Exception as e:
self.logger.error(f"Error in handle_globaltop: {e}")
self.send_message(channel, f"{nick} > Error retrieving global leaderboard data.")
async def handle_duckhelp(self, nick, channel, _player):
"""Handle !duckhelp command"""
"""Handle !duckhelp command - sends detailed help via PM"""
# Send notification to channel
if channel.startswith('#'):
self.send_message(channel, f"{nick} > Please check your PM for the duckhunt command list.")
# Build detailed help message
help_lines = [
self.messages.get('help_header'),
self.messages.get('help_user_commands'),
self.messages.get('help_help_command')
"=== DuckHunt Commands ===",
"",
"BASIC COMMANDS:",
" !bang - Shoot at a duck",
" !bef or !befriend - Try to befriend a duck",
" !reload - Reload your gun",
"",
"INFO COMMANDS:",
" !duckstats [player] - View duck hunting statistics",
" !topduck - View leaderboard (top hunters)",
" !globaltop - View global leaderboard (top 5 across all channels)",
"",
"SHOP COMMANDS:",
" !shop - View available items",
" !shop buy <item_id> - Purchase an item from the shop",
"",
"EXAMPLES:",
" When a duck appears, type: !bang",
" To reload: !reload",
" Check your stats: !duckstats",
" Buy item #2 from shop: !shop buy 2",
]
# Add admin commands if user is admin
if self.is_admin(f"{nick}!user@host"):
help_lines.append(self.messages.get('help_admin_commands'))
# We need to construct a proper user string for is_admin check
user_string = f"{nick}!user@host" # Simplified check
if hasattr(self, 'is_admin') and self.is_admin(user_string):
help_lines.extend([
"",
"=== ADMIN COMMANDS ===",
" !rearm <player|all> - Give player a gun",
" !disarm <player> - Confiscate player's gun",
" !ignore <player> - Ignore player's commands",
" !unignore <player> - Unignore player",
" !ducklaunch [duck_type] - Force spawn a duck (normal, golden, fast)",
" !join #channel - Make bot join a channel",
" !part #channel - Make bot leave a channel",
"",
"Admin commands work in PM or in-channel."
])
help_lines.extend([
"",
"=== TIPS ===",
"- Ducks spawn randomly. Watch for them!",
"- Golden ducks have multiple HP and give more XP",
"- Fast ducks fly away quickly",
"- Buy items from !shop to improve your hunting",
"",
"Good luck hunting! 🦆"
])
# Send all help lines as PM
for line in help_lines:
self.send_message(channel, line)
self.send_message(nick, line)
async def handle_reloadbot(self, nick, channel):
"""Admin-only: restart the bot process via PM (!reload) to apply code changes."""
# PM-only to avoid accidental public restarts
if channel.startswith('#'):
self.send_message(channel, f"{nick} > Use this command in PM only.")
return
self.send_message(nick, "Restarting bot now...")
try:
self.db.save_database()
except Exception:
pass
self.restart_requested = True
self.shutdown_requested = True
async def handle_use(self, nick, channel, player, args):
"""Handle !use command"""
@@ -1212,11 +1408,19 @@ 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():
if player.get('gun_confiscated', False):
player['gun_confiscated'] = False
self.levels.update_player_magazines(player)
player['current_ammo'] = player.get('bullets_per_magazine', 6)
if is_private_msg:
for _ch, _pn, p in self.db.iter_all_players():
if p.get('gun_confiscated', False):
p['gun_confiscated'] = False
self.levels.update_player_magazines(p)
p['current_ammo'] = p.get('bullets_per_magazine', 6)
rearmed_count += 1
else:
for _pn, p in self.db.get_players_for_channel(channel).items():
if p.get('gun_confiscated', False):
p['gun_confiscated'] = False
self.levels.update_player_magazines(p)
p['current_ammo'] = p.get('bullets_per_magazine', 6)
rearmed_count += 1
if is_private_msg:
@@ -1251,10 +1455,7 @@ 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
player = self.db.get_player(nick, channel)
player['gun_confiscated'] = False
@@ -1317,10 +1518,7 @@ 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
player = self.db.get_player(target, channel)
action_func(player)
@@ -1434,7 +1632,60 @@ class DuckHuntBot:
pass
async def handle_join_channel(self, nick, channel, args):
"""Handle !join command (admin only) - join a channel"""
if not args:
self.send_message(channel, f"{nick} > Usage: !join <#channel>")
return
target_channel = args[0]
# Validate channel format
if not target_channel.startswith('#'):
self.send_message(channel, f"{nick} > Invalid channel format. Must start with #")
return
# Check if already joined
if target_channel in self.channels_joined:
self.send_message(channel, f"{nick} > Already in {target_channel}")
return
# Send JOIN command
if self.send_raw(f"JOIN {target_channel}"):
self.channels_joined.add(target_channel)
self.send_message(channel, f"{nick} > Joined {target_channel}")
self.logger.info(f"Admin {nick} made bot join {target_channel}")
else:
self.send_message(channel, f"{nick} > Failed to join {target_channel}")
async def handle_part_channel(self, nick, channel, args):
"""Handle !part command (admin only) - leave a channel"""
if not args:
self.send_message(channel, f"{nick} > Usage: !part <#channel>")
return
target_channel = args[0]
# Validate channel format
if not target_channel.startswith('#'):
self.send_message(channel, f"{nick} > Invalid channel format. Must start with #")
return
# Check if in channel
if target_channel not in self.channels_joined:
self.send_message(channel, f"{nick} > Not in {target_channel}")
return
# Send PART command
if self.send_raw(f"PART {target_channel}"):
self.channels_joined.discard(target_channel)
self.send_message(channel, f"{nick} > Left {target_channel}")
self.logger.info(f"Admin {nick} made bot leave {target_channel}")
else:
self.send_message(channel, f"{nick} > Failed to leave {target_channel}")
async def message_loop(self):
"""Main message processing loop with comprehensive error handling"""
consecutive_errors = 0
max_consecutive_errors = 10
@@ -1583,6 +1834,11 @@ class DuckHuntBot:
self.logger.info("Bot shutdown complete")
# If restart was requested (admin command), re-exec the process.
if self.restart_requested:
self.logger.warning("Restart requested; re-executing process...")
os.execv(sys.executable, [sys.executable] + sys.argv)
async def _close_connection(self):
"""Close IRC connection with comprehensive error handling"""
if not self.writer:

View File

@@ -227,6 +227,16 @@ class DuckGame:
# Calculate hit chance using level-modified accuracy
modified_accuracy = self.bot.levels.get_modified_accuracy(player)
hit_chance = modified_accuracy / 100.0
# Apply clover luck effect (temporary boost to minimum hit chance)
clover = self._get_active_effect(player, 'clover_luck')
if clover:
try:
min_hit = float(clover.get('min_hit_chance', 0.0) or 0.0)
except (ValueError, TypeError):
min_hit = 0.0
hit_chance = max(hit_chance, max(0.0, min(min_hit, 1.0)))
if random.random() < hit_chance:
# Hit! Get the duck and reveal its type
duck = self.ducks[channel][0]
@@ -284,7 +294,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,8 +334,8 @@ 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():
if (other_nick.lower() != nick.lower() and
for other_nick, other_player in self.db.get_players_for_channel(channel).items():
if (str(other_nick).lower() != nick.lower() and
not other_player.get('gun_confiscated', False) and
other_player.get('current_ammo', 0) > 0):
armed_players.append((other_nick, other_player))
@@ -407,6 +417,15 @@ class DuckGame:
level_modified_rate = self.bot.levels.get_modified_befriend_rate(player, base_rate)
success_rate = level_modified_rate / 100.0
# Apply clover luck effect (temporary boost to minimum befriend chance)
clover = self._get_active_effect(player, 'clover_luck')
if clover:
try:
min_bef = float(clover.get('min_befriend_chance', 0.0) or 0.0)
except (ValueError, TypeError):
min_bef = 0.0
success_rate = max(success_rate, max(0.0, min(min_bef, 1.0)))
if random.random() < success_rate:
# Success - befriend the duck
duck = self.ducks[channel].pop(0)
@@ -424,7 +443,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 +513,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 the given 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
@@ -518,7 +537,7 @@ class DuckGame:
current_time = time.time()
try:
for player_name, player_data in self.db.players.items():
for _ch, _player_name, player_data in self.db.iter_all_players():
effects = player_data.get('temporary_effects', [])
for effect in effects:
if (effect.get('type') == 'attract_ducks' and
@@ -566,7 +585,7 @@ class DuckGame:
current_time = time.time()
try:
for player_name, player_data in self.db.players.items():
for _ch, player_name, player_data in self.db.iter_all_players():
effects = player_data.get('temporary_effects', [])
active_effects = []
@@ -581,6 +600,21 @@ class DuckGame:
except Exception as e:
self.logger.error(f"Error cleaning expired effects: {e}")
def _get_active_effect(self, player, effect_type: str):
"""Return the first active temporary effect dict matching type, or None."""
try:
current_time = time.time()
effects = player.get('temporary_effects', [])
if not isinstance(effects, list):
return None
for effect in effects:
if (isinstance(effect, dict) and effect.get('type') == effect_type and
effect.get('expires_at', 0) > current_time):
return effect
return None
except Exception:
return None
def _check_item_drop(self, player, duck_type):
"""
Check if the duck drops an item and add it to player's inventory

View File

@@ -389,6 +389,62 @@ class ShopManager:
"duration": duration // 60 # return duration in minutes
}
elif item_type == 'clover_luck':
# Temporarily boost hit + befriend success rates
if 'temporary_effects' not in player or not isinstance(player.get('temporary_effects'), list):
player['temporary_effects'] = []
duration = item.get('duration', 600) # seconds
try:
duration = int(duration)
except (ValueError, TypeError):
duration = 600
duration = max(30, min(duration, 86400))
try:
min_hit = float(item.get('min_hit_chance', 0.95))
except (ValueError, TypeError):
min_hit = 0.95
try:
min_bef = float(item.get('min_befriend_chance', 0.95))
except (ValueError, TypeError):
min_bef = 0.95
min_hit = max(0.0, min(min_hit, 1.0))
min_bef = max(0.0, min(min_bef, 1.0))
now = time.time()
expires_at = now + duration
# If an existing clover effect is active, extend it instead of stacking.
for effect in player['temporary_effects']:
if isinstance(effect, dict) and effect.get('type') == 'clover_luck' and effect.get('expires_at', 0) > now:
effect['expires_at'] = max(effect.get('expires_at', now), now) + duration
effect['min_hit_chance'] = max(float(effect.get('min_hit_chance', 0.0) or 0.0), min_hit)
effect['min_befriend_chance'] = max(float(effect.get('min_befriend_chance', 0.0) or 0.0), min_bef)
return {
"type": "clover_luck",
"duration": duration // 60,
"min_hit_chance": min_hit,
"min_befriend_chance": min_bef,
"extended": True
}
effect = {
'type': 'clover_luck',
'min_hit_chance': min_hit,
'min_befriend_chance': min_bef,
'expires_at': expires_at
}
player['temporary_effects'].append(effect)
return {
"type": "clover_luck",
"duration": duration // 60,
"min_hit_chance": min_hit,
"min_befriend_chance": min_bef,
"extended": False
}
elif item_type == 'insurance':
# Add insurance protection against friendly fire
if 'temporary_effects' not in player: