Prepare for GitHub release
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -130,8 +130,9 @@ duckhunt.log*
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.backup
|
*.backup
|
||||||
|
|
||||||
# Database files (optional - uncomment if you want to ignore database)
|
# Runtime files (do not commit)
|
||||||
# duckhunt.json
|
duckhunt.json
|
||||||
|
config.json
|
||||||
|
|
||||||
# IDE files
|
# IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
231
README.md
231
README.md
@@ -1,201 +1,90 @@
|
|||||||
# DuckHunt IRC Bot
|
# 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
|
- Originally written by **Computertech**
|
||||||
- 🎯 **Accuracy System**: Dynamic accuracy that improves with hits and degrades with misses
|
- New features, fixes, and maintenance added by **End3r**
|
||||||
- **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
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Features
|
||||||
|
|
||||||
### Prerequisites
|
- Per-channel stats (same nick has separate stats per channel)
|
||||||
|
- Multiple duck types (normal / golden / fast)
|
||||||
|
- Shop + inventory items
|
||||||
|
- Admin commands (rearm/disarm/ignore, spawn ducks, join/leave channels)
|
||||||
|
- `!globalducks` totals across channels
|
||||||
|
- JSON persistence to disk (`duckhunt.json`)
|
||||||
|
|
||||||
- Python 3.8+
|
## Quick start
|
||||||
- Virtual environment (recommended)
|
|
||||||
|
|
||||||
### Installation
|
### Requirements
|
||||||
|
|
||||||
1. **Clone the repository:**
|
- Python 3.8+
|
||||||
```bash
|
|
||||||
git clone https://github.com/your-username/duckhunt-bot.git
|
|
||||||
cd duckhunt-bot
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Set up virtual environment:**
|
### Run
|
||||||
```bash
|
|
||||||
python -m venv .venv
|
|
||||||
source .venv/bin/activate # Linux/Mac
|
|
||||||
# or
|
|
||||||
.venv\Scripts\activate # Windows
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Install dependencies:**
|
From the repo root:
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Configure the bot:**
|
```bash
|
||||||
- Copy `config.json.example` to `config.json` (if available)
|
python3 duckhunt.py
|
||||||
- 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Duck Types & Rewards
|
## Configuration
|
||||||
```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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**📖 See [CONFIG.md](CONFIG.md) for complete configuration documentation.**
|
Edit `config.json`:
|
||||||
|
|
||||||
## 🎮 Game Commands
|
- `connection.server`, `connection.port`, `connection.nick`
|
||||||
|
- `connection.channels` (list of channels to join on connect)
|
||||||
|
- `connection.ssl` and optional password/SASL settings
|
||||||
|
|
||||||
### Player Commands
|
Security note: don’t commit real IRC passwords/tokens in `config.json`.
|
||||||
- `!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
|
## Persistence
|
||||||
- `!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
|
Player stats are saved to `duckhunt.json`.
|
||||||
|
|
||||||
| Type | Spawn Rate | HP | Timeout | XP Reward |
|
- Stats are stored per channel.
|
||||||
|------|------------|----|---------|-----------|
|
- If you run `!join` / `!leave`, the bot updates `config.json` so channel changes persist across restarts.
|
||||||
| Normal | 60% | 1 | 60s | 10 |
|
|
||||||
| Golden | 15% | 3-5 | 60s | 15 |
|
|
||||||
| Fast | 25% | 1 | 20s | 12 |
|
|
||||||
|
|
||||||
## 🛒 Shop Items
|
## Commands
|
||||||
|
|
||||||
- **Bread** - Attracts more ducks
|
### Player commands
|
||||||
- **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
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
- `!bang`
|
||||||
|
- `!reload`
|
||||||
|
- `!shop`
|
||||||
|
- `!buy <item_id>`
|
||||||
|
- `!use <item_id> [target]`
|
||||||
|
- `!duckstats [player]`
|
||||||
|
- `!topduck`
|
||||||
|
- `!give <item_id> <player>`
|
||||||
|
- `!globalducks [player]` (totals across all configured channels)
|
||||||
|
- `!duckhelp` (sends a PM with examples)
|
||||||
|
|
||||||
|
### Admin commands
|
||||||
|
|
||||||
|
- `!rearm <player|all>`
|
||||||
|
- `!disarm <player>`
|
||||||
|
- `!ignore <player>` / `!unignore <player>`
|
||||||
|
- `!ducklaunch [duck_type]` (in-channel)
|
||||||
|
- `!ducklaunch <#channel> [duck_type]` (in PM)
|
||||||
|
- `!join <#channel>` / `!leave <#channel>` (persists to `config.json`)
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
```
|
```
|
||||||
duckhunt/
|
duckhunt/
|
||||||
├── duckhunt.py # Main bot entry point
|
├── duckhunt.py # Entry point
|
||||||
├── config.json # Bot configuration
|
├── config.json # Bot configuration
|
||||||
├── CONFIG.md # Configuration documentation
|
├── duckhunt.json # Player database (generated/updated at runtime)
|
||||||
├── src/
|
└── src/
|
||||||
│ ├── duckhuntbot.py # Core bot IRC functionality
|
├── duckhuntbot.py # IRC bot + command routing
|
||||||
│ ├── game.py # Duck game mechanics
|
├── game.py # Duck game logic
|
||||||
│ ├── db.py # Player database management
|
├── db.py # Persistence layer
|
||||||
│ ├── shop.py # Shop system
|
├── shop.py # Shop/inventory
|
||||||
│ ├── levels.py # Player leveling system
|
├── levels.py # Level 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Adding New Features
|
|
||||||
|
|
||||||
The bot is designed with modularity in mind:
|
|
||||||
|
|
||||||
1. **Game mechanics** are in `src/game.py`
|
|
||||||
2. **IRC functionality** is in `src/duckhuntbot.py`
|
|
||||||
3. **Database operations** are in `src/db.py`
|
|
||||||
4. **Configuration** uses dot notation: `bot.get_config('duck_types.normal.xp')`
|
|
||||||
|
|
||||||
### Testing Configuration
|
|
||||||
|
|
||||||
Use the built-in config tester:
|
|
||||||
```bash
|
|
||||||
python test_config.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Connection fails**: Check server, port, and SSL settings in config
|
|
||||||
2. **SASL authentication fails**: Verify username/password and ensure nick is registered
|
|
||||||
3. **Bot doesn't respond**: Check channel permissions and admin list
|
|
||||||
4. **Config errors**: Validate JSON syntax and see CONFIG.md for proper values
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
Enable detailed logging by setting log level in the code or add verbose output.
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. Make your changes
|
|
||||||
4. Add tests if applicable
|
|
||||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
|
||||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
|
||||||
7. Open a Pull Request
|
|
||||||
|
|
||||||
## 📝 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
- Inspired by classic IRC duck hunting bots
|
|
||||||
- Built with Python's asyncio for modern async IRC handling
|
|
||||||
- Thanks to all contributors and testers
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
- 📖 **Documentation**: See [CONFIG.md](CONFIG.md) for configuration help
|
|
||||||
- 🐛 **Issues**: Report bugs via GitHub Issues
|
|
||||||
- 💬 **Discussion**: Join our IRC channel for help and discussion
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Duck Hunting!**
|
**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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
121
config.json.example
Normal file
121
config.json.example
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
{
|
||||||
|
"connection": {
|
||||||
|
"server": "irc.example.net",
|
||||||
|
"port": 6697,
|
||||||
|
"nick": "Quackbot",
|
||||||
|
"channels": [
|
||||||
|
"#duckhunt"
|
||||||
|
],
|
||||||
|
"ssl": true,
|
||||||
|
"password": "",
|
||||||
|
"max_retries": 3,
|
||||||
|
"retry_delay": 5,
|
||||||
|
"timeout": 30,
|
||||||
|
"auto_rejoin": {
|
||||||
|
"enabled": true,
|
||||||
|
"retry_interval": 20,
|
||||||
|
"max_rejoin_attempts": 100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sasl": {
|
||||||
|
"enabled": false,
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
},
|
||||||
|
"admins": [
|
||||||
|
"yourNickHere"
|
||||||
|
],
|
||||||
|
"duck_spawning": {
|
||||||
|
"spawn_min": 1200,
|
||||||
|
"spawn_max": 3600,
|
||||||
|
"timeout": 60,
|
||||||
|
"rearm_on_duck_shot": true
|
||||||
|
},
|
||||||
|
"duck_types": {
|
||||||
|
"normal": {
|
||||||
|
"xp": 10,
|
||||||
|
"timeout": 60,
|
||||||
|
"drop_chance": 0.15
|
||||||
|
},
|
||||||
|
"golden": {
|
||||||
|
"chance": 0.15,
|
||||||
|
"min_hp": 3,
|
||||||
|
"max_hp": 5,
|
||||||
|
"xp": 15,
|
||||||
|
"timeout": 60,
|
||||||
|
"drop_chance": 0.5
|
||||||
|
},
|
||||||
|
"fast": {
|
||||||
|
"chance": 0.25,
|
||||||
|
"timeout": 20,
|
||||||
|
"xp": 12,
|
||||||
|
"drop_chance": 0.25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_drops": {
|
||||||
|
"normal_duck_drops": [
|
||||||
|
{"item_id": 1, "weight": 40},
|
||||||
|
{"item_id": 2, "weight": 25},
|
||||||
|
{"item_id": 4, "weight": 20},
|
||||||
|
{"item_id": 3, "weight": 15}
|
||||||
|
],
|
||||||
|
"fast_duck_drops": [
|
||||||
|
{"item_id": 1, "weight": 30},
|
||||||
|
{"item_id": 2, "weight": 25},
|
||||||
|
{"item_id": 4, "weight": 20},
|
||||||
|
{"item_id": 8, "weight": 15},
|
||||||
|
{"item_id": 3, "weight": 10}
|
||||||
|
],
|
||||||
|
"golden_duck_drops": [
|
||||||
|
{"item_id": 5, "weight": 25},
|
||||||
|
{"item_id": 6, "weight": 20},
|
||||||
|
{"item_id": 7, "weight": 20},
|
||||||
|
{"item_id": 2, "weight": 15},
|
||||||
|
{"item_id": 9, "weight": 10},
|
||||||
|
{"item_id": 1, "weight": 10}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"player_defaults": {
|
||||||
|
"accuracy": 75,
|
||||||
|
"magazines": 3,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
|
"jam_chance": 15,
|
||||||
|
"xp": 0
|
||||||
|
},
|
||||||
|
"gameplay": {
|
||||||
|
"befriend_success_rate": 75,
|
||||||
|
"befriend_xp": 5,
|
||||||
|
"accuracy_gain_on_hit": 1,
|
||||||
|
"accuracy_loss_on_miss": 2,
|
||||||
|
"min_accuracy": 10,
|
||||||
|
"max_accuracy": 100,
|
||||||
|
"min_befriend_success_rate": 5,
|
||||||
|
"max_befriend_success_rate": 95,
|
||||||
|
"wet_clothes_duration": 300
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"shop_enabled": true,
|
||||||
|
"inventory_enabled": true,
|
||||||
|
"auto_rearm_enabled": true
|
||||||
|
},
|
||||||
|
"limits": {
|
||||||
|
"max_inventory_items": 20,
|
||||||
|
"max_temp_effects": 20
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"_comment_enabled": "Whether debug logging is enabled at all (true=debug mode, false=minimal logging)",
|
||||||
|
"enabled": true,
|
||||||
|
"_comment_log_level": "Overall logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||||
|
"log_level": "DEBUG",
|
||||||
|
"_comment_console_level": "Console output level - what shows in terminal (DEBUG, INFO, WARNING, ERROR)",
|
||||||
|
"console_log_level": "INFO",
|
||||||
|
"_comment_file_level": "File logging level - what gets written to log files (DEBUG, INFO, WARNING, ERROR)",
|
||||||
|
"file_log_level": "DEBUG",
|
||||||
|
"_comment_log_everything": "If true, logs ALL events. If false, logs only important events",
|
||||||
|
"log_everything": true,
|
||||||
|
"_comment_log_performance": "Whether to enable performance/metrics logging to performance.log",
|
||||||
|
"log_performance": true,
|
||||||
|
"_comment_unified_format": "If true, console and file logs use same format. If false, console has colors, file is plain",
|
||||||
|
"unified_format": true
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/db.py
242
src/db.py
@@ -8,6 +8,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input
|
from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input
|
||||||
|
|
||||||
|
|
||||||
@@ -15,8 +16,16 @@ class DuckDB:
|
|||||||
"""Simplified database management"""
|
"""Simplified database management"""
|
||||||
|
|
||||||
def __init__(self, db_file="duckhunt.json", bot=None):
|
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.bot = bot
|
||||||
|
# Channel-scoped player storage:
|
||||||
|
# {"#channel": {"nick": {player_data}}, ...}
|
||||||
self.players = {}
|
self.players = {}
|
||||||
self.logger = logging.getLogger('DuckHuntBot.DB')
|
self.logger = logging.getLogger('DuckHuntBot.DB')
|
||||||
|
|
||||||
@@ -24,7 +33,63 @@ class DuckDB:
|
|||||||
self.error_recovery = ErrorRecovery()
|
self.error_recovery = ErrorRecovery()
|
||||||
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
|
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
|
||||||
|
|
||||||
self.load_database()
|
data = self.load_database()
|
||||||
|
self._hydrate_from_data(data)
|
||||||
|
|
||||||
|
def _default_channel(self) -> str:
|
||||||
|
"""Pick a reasonable default channel context."""
|
||||||
|
if self.bot:
|
||||||
|
channels = self.bot.get_config('connection.channels', []) or []
|
||||||
|
if isinstance(channels, list) and channels:
|
||||||
|
first = channels[0]
|
||||||
|
if isinstance(first, str) and first.strip():
|
||||||
|
return first.strip()
|
||||||
|
return "#duckhunt"
|
||||||
|
|
||||||
|
def _normalize_channel(self, channel: Optional[str]) -> str:
|
||||||
|
if isinstance(channel, str) and channel.strip().startswith(('#', '&')):
|
||||||
|
return channel.strip()
|
||||||
|
return self._default_channel()
|
||||||
|
|
||||||
|
def _hydrate_from_data(self, data: dict) -> None:
|
||||||
|
"""Load in-memory channel->players structure from parsed JSON."""
|
||||||
|
try:
|
||||||
|
players_by_channel: dict = {}
|
||||||
|
|
||||||
|
if isinstance(data, dict) and isinstance(data.get('channels'), dict):
|
||||||
|
# New format
|
||||||
|
for ch, ch_data in data['channels'].items():
|
||||||
|
if not isinstance(ch, str):
|
||||||
|
continue
|
||||||
|
if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict):
|
||||||
|
players = ch_data.get('players', {})
|
||||||
|
elif isinstance(ch_data, dict):
|
||||||
|
# Support legacy "channels: {"#c": {nick: {...}}}" shape
|
||||||
|
players = ch_data
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Keep only dict players
|
||||||
|
clean_players = {}
|
||||||
|
for nick, pdata in players.items():
|
||||||
|
if isinstance(nick, str) and isinstance(pdata, dict):
|
||||||
|
clean_players[nick.lower()] = pdata
|
||||||
|
if clean_players:
|
||||||
|
players_by_channel[ch] = clean_players
|
||||||
|
|
||||||
|
elif isinstance(data, dict) and isinstance(data.get('players'), dict):
|
||||||
|
# Old format: single global player dictionary
|
||||||
|
default_channel = self._default_channel()
|
||||||
|
migrated = {}
|
||||||
|
for nick, pdata in data['players'].items():
|
||||||
|
if isinstance(nick, str) and isinstance(pdata, dict):
|
||||||
|
migrated[nick.lower()] = pdata
|
||||||
|
players_by_channel[default_channel] = migrated
|
||||||
|
|
||||||
|
self.players = players_by_channel if isinstance(players_by_channel, dict) else {}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error hydrating database in-memory state: {e}")
|
||||||
|
self.players = {}
|
||||||
|
|
||||||
def load_database(self) -> dict:
|
def load_database(self) -> dict:
|
||||||
"""Load the database, creating it if it doesn't exist"""
|
"""Load the database, creating it if it doesn't exist"""
|
||||||
@@ -54,14 +119,28 @@ class DuckDB:
|
|||||||
'last_modified': datetime.now().isoformat()
|
'last_modified': datetime.now().isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Initialize players section if missing
|
# Initialize channels section if missing
|
||||||
if 'players' not in data:
|
if 'channels' not in data:
|
||||||
data['players'] = {}
|
# If old format has players, keep it for migration.
|
||||||
|
data.setdefault('players', {})
|
||||||
|
else:
|
||||||
|
if not isinstance(data.get('channels'), dict):
|
||||||
|
data['channels'] = {}
|
||||||
|
|
||||||
# Update last_modified
|
# Update last_modified
|
||||||
data['metadata']['last_modified'] = datetime.now().isoformat()
|
data['metadata']['last_modified'] = datetime.now().isoformat()
|
||||||
|
|
||||||
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
|
try:
|
||||||
|
if isinstance(data.get('channels'), dict):
|
||||||
|
total_players = 0
|
||||||
|
for ch_data in data['channels'].values():
|
||||||
|
if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict):
|
||||||
|
total_players += len(ch_data.get('players', {}))
|
||||||
|
self.logger.info(f"Successfully loaded database with {total_players} total players across {len(data.get('channels', {}))} channels")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
|
||||||
|
except Exception:
|
||||||
|
self.logger.info("Successfully loaded database")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
@@ -75,9 +154,9 @@ class DuckDB:
|
|||||||
"""Create a new default database file with proper structure"""
|
"""Create a new default database file with proper structure"""
|
||||||
try:
|
try:
|
||||||
default_data = {
|
default_data = {
|
||||||
"players": {},
|
"channels": {},
|
||||||
"last_save": str(time.time()),
|
"last_save": str(time.time()),
|
||||||
"version": "1.0",
|
"version": "2.0",
|
||||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"description": "DuckHunt Bot Player Database"
|
"description": "DuckHunt Bot Player Database"
|
||||||
}
|
}
|
||||||
@@ -92,9 +171,9 @@ class DuckDB:
|
|||||||
self.logger.error(f"Failed to create default database: {e}")
|
self.logger.error(f"Failed to create default database: {e}")
|
||||||
# Return a minimal valid structure even if file creation fails
|
# Return a minimal valid structure even if file creation fails
|
||||||
return {
|
return {
|
||||||
"players": {},
|
"channels": {},
|
||||||
"last_save": str(time.time()),
|
"last_save": str(time.time()),
|
||||||
"version": "1.0",
|
"version": "2.0",
|
||||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"description": "DuckHunt Bot Player Database"
|
"description": "DuckHunt Bot Player Database"
|
||||||
}
|
}
|
||||||
@@ -201,26 +280,40 @@ class DuckDB:
|
|||||||
try:
|
try:
|
||||||
# Prepare data with validation
|
# Prepare data with validation
|
||||||
data = {
|
data = {
|
||||||
'players': {},
|
'channels': {},
|
||||||
'last_save': str(time.time()),
|
'last_save': str(time.time()),
|
||||||
'version': '1.0'
|
'version': '2.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate and clean player data before saving
|
# Validate and clean player data before saving
|
||||||
valid_count = 0
|
valid_count = 0
|
||||||
for nick, player_data in self.players.items():
|
for channel_name, channel_players in self.players.items():
|
||||||
if isinstance(nick, str) and isinstance(player_data, dict):
|
if not isinstance(channel_name, str) or not isinstance(channel_players, dict):
|
||||||
try:
|
continue
|
||||||
sanitized_nick = sanitize_user_input(nick, max_length=50)
|
|
||||||
data['players'][sanitized_nick] = self._sanitize_player_data(player_data)
|
safe_channel = sanitize_user_input(
|
||||||
valid_count += 1
|
channel_name,
|
||||||
except Exception as e:
|
max_length=100,
|
||||||
self.logger.warning(f"Error processing player {nick} during save: {e}")
|
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
||||||
else:
|
)
|
||||||
self.logger.warning(f"Skipping invalid player data during save: {nick}")
|
if not safe_channel or not (safe_channel.startswith('#') or safe_channel.startswith('&')):
|
||||||
|
continue
|
||||||
if valid_count == 0:
|
|
||||||
raise ValueError("No valid player data to save")
|
data['channels'].setdefault(safe_channel, {'players': {}})
|
||||||
|
|
||||||
|
for nick, player_data in channel_players.items():
|
||||||
|
if isinstance(nick, str) and isinstance(player_data, dict):
|
||||||
|
try:
|
||||||
|
sanitized_nick = sanitize_user_input(nick, max_length=50)
|
||||||
|
if not sanitized_nick:
|
||||||
|
continue
|
||||||
|
data['channels'][safe_channel]['players'][sanitized_nick.lower()] = self._sanitize_player_data(player_data)
|
||||||
|
valid_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Error processing player {nick} in {safe_channel} during save: {e}")
|
||||||
|
|
||||||
|
# Saving an empty database is valid (e.g., first run or after admin wipes).
|
||||||
|
# Previously this raised and prevented the file from being written/updated.
|
||||||
|
|
||||||
# Write to temporary file first (atomic write)
|
# Write to temporary file first (atomic write)
|
||||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||||
@@ -257,7 +350,79 @@ class DuckDB:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_player(self, nick):
|
def get_players_for_channel(self, channel: Optional[str]) -> dict:
|
||||||
|
"""Get the mutable player dict for a channel, creating the channel bucket if needed."""
|
||||||
|
ch = self._normalize_channel(channel)
|
||||||
|
if ch not in self.players or not isinstance(self.players.get(ch), dict):
|
||||||
|
self.players[ch] = {}
|
||||||
|
return self.players[ch]
|
||||||
|
|
||||||
|
def get_player_if_exists(self, nick: str, channel: Optional[str]) -> Optional[dict]:
|
||||||
|
"""Get an existing player record for a channel without creating one."""
|
||||||
|
try:
|
||||||
|
if not isinstance(nick, str) or not nick.strip():
|
||||||
|
return None
|
||||||
|
nick_clean = sanitize_user_input(
|
||||||
|
nick,
|
||||||
|
max_length=50,
|
||||||
|
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
||||||
|
)
|
||||||
|
nick_lower = (nick_clean or '').lower().strip()
|
||||||
|
if not nick_lower:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ch = self._normalize_channel(channel)
|
||||||
|
channel_players = self.players.get(ch)
|
||||||
|
if not isinstance(channel_players, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
player = channel_players.get(nick_lower)
|
||||||
|
if isinstance(player, dict):
|
||||||
|
return player
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_global_duck_totals(self, nick: str, channels: list) -> dict:
|
||||||
|
"""Sum ducks_shot/ducks_befriended for a user across the provided channels."""
|
||||||
|
total_shot = 0
|
||||||
|
total_bef = 0
|
||||||
|
channels_counted = 0
|
||||||
|
|
||||||
|
for ch in channels or []:
|
||||||
|
if not isinstance(ch, str):
|
||||||
|
continue
|
||||||
|
player = self.get_player_if_exists(nick, ch)
|
||||||
|
if not player:
|
||||||
|
continue
|
||||||
|
channels_counted += 1
|
||||||
|
try:
|
||||||
|
total_shot += int(player.get('ducks_shot', 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
total_bef += int(player.get('ducks_befriended', 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'nick': nick,
|
||||||
|
'ducks_shot': total_shot,
|
||||||
|
'ducks_befriended': total_bef,
|
||||||
|
'total_ducks': total_shot + total_bef,
|
||||||
|
'channels_counted': channels_counted,
|
||||||
|
}
|
||||||
|
|
||||||
|
def iter_all_players(self):
|
||||||
|
"""Yield (channel, nick, player_dict) for all players."""
|
||||||
|
for ch, players in (self.players or {}).items():
|
||||||
|
if not isinstance(players, dict):
|
||||||
|
continue
|
||||||
|
for nick, pdata in players.items():
|
||||||
|
if isinstance(nick, str) and isinstance(pdata, dict):
|
||||||
|
yield ch, nick, pdata
|
||||||
|
|
||||||
|
def get_player(self, nick, channel: Optional[str] = None):
|
||||||
"""Get player data, creating if doesn't exist with comprehensive validation"""
|
"""Get player data, creating if doesn't exist with comprehensive validation"""
|
||||||
try:
|
try:
|
||||||
# Validate and sanitize nick
|
# Validate and sanitize nick
|
||||||
@@ -278,14 +443,16 @@ class DuckDB:
|
|||||||
self.logger.warning(f"Empty nick after sanitization: {nick}")
|
self.logger.warning(f"Empty nick after sanitization: {nick}")
|
||||||
return self.create_player('Unknown')
|
return self.create_player('Unknown')
|
||||||
|
|
||||||
if nick_lower not in self.players:
|
channel_players = self.get_players_for_channel(channel)
|
||||||
self.players[nick_lower] = self.create_player(nick_clean)
|
|
||||||
|
if nick_lower not in channel_players:
|
||||||
|
channel_players[nick_lower] = self.create_player(nick_clean)
|
||||||
else:
|
else:
|
||||||
# Ensure existing players have all required fields
|
# Ensure existing players have all required fields
|
||||||
player = self.players[nick_lower]
|
player = channel_players[nick_lower]
|
||||||
if not isinstance(player, dict):
|
if not isinstance(player, dict):
|
||||||
self.logger.warning(f"Invalid player data for {nick_lower}, recreating")
|
self.logger.warning(f"Invalid player data for {nick_lower}, recreating")
|
||||||
self.players[nick_lower] = self.create_player(nick_clean)
|
channel_players[nick_lower] = self.create_player(nick_clean)
|
||||||
else:
|
else:
|
||||||
# Migrate and validate existing player data with error recovery
|
# Migrate and validate existing player data with error recovery
|
||||||
validated = self.error_recovery.safe_execute(
|
validated = self.error_recovery.safe_execute(
|
||||||
@@ -293,9 +460,9 @@ class DuckDB:
|
|||||||
fallback=self.create_player(nick_clean),
|
fallback=self.create_player(nick_clean),
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
self.players[nick_lower] = validated
|
channel_players[nick_lower] = validated
|
||||||
|
|
||||||
return self.players[nick_lower]
|
return channel_players[nick_lower]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Critical error getting player {nick}: {e}")
|
self.logger.error(f"Critical error getting player {nick}: {e}")
|
||||||
@@ -409,11 +576,16 @@ class DuckDB:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_leaderboard(self, category='xp', limit=3):
|
def get_leaderboard(self, category='xp', limit=3):
|
||||||
"""Get top players by specified category"""
|
"""Get top players by specified category (default channel)."""
|
||||||
|
return self.get_leaderboard_for_channel(self._default_channel(), category=category, limit=limit)
|
||||||
|
|
||||||
|
def get_leaderboard_for_channel(self, channel: Optional[str], category='xp', limit=3):
|
||||||
|
"""Get top players for a channel by specified category"""
|
||||||
try:
|
try:
|
||||||
leaderboard = []
|
leaderboard = []
|
||||||
|
|
||||||
for nick, player_data in self.players.items():
|
channel_players = self.get_players_for_channel(channel)
|
||||||
|
for nick, player_data in channel_players.items():
|
||||||
sanitized_data = self._sanitize_player_data(player_data)
|
sanitized_data = self._sanitize_player_data(player_data)
|
||||||
|
|
||||||
if category == 'xp':
|
if category == 'xp':
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class DuckHuntBot:
|
|||||||
self.messages = MessageManager()
|
self.messages = MessageManager()
|
||||||
|
|
||||||
self.sasl_handler = SASLHandler(self, config)
|
self.sasl_handler = SASLHandler(self, config)
|
||||||
|
|
||||||
|
# Config file path for persisting runtime config changes (e.g., admin join/leave).
|
||||||
|
self.config_file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json')
|
||||||
|
|
||||||
# Set up health checks
|
# Set up health checks
|
||||||
self._setup_health_checks()
|
self._setup_health_checks()
|
||||||
@@ -58,7 +61,7 @@ class DuckHuntBot:
|
|||||||
# Database health check
|
# Database health check
|
||||||
self.health_checker.add_check(
|
self.health_checker.add_check(
|
||||||
'database',
|
'database',
|
||||||
lambda: self.db is not None and len(self.db.players) >= 0,
|
lambda: self.db is not None and sum(1 for _ in self.db.iter_all_players()) >= 0,
|
||||||
critical=True
|
critical=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,10 +133,8 @@ class DuckHuntBot:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
target = args[0].lower()
|
target = args[0].lower()
|
||||||
player = self.db.get_player(target)
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
if player is None:
|
player = self.db.get_player(target, channel_ctx)
|
||||||
player = self.db.create_player(target)
|
|
||||||
self.db.players[target] = player
|
|
||||||
action_func(player)
|
action_func(player)
|
||||||
|
|
||||||
message = self.messages.get(success_message_key, target=target, admin=nick)
|
message = self.messages.get(success_message_key, target=target, admin=nick)
|
||||||
@@ -147,29 +148,21 @@ class DuckHuntBot:
|
|||||||
Returns (player, error_message) - if error_message is not None, command should return early.
|
Returns (player, error_message) - if error_message is not None, command should return early.
|
||||||
"""
|
"""
|
||||||
is_private_msg = not channel.startswith('#')
|
is_private_msg = not channel.startswith('#')
|
||||||
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
|
|
||||||
if not is_private_msg:
|
if not is_private_msg:
|
||||||
if target_nick.lower() == nick.lower():
|
if target_nick.lower() == nick.lower():
|
||||||
target_nick = target_nick.lower()
|
target_nick = target_nick.lower()
|
||||||
player = self.db.get_player(target_nick)
|
player = self.db.get_player(target_nick, channel_ctx)
|
||||||
if player is None:
|
|
||||||
player = self.db.create_player(target_nick)
|
|
||||||
self.db.players[target_nick] = player
|
|
||||||
return player, None
|
return player, None
|
||||||
else:
|
else:
|
||||||
is_valid, player, error_msg = self.validate_target_player(target_nick, channel)
|
is_valid, player, error_msg = self.validate_target_player(target_nick, channel)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return None, error_msg
|
return None, error_msg
|
||||||
target_nick = target_nick.lower()
|
|
||||||
if target_nick not in self.db.players:
|
|
||||||
self.db.players[target_nick] = player
|
|
||||||
return player, None
|
return player, None
|
||||||
else:
|
else:
|
||||||
target_nick = target_nick.lower()
|
target_nick = target_nick.lower()
|
||||||
player = self.db.get_player(target_nick)
|
player = self.db.get_player(target_nick, channel_ctx)
|
||||||
if player is None:
|
|
||||||
player = self.db.create_player(target_nick)
|
|
||||||
self.db.players[target_nick] = player
|
|
||||||
return player, None
|
return player, None
|
||||||
|
|
||||||
def _get_validated_target_player(self, nick, channel, target_nick):
|
def _get_validated_target_player(self, nick, channel, target_nick):
|
||||||
@@ -566,8 +559,9 @@ class DuckHuntBot:
|
|||||||
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
||||||
|
|
||||||
# Get player data with error recovery
|
# Get player data with error recovery
|
||||||
|
channel_ctx = safe_channel if isinstance(safe_channel, str) and safe_channel.startswith('#') else None
|
||||||
player = self.error_recovery.safe_execute(
|
player = self.error_recovery.safe_execute(
|
||||||
lambda: self.db.get_player(nick),
|
lambda: self.db.get_player(nick, channel_ctx),
|
||||||
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
|
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
@@ -580,7 +574,6 @@ class DuckHuntBot:
|
|||||||
try:
|
try:
|
||||||
player['last_activity_channel'] = safe_channel
|
player['last_activity_channel'] = safe_channel
|
||||||
player['last_activity_time'] = time.time()
|
player['last_activity_time'] = time.time()
|
||||||
self.db.players[nick.lower()] = player
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Error updating player activity for {nick}: {e}")
|
self.logger.warning(f"Error updating player activity for {nick}: {e}")
|
||||||
|
|
||||||
@@ -672,6 +665,13 @@ class DuckHuntBot:
|
|||||||
fallback=None,
|
fallback=None,
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
|
elif cmd == "globalducks":
|
||||||
|
command_executed = True
|
||||||
|
await self.error_recovery.safe_execute_async(
|
||||||
|
lambda: self.handle_globalducks(nick, channel, safe_args, user),
|
||||||
|
fallback=None,
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
elif cmd == "rearm" and self.is_admin(user):
|
elif cmd == "rearm" and self.is_admin(user):
|
||||||
command_executed = True
|
command_executed = True
|
||||||
await self.error_recovery.safe_execute_async(
|
await self.error_recovery.safe_execute_async(
|
||||||
@@ -707,6 +707,20 @@ class DuckHuntBot:
|
|||||||
fallback=None,
|
fallback=None,
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
|
elif cmd == "join" and self.is_admin(user):
|
||||||
|
command_executed = True
|
||||||
|
await self.error_recovery.safe_execute_async(
|
||||||
|
lambda: self.handle_join_channel(nick, channel, safe_args),
|
||||||
|
fallback=None,
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
|
elif (cmd == "leave" or cmd == "part") and self.is_admin(user):
|
||||||
|
command_executed = True
|
||||||
|
await self.error_recovery.safe_execute_async(
|
||||||
|
lambda: self.handle_leave_channel(nick, channel, safe_args),
|
||||||
|
fallback=None,
|
||||||
|
logger=self.logger
|
||||||
|
)
|
||||||
|
|
||||||
# If no command was executed, it might be an unknown command
|
# If no command was executed, it might be an unknown command
|
||||||
if not command_executed:
|
if not command_executed:
|
||||||
@@ -743,8 +757,9 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
if not target_nick:
|
if not target_nick:
|
||||||
return False, None, "Invalid target nickname"
|
return False, None, "Invalid target nickname"
|
||||||
|
|
||||||
player = self.db.get_player(target_nick)
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
|
player = self.db.get_player(target_nick, channel_ctx)
|
||||||
if not player:
|
if not player:
|
||||||
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
|
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
|
||||||
|
|
||||||
@@ -768,7 +783,8 @@ class DuckHuntBot:
|
|||||||
We assume if someone has been active recently, they're still in the channel.
|
We assume if someone has been active recently, they're still in the channel.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
player = self.db.get_player(nick)
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
|
player = self.db.get_player(nick, channel_ctx)
|
||||||
if not player:
|
if not player:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -887,7 +903,8 @@ class DuckHuntBot:
|
|||||||
"""Handle !duckstats command"""
|
"""Handle !duckstats command"""
|
||||||
if args and len(args) > 0:
|
if args and len(args) > 0:
|
||||||
target_nick = args[0]
|
target_nick = args[0]
|
||||||
target_player = self.db.get_player(target_nick)
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
|
target_player = self.db.get_player(target_nick, channel_ctx)
|
||||||
if not target_player:
|
if not target_player:
|
||||||
message = f"{nick} > Player '{target_nick}' not found."
|
message = f"{nick} > Player '{target_nick}' not found."
|
||||||
self.send_message(channel, message)
|
self.send_message(channel, message)
|
||||||
@@ -980,11 +997,13 @@ class DuckHuntBot:
|
|||||||
bold = self.messages.messages.get('colours', {}).get('bold', '')
|
bold = self.messages.messages.get('colours', {}).get('bold', '')
|
||||||
reset = self.messages.messages.get('colours', {}).get('reset', '')
|
reset = self.messages.messages.get('colours', {}).get('reset', '')
|
||||||
|
|
||||||
# Get top 3 by XP
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
top_xp = self.db.get_leaderboard('xp', 3)
|
|
||||||
|
# Get top 3 by XP (channel-scoped)
|
||||||
# Get top 3 by ducks shot
|
top_xp = self.db.get_leaderboard_for_channel(channel_ctx, 'xp', 3)
|
||||||
top_ducks = self.db.get_leaderboard('ducks_shot', 3)
|
|
||||||
|
# Get top 3 by ducks shot (channel-scoped)
|
||||||
|
top_ducks = self.db.get_leaderboard_for_channel(channel_ctx, 'ducks_shot', 3)
|
||||||
|
|
||||||
# Format XP leaderboard as single line
|
# Format XP leaderboard as single line
|
||||||
if top_xp:
|
if top_xp:
|
||||||
@@ -1013,19 +1032,216 @@ class DuckHuntBot:
|
|||||||
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
|
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
|
||||||
|
|
||||||
async def handle_duckhelp(self, nick, channel, _player):
|
async def handle_duckhelp(self, nick, channel, _player):
|
||||||
"""Handle !duckhelp command"""
|
"""Handle !duckhelp command
|
||||||
|
|
||||||
|
Sends help to the user via private message (PM/DM) with examples.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dm_target = nick # PRIVMSG target for a PM is the nick
|
||||||
|
|
||||||
help_lines = [
|
help_lines = [
|
||||||
self.messages.get('help_header'),
|
"DuckHunt Commands (sent via PM)",
|
||||||
self.messages.get('help_user_commands'),
|
"Player commands:",
|
||||||
self.messages.get('help_help_command')
|
"- !bang — shoot when a duck appears. Example: !bang",
|
||||||
|
"- !reload — reload your weapon. Example: !reload",
|
||||||
|
"- !shop — list shop items. Example: !shop",
|
||||||
|
"- !buy <item_id> — buy from shop. Example: !buy 3",
|
||||||
|
"- !use <item_id> [target] — use an inventory item. Example: !use 7 OR !use 9 SomeNick",
|
||||||
|
"- !duckstats [player] — view stats/inventory. Example: !duckstats OR !duckstats SomeNick",
|
||||||
|
"- !topduck — show leaderboards. Example: !topduck",
|
||||||
|
"- !give <item_id> <player> — gift an owned item. Example: !give 2 SomeNick",
|
||||||
|
"- !globalducks [player] — duck totals across all configured channels. Example: !globalducks OR !globalducks SomeNick",
|
||||||
|
"- !duckhelp — show this help. Example: !duckhelp",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add admin commands if user is admin
|
# Include admin commands only for admins.
|
||||||
if self.is_admin(f"{nick}!user@host"):
|
# (Using nick list avoids relying on hostmask parsing.)
|
||||||
help_lines.append(self.messages.get('help_admin_commands'))
|
if nick.lower() in self.admins:
|
||||||
|
help_lines.extend([
|
||||||
|
"Admin commands:",
|
||||||
|
"- !rearm <player|all> — rearm a player. Example: !rearm SomeNick OR !rearm all",
|
||||||
|
"- !disarm <player> — confiscate gun. Example: !disarm SomeNick",
|
||||||
|
"- !ignore <player> — ignore a player. Example: !ignore SomeNick",
|
||||||
|
"- !unignore <player> — unignore a player. Example: !unignore SomeNick",
|
||||||
|
"- !ducklaunch [duck_type] — force spawn. Example: !ducklaunch golden",
|
||||||
|
"- (PM) !ducklaunch <#channel> [duck_type] — Example: !ducklaunch #ct fast",
|
||||||
|
"- !join <#channel> — make the bot join a channel. Example: !join #ct",
|
||||||
|
"- !leave <#channel> — make the bot leave a channel. Example: !leave #ct",
|
||||||
|
])
|
||||||
|
|
||||||
for line in help_lines:
|
for line in help_lines:
|
||||||
self.send_message(channel, line)
|
self.send_message(dm_target, line)
|
||||||
|
|
||||||
|
# If invoked in a channel, add a brief confirmation to check PMs.
|
||||||
|
if isinstance(channel, str) and channel.startswith('#'):
|
||||||
|
self.send_message(channel, f"{nick} > I sent you a PM with commands and examples. Please check your PM window.")
|
||||||
|
|
||||||
|
async def handle_globalducks(self, nick, channel, args, user):
|
||||||
|
"""User: !globalducks [player] — totals across all configured channels.
|
||||||
|
|
||||||
|
Non-admins can query themselves only. Admins can query other nicks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
channels = self.get_config('connection.channels', []) or []
|
||||||
|
if not isinstance(channels, list):
|
||||||
|
channels = []
|
||||||
|
|
||||||
|
target_nick = nick
|
||||||
|
if args and len(args) >= 1:
|
||||||
|
requested = sanitize_user_input(
|
||||||
|
str(args[0]),
|
||||||
|
max_length=50,
|
||||||
|
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
||||||
|
)
|
||||||
|
if requested:
|
||||||
|
target_nick = requested
|
||||||
|
|
||||||
|
# Anyone can query anyone via !globalducks <player>
|
||||||
|
|
||||||
|
totals = self.db.get_global_duck_totals(target_nick, channels)
|
||||||
|
shot = totals.get('ducks_shot', 0)
|
||||||
|
bef = totals.get('ducks_befriended', 0)
|
||||||
|
total = totals.get('total_ducks', shot + bef)
|
||||||
|
counted = totals.get('channels_counted', 0)
|
||||||
|
|
||||||
|
if not channels:
|
||||||
|
self.send_message(channel, f"{nick} > No configured channels to total.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_message(
|
||||||
|
channel,
|
||||||
|
f"{nick} > Global totals for {target_nick} across configured channels: {shot} shot, {bef} befriended ({total} total) [{counted}/{len(channels)} channels have stats]."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error in handle_globalducks: {e}")
|
||||||
|
self.send_message(channel, f"{nick} > Error calculating global totals.")
|
||||||
|
|
||||||
|
def _sanitize_channel_name(self, channel_name: str) -> str:
|
||||||
|
"""Validate/sanitize an IRC channel name."""
|
||||||
|
if not isinstance(channel_name, str):
|
||||||
|
return ""
|
||||||
|
safe = sanitize_user_input(
|
||||||
|
channel_name.strip(),
|
||||||
|
max_length=100,
|
||||||
|
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
||||||
|
)
|
||||||
|
if not safe:
|
||||||
|
return ""
|
||||||
|
if not (safe.startswith('#') or safe.startswith('&')):
|
||||||
|
return ""
|
||||||
|
return safe
|
||||||
|
|
||||||
|
def _config_channels_list(self):
|
||||||
|
"""Return the mutable in-memory config channel list, creating it if needed."""
|
||||||
|
if not isinstance(self.config, dict):
|
||||||
|
self.config = {}
|
||||||
|
connection = self.config.get('connection')
|
||||||
|
if not isinstance(connection, dict):
|
||||||
|
connection = {}
|
||||||
|
self.config['connection'] = connection
|
||||||
|
channels = connection.get('channels')
|
||||||
|
if not isinstance(channels, list):
|
||||||
|
channels = []
|
||||||
|
connection['channels'] = channels
|
||||||
|
return channels
|
||||||
|
|
||||||
|
def _persist_config(self) -> bool:
|
||||||
|
"""Persist current config to disk (best-effort, atomic write)."""
|
||||||
|
try:
|
||||||
|
config_dir = os.path.dirname(self.config_file_path)
|
||||||
|
os.makedirs(config_dir, exist_ok=True)
|
||||||
|
|
||||||
|
tmp_path = f"{self.config_file_path}.tmp"
|
||||||
|
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||||
|
# Keep it stable/human-readable.
|
||||||
|
import json
|
||||||
|
json.dump(self.config, f, indent=4, ensure_ascii=False)
|
||||||
|
f.write("\n")
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
|
os.replace(tmp_path, self.config_file_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to persist config to disk: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle_join_channel(self, nick, channel, args):
|
||||||
|
"""Admin: !join <#channel> (supports PM and channel invocation)."""
|
||||||
|
if not args:
|
||||||
|
self.send_message(channel, f"{nick} > Usage: !join <#channel>")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_channel = self._sanitize_channel_name(args[0])
|
||||||
|
if not target_channel:
|
||||||
|
self.send_message(channel, f"{nick} > Invalid channel. Usage: !join <#channel>")
|
||||||
|
return
|
||||||
|
|
||||||
|
if target_channel in self.channels_joined:
|
||||||
|
self.send_message(channel, f"{nick} > I'm already in {target_channel}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.send_raw(f"JOIN {target_channel}"):
|
||||||
|
self.send_message(channel, f"{nick} > Couldn't send JOIN (not connected?).")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Track it immediately (we also reconcile on actual JOIN server message).
|
||||||
|
self.channels_joined.add(target_channel)
|
||||||
|
|
||||||
|
# Update in-memory config so reconnects keep the new channel.
|
||||||
|
channels = self._config_channels_list()
|
||||||
|
if target_channel not in channels:
|
||||||
|
channels.append(target_channel)
|
||||||
|
|
||||||
|
# Persist across restarts
|
||||||
|
if not self._persist_config():
|
||||||
|
self.send_message(channel, f"{nick} > Joined {target_channel}, but failed to write config.json (check permissions).")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_message(channel, f"{nick} > Joining {target_channel}.")
|
||||||
|
|
||||||
|
async def handle_leave_channel(self, nick, channel, args):
|
||||||
|
"""Admin: !leave <#channel> / !part <#channel> (supports PM and channel invocation)."""
|
||||||
|
if not args:
|
||||||
|
self.send_message(channel, f"{nick} > Usage: !leave <#channel>")
|
||||||
|
return
|
||||||
|
|
||||||
|
target_channel = self._sanitize_channel_name(args[0])
|
||||||
|
if not target_channel:
|
||||||
|
self.send_message(channel, f"{nick} > Invalid channel. Usage: !leave <#channel>")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cancel any pending rejoin attempts and forget state.
|
||||||
|
if target_channel in self.rejoin_tasks:
|
||||||
|
try:
|
||||||
|
self.rejoin_tasks[target_channel].cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
del self.rejoin_tasks[target_channel]
|
||||||
|
if target_channel in self.rejoin_attempts:
|
||||||
|
del self.rejoin_attempts[target_channel]
|
||||||
|
|
||||||
|
self.channels_joined.discard(target_channel)
|
||||||
|
|
||||||
|
# Update in-memory config so reconnects do not rejoin the channel.
|
||||||
|
channels = self._config_channels_list()
|
||||||
|
try:
|
||||||
|
while target_channel in channels:
|
||||||
|
channels.remove(target_channel)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Persist across restarts
|
||||||
|
if not self._persist_config():
|
||||||
|
self.send_message(channel, f"{nick} > Removed {target_channel} from my channel list, but failed to write config.json (check permissions).")
|
||||||
|
# Continue attempting PART anyway.
|
||||||
|
|
||||||
|
# Send PART even if we don't think we're in it (server will ignore or error).
|
||||||
|
if not self.send_raw(f"PART {target_channel} :Requested by {nick}"):
|
||||||
|
self.send_message(channel, f"{nick} > Couldn't send PART (not connected?).")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.send_message(channel, f"{nick} > Leaving {target_channel}.")
|
||||||
|
|
||||||
async def handle_use(self, nick, channel, player, args):
|
async def handle_use(self, nick, channel, player, args):
|
||||||
"""Handle !use command"""
|
"""Handle !use command"""
|
||||||
@@ -1212,7 +1428,8 @@ class DuckHuntBot:
|
|||||||
# Check if admin wants to rearm all players
|
# Check if admin wants to rearm all players
|
||||||
if target_nick.lower() == 'all':
|
if target_nick.lower() == 'all':
|
||||||
rearmed_count = 0
|
rearmed_count = 0
|
||||||
for player_nick, player in self.db.players.items():
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
|
for player_nick, player in self.db.get_players_for_channel(channel_ctx).items():
|
||||||
if player.get('gun_confiscated', False):
|
if player.get('gun_confiscated', False):
|
||||||
player['gun_confiscated'] = False
|
player['gun_confiscated'] = False
|
||||||
self.levels.update_player_magazines(player)
|
self.levels.update_player_magazines(player)
|
||||||
@@ -1251,10 +1468,8 @@ class DuckHuntBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Rearm the admin themselves (only in channels)
|
# Rearm the admin themselves (only in channels)
|
||||||
player = self.db.get_player(nick)
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
if player is None:
|
player = self.db.get_player(nick, channel_ctx)
|
||||||
player = self.db.create_player(nick)
|
|
||||||
self.db.players[nick.lower()] = player
|
|
||||||
|
|
||||||
player['gun_confiscated'] = False
|
player['gun_confiscated'] = False
|
||||||
|
|
||||||
@@ -1317,10 +1532,8 @@ class DuckHuntBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
target = args[0].lower()
|
target = args[0].lower()
|
||||||
player = self.db.get_player(target)
|
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
||||||
if player is None:
|
player = self.db.get_player(target, channel_ctx)
|
||||||
player = self.db.create_player(target)
|
|
||||||
self.db.players[target] = player
|
|
||||||
|
|
||||||
action_func(player)
|
action_func(player)
|
||||||
|
|
||||||
|
|||||||
36
src/game.py
36
src/game.py
@@ -34,12 +34,20 @@ class DuckGame:
|
|||||||
"""Duck spawning loop with responsive shutdown"""
|
"""Duck spawning loop with responsive shutdown"""
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
# Pick a target channel first so spawn multipliers are per-channel
|
||||||
|
channels = list(self.bot.channels_joined)
|
||||||
|
if not channels:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
channel = random.choice(channels)
|
||||||
|
|
||||||
# Wait random time between spawns, but in small chunks for responsiveness
|
# Wait random time between spawns, but in small chunks for responsiveness
|
||||||
min_wait = self.bot.get_config('duck_spawning.spawn_min', 300) # 5 minutes
|
min_wait = self.bot.get_config('duck_spawning.spawn_min', 300) # 5 minutes
|
||||||
max_wait = self.bot.get_config('duck_spawning.spawn_max', 900) # 15 minutes
|
max_wait = self.bot.get_config('duck_spawning.spawn_max', 900) # 15 minutes
|
||||||
|
|
||||||
# Check for active bread effects to modify spawn timing
|
# Check for active bread effects to modify spawn timing
|
||||||
spawn_multiplier = self._get_active_spawn_multiplier()
|
spawn_multiplier = self._get_active_spawn_multiplier(channel)
|
||||||
if spawn_multiplier > 1.0:
|
if spawn_multiplier > 1.0:
|
||||||
# Reduce wait time when bread is active
|
# Reduce wait time when bread is active
|
||||||
min_wait = int(min_wait / spawn_multiplier)
|
min_wait = int(min_wait / spawn_multiplier)
|
||||||
@@ -51,10 +59,8 @@ class DuckGame:
|
|||||||
for _ in range(wait_time):
|
for _ in range(wait_time):
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# Spawn duck in random channel
|
# Spawn duck in the chosen channel (if still joined)
|
||||||
channels = list(self.bot.channels_joined)
|
if channel in self.bot.channels_joined:
|
||||||
if channels:
|
|
||||||
channel = random.choice(channels)
|
|
||||||
await self.spawn_duck(channel)
|
await self.spawn_duck(channel)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -284,7 +290,7 @@ class DuckGame:
|
|||||||
|
|
||||||
# If config option enabled, rearm all disarmed players when duck is shot
|
# If config option enabled, rearm all disarmed players when duck is shot
|
||||||
if self.bot.get_config('duck_spawning.rearm_on_duck_shot', False):
|
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
|
# Check for item drops
|
||||||
dropped_item = self._check_item_drop(player, duck_type)
|
dropped_item = self._check_item_drop(player, duck_type)
|
||||||
@@ -324,7 +330,7 @@ class DuckGame:
|
|||||||
if random.random() < friendly_fire_chance:
|
if random.random() < friendly_fire_chance:
|
||||||
# Get other armed players in the same channel
|
# Get other armed players in the same channel
|
||||||
armed_players = []
|
armed_players = []
|
||||||
for other_nick, other_player in self.db.players.items():
|
for other_nick, other_player in self.db.get_players_for_channel(channel).items():
|
||||||
if (other_nick.lower() != nick.lower() and
|
if (other_nick.lower() != nick.lower() and
|
||||||
not other_player.get('gun_confiscated', False) and
|
not other_player.get('gun_confiscated', False) and
|
||||||
other_player.get('current_ammo', 0) > 0):
|
other_player.get('current_ammo', 0) > 0):
|
||||||
@@ -424,7 +430,7 @@ class DuckGame:
|
|||||||
|
|
||||||
# If config option enabled, rearm all disarmed players when duck is befriended
|
# If config option enabled, rearm all disarmed players when duck is befriended
|
||||||
if self.bot.get_config('rearm_on_duck_shot', False):
|
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()
|
self.db.save_database()
|
||||||
return {
|
return {
|
||||||
@@ -494,11 +500,11 @@ class DuckGame:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def _rearm_all_disarmed_players(self):
|
def _rearm_all_disarmed_players(self, channel):
|
||||||
"""Rearm all players who have been disarmed (gun confiscated)"""
|
"""Rearm all players who have been disarmed (gun confiscated) in a channel"""
|
||||||
try:
|
try:
|
||||||
rearmed_count = 0
|
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):
|
if player_data.get('gun_confiscated', False):
|
||||||
player_data['gun_confiscated'] = False
|
player_data['gun_confiscated'] = False
|
||||||
# Update magazines based on player level
|
# Update magazines based on player level
|
||||||
@@ -511,14 +517,14 @@ class DuckGame:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error in _rearm_all_disarmed_players: {e}")
|
self.logger.error(f"Error in _rearm_all_disarmed_players: {e}")
|
||||||
|
|
||||||
def _get_active_spawn_multiplier(self):
|
def _get_active_spawn_multiplier(self, channel):
|
||||||
"""Get the current spawn rate multiplier from active bread effects"""
|
"""Get the current spawn rate multiplier from active bread effects in a channel"""
|
||||||
import time
|
import time
|
||||||
max_multiplier = 1.0
|
max_multiplier = 1.0
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for player_name, player_data in self.db.players.items():
|
for player_name, player_data in self.db.get_players_for_channel(channel).items():
|
||||||
effects = player_data.get('temporary_effects', [])
|
effects = player_data.get('temporary_effects', [])
|
||||||
for effect in effects:
|
for effect in effects:
|
||||||
if (effect.get('type') == 'attract_ducks' and
|
if (effect.get('type') == 'attract_ducks' and
|
||||||
@@ -566,7 +572,7 @@ class DuckGame:
|
|||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for player_name, player_data in self.db.players.items():
|
for _channel, player_name, player_data in self.db.iter_all_players():
|
||||||
effects = player_data.get('temporary_effects', [])
|
effects = player_data.get('temporary_effects', [])
|
||||||
active_effects = []
|
active_effects = []
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class MessageManager:
|
|||||||
"help_header": "DuckHunt Commands:",
|
"help_header": "DuckHunt Commands:",
|
||||||
"help_user_commands": "!bang - Shoot at ducks | !reload - Reload your gun | !shop - View the shop",
|
"help_user_commands": "!bang - Shoot at ducks | !reload - Reload your gun | !shop - View the shop",
|
||||||
"help_help_command": "!duckhelp - Show this help",
|
"help_help_command": "!duckhelp - Show this help",
|
||||||
"help_admin_commands": "Admin: !rearm <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch",
|
"help_admin_commands": "Admin: !rearm <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch | !join <#channel> | !leave <#channel>",
|
||||||
"admin_rearm_player": "[ADMIN] {target} has been rearmed by {admin}",
|
"admin_rearm_player": "[ADMIN] {target} has been rearmed by {admin}",
|
||||||
"admin_rearm_all": "[ADMIN] All players have been rearmed by {admin}",
|
"admin_rearm_all": "[ADMIN] All players have been rearmed by {admin}",
|
||||||
"admin_disarm": "[ADMIN] {target} has been disarmed by {admin}",
|
"admin_disarm": "[ADMIN] {target} has been disarmed by {admin}",
|
||||||
|
|||||||
Reference in New Issue
Block a user