Compare commits
42 Commits
f8c46980de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5b42a507 | ||
|
|
626eb7cb2a | ||
|
|
b6d2fe2a35 | ||
|
|
77ed3f95ad | ||
|
|
68a0a1fc83 | ||
|
|
292db0c95e | ||
|
|
b944e234d6 | ||
|
|
735c46b8c2 | ||
|
|
38d9159f50 | ||
|
|
a1afd25053 | ||
|
|
1e4e1e31ba | ||
|
|
9bff554a07 | ||
|
|
2372075195 | ||
|
|
2ef81cdd26 | ||
|
|
67bf6957a7 | ||
|
|
214d1ed263 | ||
|
|
f47778e608 | ||
|
|
7147f5f30c | ||
|
|
51c212e8cd | ||
|
|
5efe1e70bf | ||
|
|
a8b4196cf2 | ||
|
|
5e4bbb4309 | ||
|
|
53a66f5202 | ||
|
|
b71f8f4ec6 | ||
|
|
5db4ce0ab3 | ||
|
|
90b604ba72 | ||
|
|
dd06c9377f | ||
|
|
eb907e1e2c | ||
|
|
f6a9f4592a | ||
|
|
6069240553 | ||
|
|
f3f251a391 | ||
|
|
3e7436840e | ||
|
|
9bd51a24cc | ||
|
|
d5654e9783 | ||
|
|
ba9beae82f | ||
|
|
7d85f83faa | ||
|
|
02c055d7e3 | ||
|
|
617d9560e6 | ||
|
|
3b72a853ae | ||
|
|
ffe8bdfaf2 | ||
|
|
b256b9a9f6 | ||
|
|
4d17ae8f04 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
45
MULTI_CHANNEL_PLAN.md
Normal 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
|
||||
308
README.md
308
README.md
@@ -1,201 +1,171 @@
|
||||
# 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
|
||||
- **Per-channel player stats** - Stats are tracked separately per channel
|
||||
- **Global leaderboard** - View the global top 5 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
|
||||
|
||||
- Python 3.8+
|
||||
- Virtual environment (recommended)
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
### Requirements
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/your-username/duckhunt-bot.git
|
||||
cd duckhunt-bot
|
||||
```
|
||||
- Python 3.8+
|
||||
|
||||
2. **Set up virtual environment:**
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Linux/Mac
|
||||
# or
|
||||
.venv\Scripts\activate # Windows
|
||||
```
|
||||
### Run
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
From the repo root:
|
||||
|
||||
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
|
||||
|
||||
Copy the example config and edit it:
|
||||
|
||||
```bash
|
||||
cp config.json.example config.json
|
||||
```
|
||||
|
||||
**📖 See [CONFIG.md](CONFIG.md) for complete configuration documentation.**
|
||||
Then 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
|
||||
- `admins` (list of admin nicks or nick+hostmask patterns)
|
||||
|
||||
**Security note:** `config.json` is ignored by git - don't commit real IRC passwords/tokens.
|
||||
|
||||
### Duck Types
|
||||
|
||||
Three duck types with different behaviors:
|
||||
|
||||
- **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 spawn behavior is configured in `config.json` under `duck_types`:
|
||||
|
||||
- `duck_types.golden.chance` - Probability of a golden duck (default: 0.15)
|
||||
- `duck_types.fast.chance` - Probability of a fast duck (default: 0.25)
|
||||
- `duck_types.golden.min_hp` / `duck_types.golden.max_hp` - Golden duck HP range
|
||||
|
||||
## Persistence
|
||||
|
||||
Player stats are saved to `duckhunt.json`:
|
||||
|
||||
- **Per-channel stats** - Players have separate stats per channel (stored under `channels`)
|
||||
- **Global top 5** - `!globaltop` aggregates XP 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
|
||||
|
||||
### 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
|
||||
- `!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 for the current channel
|
||||
- `!topduck` - View leaderboard (top hunters)
|
||||
- `!globaltop` - View global leaderboard (top 5 across all channels)
|
||||
- `!duckhelp` - Get detailed command list via PM
|
||||
|
||||
## Duck Types
|
||||
### Admin Commands
|
||||
|
||||
| Type | Spawn Rate | HP | Timeout | XP Reward |
|
||||
|------|------------|----|---------|-----------|
|
||||
| Normal | 60% | 1 | 60s | 10 |
|
||||
| Golden | 15% | 3-5 | 60s | 15 |
|
||||
| Fast | 25% | 1 | 20s | 12 |
|
||||
- `!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
|
||||
|
||||
## 🛒 Shop Items
|
||||
Admin commands work in PM or in-channel.
|
||||
|
||||
- **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
|
||||
## Shop Items
|
||||
|
||||
## 📁 Project Structure
|
||||
Basic shop items available (use `!shop` to see current inventory):
|
||||
|
||||
- **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
|
||||
|
||||
Note: stats are tracked per-channel; use `!globaltop` for an across-channels view.
|
||||
|
||||
## 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!** 🦆
|
||||
124
config.json
124
config.json
@@ -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
171
config.json.example
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
1
original
Submodule
Submodule original added at f8c46980de
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
256
src/db.py
256
src/db.py
@@ -15,16 +15,95 @@ 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
|
||||
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__'
|
||||
# Preserve internal buckets used by the bot/database.
|
||||
# This allows explicit references like '__global__' without being remapped to '__pm__'.
|
||||
if channel.startswith('__') and channel.endswith('__'):
|
||||
return channel
|
||||
if channel.startswith('#') or channel.startswith('&'):
|
||||
return channel.lower()
|
||||
return '__pm__'
|
||||
|
||||
def is_ignored(self, nick: str, channel: str) -> bool:
|
||||
"""Return True if nick is ignored for this channel or globally."""
|
||||
try:
|
||||
if not isinstance(nick, str) or not nick.strip():
|
||||
return False
|
||||
nick_clean = sanitize_user_input(
|
||||
nick,
|
||||
max_length=50,
|
||||
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
||||
)
|
||||
nick_lower = nick_clean.lower().strip()
|
||||
if not nick_lower:
|
||||
return False
|
||||
|
||||
# Channel-scoped ignore
|
||||
player = self.get_player_if_exists(nick_lower, channel)
|
||||
if isinstance(player, dict) and bool(player.get('ignored', False)):
|
||||
return True
|
||||
|
||||
# Global ignore bucket
|
||||
global_player = self.get_player_if_exists(nick_lower, '__global__')
|
||||
return bool(global_player and global_player.get('ignored', False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def set_global_ignored(self, nick: str, ignored: bool) -> bool:
|
||||
"""Set global ignored flag for nick (persisted)."""
|
||||
try:
|
||||
player = self.get_player(nick, '__global__')
|
||||
if not isinstance(player, dict):
|
||||
return False
|
||||
player['ignored'] = bool(ignored)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@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"""
|
||||
@@ -53,15 +132,40 @@ class DuckDB:
|
||||
'created': datetime.now().isoformat(),
|
||||
'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 +179,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 +196,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"
|
||||
}
|
||||
@@ -122,6 +226,14 @@ class DuckDB:
|
||||
# Equipment and stats
|
||||
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)))))
|
||||
@@ -201,26 +313,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():
|
||||
if isinstance(nick, str) and isinstance(player_data, dict):
|
||||
try:
|
||||
sanitized_nick = sanitize_user_input(nick, max_length=50)
|
||||
data['players'][sanitized_nick] = self._sanitize_player_data(player_data)
|
||||
valid_count += 1
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error processing player {nick} during save: {e}")
|
||||
else:
|
||||
self.logger.warning(f"Skipping invalid player data during save: {nick}")
|
||||
|
||||
if valid_count == 0:
|
||||
raise ValueError("No valid player data to save")
|
||||
for channel_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['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} in {out_channel_key} 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,8 +376,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():
|
||||
@@ -277,15 +440,17 @@ class DuckDB:
|
||||
if not nick_lower:
|
||||
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 +458,9 @@ class DuckDB:
|
||||
fallback=self.create_player(nick_clean),
|
||||
logger=self.logger
|
||||
)
|
||||
self.players[nick_lower] = validated
|
||||
|
||||
return self.players[nick_lower]
|
||||
players[nick_lower] = validated
|
||||
|
||||
return players[nick_lower]
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Critical error getting player {nick}: {e}")
|
||||
@@ -363,6 +528,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 +563,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 +579,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':
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -89,6 +95,15 @@ class DuckHuntBot:
|
||||
else:
|
||||
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:
|
||||
@@ -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()
|
||||
@@ -304,30 +311,28 @@ class DuckHuntBot:
|
||||
self.rejoin_attempts[channel] += 1
|
||||
|
||||
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:
|
||||
channel = params[0]
|
||||
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,12 +623,11 @@ 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}")
|
||||
|
||||
try:
|
||||
if player.get('ignored', False) and not self.is_admin(user):
|
||||
if self.db.is_ignored(nick, safe_channel) and not self.is_admin(user):
|
||||
return
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking admin/ignore status: {e}")
|
||||
@@ -608,6 +650,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
|
||||
@@ -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:
|
||||
@@ -1011,21 +1088,140 @@ class DuckHuntBot:
|
||||
except Exception as e:
|
||||
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,12 +1408,20 @@ 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)
|
||||
rearmed_count += 1
|
||||
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:
|
||||
message = f"{nick} > Rearmed all players ({rearmed_count} players)"
|
||||
@@ -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,12 +1518,9 @@ 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
|
||||
|
||||
action_func(player)
|
||||
player = self.db.get_player(target, channel)
|
||||
|
||||
action_func(player, target)
|
||||
|
||||
if is_private_msg:
|
||||
action_name = "Ignored" if message_key == 'admin_ignore' else "Unignored"
|
||||
@@ -1340,7 +1538,7 @@ class DuckHuntBot:
|
||||
usage_command='usage_ignore',
|
||||
private_usage='!ignore <player>',
|
||||
message_key='admin_ignore',
|
||||
action_func=lambda player: player.update({'ignored': True})
|
||||
action_func=lambda player, target: (player.update({'ignored': True}), self.db.set_global_ignored(target, True))
|
||||
)
|
||||
|
||||
async def handle_unignore(self, nick, channel, args):
|
||||
@@ -1350,7 +1548,7 @@ class DuckHuntBot:
|
||||
usage_command='usage_unignore',
|
||||
private_usage='!unignore <player>',
|
||||
message_key='admin_unignore',
|
||||
action_func=lambda player: player.update({'ignored': False})
|
||||
action_func=lambda player, target: (player.update({'ignored': False}), self.db.set_global_ignored(target, False))
|
||||
)
|
||||
|
||||
async def handle_ducklaunch(self, nick, channel, args):
|
||||
@@ -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
|
||||
@@ -1582,6 +1833,11 @@ class DuckHuntBot:
|
||||
await self._close_connection()
|
||||
|
||||
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"""
|
||||
|
||||
118
src/game.py
118
src/game.py
@@ -19,6 +19,16 @@ class DuckGame:
|
||||
self.logger = logging.getLogger('DuckHuntBot.Game')
|
||||
self.spawn_task = None
|
||||
self.timeout_task = None
|
||||
|
||||
@staticmethod
|
||||
def _channel_key(channel: str) -> str:
|
||||
"""Normalize channel keys for internal dict lookups (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
|
||||
|
||||
async def start_game_loops(self):
|
||||
"""Start the game loops"""
|
||||
@@ -108,22 +118,36 @@ class DuckGame:
|
||||
|
||||
async def spawn_duck(self, channel):
|
||||
"""Spawn a duck in the channel"""
|
||||
if channel not in self.ducks:
|
||||
self.ducks[channel] = []
|
||||
channel_key = self._channel_key(channel)
|
||||
if channel_key not in self.ducks:
|
||||
self.ducks[channel_key] = []
|
||||
|
||||
# Don't spawn if there's already a duck
|
||||
if self.ducks[channel]:
|
||||
if self.ducks[channel_key]:
|
||||
return
|
||||
|
||||
# Determine duck type randomly
|
||||
golden_chance = self.bot.get_config('golden_duck_chance', 0.15)
|
||||
fast_chance = self.bot.get_config('fast_duck_chance', 0.25)
|
||||
# Determine duck type randomly.
|
||||
# Prefer the newer config structure (duck_types.*) but keep legacy keys for compatibility.
|
||||
golden_chance = self.bot.get_config(
|
||||
'duck_types.golden.chance',
|
||||
self.bot.get_config('golden_duck_chance', 0.15)
|
||||
)
|
||||
fast_chance = self.bot.get_config(
|
||||
'duck_types.fast.chance',
|
||||
self.bot.get_config('fast_duck_chance', 0.25)
|
||||
)
|
||||
|
||||
rand = random.random()
|
||||
if rand < golden_chance:
|
||||
# Golden duck - high HP, high XP
|
||||
min_hp = self.bot.get_config('golden_duck_min_hp', 3)
|
||||
max_hp = self.bot.get_config('golden_duck_max_hp', 5)
|
||||
min_hp = self.bot.get_config(
|
||||
'duck_types.golden.min_hp',
|
||||
self.bot.get_config('golden_duck_min_hp', 3)
|
||||
)
|
||||
max_hp = self.bot.get_config(
|
||||
'duck_types.golden.max_hp',
|
||||
self.bot.get_config('golden_duck_max_hp', 5)
|
||||
)
|
||||
hp = random.randint(min_hp, max_hp)
|
||||
duck_type = 'golden'
|
||||
duck = {
|
||||
@@ -134,7 +158,7 @@ class DuckGame:
|
||||
'max_hp': hp,
|
||||
'current_hp': hp
|
||||
}
|
||||
self.logger.info(f"Golden duck (hidden) spawned in {channel} with {hp} HP")
|
||||
self.logger.info(f"Golden duck (hidden) spawned in {channel_key} with {hp} HP")
|
||||
elif rand < golden_chance + fast_chance:
|
||||
# Fast duck - normal HP, flies away faster
|
||||
duck_type = 'fast'
|
||||
@@ -146,7 +170,7 @@ class DuckGame:
|
||||
'max_hp': 1,
|
||||
'current_hp': 1
|
||||
}
|
||||
self.logger.info(f"Fast duck (hidden) spawned in {channel}")
|
||||
self.logger.info(f"Fast duck (hidden) spawned in {channel_key}")
|
||||
else:
|
||||
# Normal duck
|
||||
duck_type = 'normal'
|
||||
@@ -158,15 +182,16 @@ class DuckGame:
|
||||
'max_hp': 1,
|
||||
'current_hp': 1
|
||||
}
|
||||
self.logger.info(f"Normal duck spawned in {channel}")
|
||||
self.logger.info(f"Normal duck spawned in {channel_key}")
|
||||
|
||||
# All duck types use the same spawn message - type is hidden!
|
||||
message = self.bot.messages.get('duck_spawn')
|
||||
self.ducks[channel].append(duck)
|
||||
self.ducks[channel_key].append(duck)
|
||||
self.bot.send_message(channel, message)
|
||||
|
||||
def shoot_duck(self, nick, channel, player):
|
||||
"""Handle shooting at a duck"""
|
||||
channel_key = self._channel_key(channel)
|
||||
# Check if gun is confiscated
|
||||
if player.get('gun_confiscated', False):
|
||||
return {
|
||||
@@ -204,7 +229,7 @@ class DuckGame:
|
||||
}
|
||||
|
||||
# Check for duck
|
||||
if channel not in self.ducks or not self.ducks[channel]:
|
||||
if channel_key not in self.ducks or not self.ducks[channel_key]:
|
||||
# Wild shot - gun confiscated for unsafe shooting
|
||||
player['shots_fired'] = player.get('shots_fired', 0) + 1 # Track wild shots too
|
||||
player['shots_missed'] = player.get('shots_missed', 0) + 1 # Wild shots count as misses
|
||||
@@ -227,9 +252,19 @@ 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]
|
||||
duck = self.ducks[channel_key][0]
|
||||
duck_type = duck.get('duck_type', 'normal')
|
||||
|
||||
if duck_type == 'golden':
|
||||
@@ -255,17 +290,17 @@ class DuckGame:
|
||||
}
|
||||
else:
|
||||
# Golden duck killed!
|
||||
self.ducks[channel].pop(0)
|
||||
self.ducks[channel_key].pop(0)
|
||||
xp_gained = xp_gained * duck['max_hp'] # Bonus XP for killing
|
||||
message_key = 'bang_hit_golden_killed'
|
||||
elif duck_type == 'fast':
|
||||
# Fast duck - normal HP but higher XP
|
||||
self.ducks[channel].pop(0)
|
||||
self.ducks[channel_key].pop(0)
|
||||
xp_gained = self.bot.get_config('fast_duck_xp', 12)
|
||||
message_key = 'bang_hit_fast'
|
||||
else:
|
||||
# Normal duck
|
||||
self.ducks[channel].pop(0)
|
||||
self.ducks[channel_key].pop(0)
|
||||
xp_gained = self.bot.get_config('normal_duck_xp', 10)
|
||||
message_key = 'bang_hit'
|
||||
|
||||
@@ -284,7 +319,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 +359,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))
|
||||
@@ -385,8 +420,9 @@ class DuckGame:
|
||||
|
||||
def befriend_duck(self, nick, channel, player):
|
||||
"""Handle befriending a duck"""
|
||||
channel_key = self._channel_key(channel)
|
||||
# Check for duck
|
||||
if channel not in self.ducks or not self.ducks[channel]:
|
||||
if channel_key not in self.ducks or not self.ducks[channel_key]:
|
||||
return {
|
||||
'success': False,
|
||||
'message_key': 'bef_no_duck',
|
||||
@@ -406,10 +442,19 @@ class DuckGame:
|
||||
# Apply level-based modification to befriend rate
|
||||
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)
|
||||
duck = self.ducks[channel_key].pop(0)
|
||||
|
||||
# Lower XP gain than shooting
|
||||
xp_gained = self.bot.get_config('gameplay.befriend_xp', 5)
|
||||
@@ -424,7 +469,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 {
|
||||
@@ -439,7 +484,7 @@ class DuckGame:
|
||||
}
|
||||
else:
|
||||
# Failure - duck flies away, remove from channel
|
||||
duck = self.ducks[channel].pop(0)
|
||||
duck = self.ducks[channel_key].pop(0)
|
||||
|
||||
self.db.save_database()
|
||||
return {
|
||||
@@ -494,11 +539,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 +563,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 +611,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 = []
|
||||
|
||||
@@ -580,6 +625,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):
|
||||
"""
|
||||
|
||||
56
src/shop.py
56
src/shop.py
@@ -388,6 +388,62 @@ class ShopManager:
|
||||
"spawn_multiplier": spawn_multiplier,
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user