Compare commits
3 Commits
77ed3f95ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5b42a507 | ||
|
|
626eb7cb2a | ||
|
|
b6d2fe2a35 |
19
README.md
19
README.md
@@ -10,7 +10,8 @@ DuckHunt is an asyncio-based IRC bot that runs a classic "duck hunting" mini-gam
|
||||
## Features
|
||||
|
||||
- **Multi-channel support** - Bot can be in multiple channels
|
||||
- **Global player stats** - Same player stats across all 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
|
||||
@@ -57,15 +58,18 @@ Three duck types with different behaviors:
|
||||
- **Golden** - Multi-HP duck (3-5 HP), high XP, awards XP per hit
|
||||
- **Fast** - Quick duck, 1 HP, flies away faster
|
||||
|
||||
Duck spawn rates configured in `config.json`:
|
||||
- `golden_duck_chance` - Probability of golden duck (default: 0.15)
|
||||
- `fast_duck_chance` - Probability of fast duck (default: 0.25)
|
||||
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`:
|
||||
|
||||
- **Global stats** - Players have one set of stats across all channels
|
||||
- **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
|
||||
@@ -79,8 +83,9 @@ Player stats are saved to `duckhunt.json`:
|
||||
- `!reload` - Reload your gun
|
||||
- `!shop` - View available items
|
||||
- `!shop buy <item_id>` - Purchase an item from the shop
|
||||
- `!duckstats [player]` - View hunting statistics
|
||||
- `!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
|
||||
|
||||
### Admin Commands
|
||||
@@ -131,6 +136,8 @@ Use `!shop buy <id>` to purchase.
|
||||
- Accuracy percentage
|
||||
- Current level
|
||||
|
||||
Note: stats are tracked per-channel; use `!globaltop` for an across-channels view.
|
||||
|
||||
## Repo Layout
|
||||
|
||||
```
|
||||
|
||||
40
src/db.py
40
src/db.py
@@ -46,10 +46,50 @@ class DuckDB:
|
||||
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."""
|
||||
|
||||
@@ -627,7 +627,7 @@ class DuckHuntBot:
|
||||
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}")
|
||||
@@ -1520,7 +1520,7 @@ class DuckHuntBot:
|
||||
target = args[0].lower()
|
||||
player = self.db.get_player(target, channel)
|
||||
|
||||
action_func(player)
|
||||
action_func(player, target)
|
||||
|
||||
if is_private_msg:
|
||||
action_name = "Ignored" if message_key == 'admin_ignore' else "Unignored"
|
||||
@@ -1538,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):
|
||||
@@ -1548,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):
|
||||
|
||||
66
src/game.py
66
src/game.py
@@ -20,6 +20,16 @@ class DuckGame:
|
||||
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"""
|
||||
self.spawn_task = asyncio.create_task(self.duck_spawn_loop())
|
||||
@@ -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
|
||||
@@ -239,7 +264,7 @@ class DuckGame:
|
||||
|
||||
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':
|
||||
@@ -265,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'
|
||||
|
||||
@@ -395,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',
|
||||
@@ -428,7 +454,7 @@ class DuckGame:
|
||||
|
||||
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)
|
||||
@@ -458,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 {
|
||||
|
||||
Reference in New Issue
Block a user