Compare commits

...

3 Commits

Author SHA1 Message Date
3nd3r
0b5b42a507 Fix duck tracking across channel case 2026-01-01 13:32:49 -06:00
3nd3r
626eb7cb2a Fix ignore/unignore global behavior 2026-01-01 11:16:47 -06:00
3nd3r
b6d2fe2a35 Update README; fix duck_types config 2026-01-01 11:10:14 -06:00
4 changed files with 104 additions and 31 deletions

View File

@@ -10,7 +10,8 @@ DuckHunt is an asyncio-based IRC bot that runs a classic "duck hunting" mini-gam
## Features ## Features
- **Multi-channel support** - Bot can be in multiple channels - **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 - **Three duck types** - Normal, Golden (multi-HP), and Fast ducks
- **Shop system** - Buy items to improve your hunting - **Shop system** - Buy items to improve your hunting
- **Leveling system** - Gain XP and increase your level - **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 - **Golden** - Multi-HP duck (3-5 HP), high XP, awards XP per hit
- **Fast** - Quick duck, 1 HP, flies away faster - **Fast** - Quick duck, 1 HP, flies away faster
Duck spawn rates configured in `config.json`: Duck spawn behavior is configured in `config.json` under `duck_types`:
- `golden_duck_chance` - Probability of golden duck (default: 0.15)
- `fast_duck_chance` - Probability of fast duck (default: 0.25) - `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 ## Persistence
Player stats are saved to `duckhunt.json`: 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.) - **Auto-save** - Database saved after each action (shoot, reload, shop, etc.)
- **Atomic writes** - Safe file handling prevents database corruption - **Atomic writes** - Safe file handling prevents database corruption
- **Retry logic** - Automatic retry on save failures - **Retry logic** - Automatic retry on save failures
@@ -79,8 +83,9 @@ Player stats are saved to `duckhunt.json`:
- `!reload` - Reload your gun - `!reload` - Reload your gun
- `!shop` - View available items - `!shop` - View available items
- `!shop buy <item_id>` - Purchase an item from the shop - `!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) - `!topduck` - View leaderboard (top hunters)
- `!globaltop` - View global leaderboard (top 5 across all channels)
- `!duckhelp` - Get detailed command list via PM - `!duckhelp` - Get detailed command list via PM
### Admin Commands ### Admin Commands
@@ -131,6 +136,8 @@ Use `!shop buy <id>` to purchase.
- Accuracy percentage - Accuracy percentage
- Current level - Current level
Note: stats are tracked per-channel; use `!globaltop` for an across-channels view.
## Repo Layout ## Repo Layout
``` ```

View File

@@ -46,10 +46,50 @@ class DuckDB:
channel = channel.strip() channel = channel.strip()
if not channel: if not channel:
return '__unknown__' 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('&'): if channel.startswith('#') or channel.startswith('&'):
return channel.lower() return channel.lower()
return '__pm__' 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 @property
def players(self): def players(self):
"""Backward-compatible flattened view of all players across channels.""" """Backward-compatible flattened view of all players across channels."""

View File

@@ -627,7 +627,7 @@ class DuckHuntBot:
self.logger.warning(f"Error updating player activity for {nick}: {e}") self.logger.warning(f"Error updating player activity for {nick}: {e}")
try: 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 return
except Exception as e: except Exception as e:
self.logger.error(f"Error checking admin/ignore status: {e}") self.logger.error(f"Error checking admin/ignore status: {e}")
@@ -1520,7 +1520,7 @@ class DuckHuntBot:
target = args[0].lower() target = args[0].lower()
player = self.db.get_player(target, channel) player = self.db.get_player(target, channel)
action_func(player) action_func(player, target)
if is_private_msg: if is_private_msg:
action_name = "Ignored" if message_key == 'admin_ignore' else "Unignored" action_name = "Ignored" if message_key == 'admin_ignore' else "Unignored"
@@ -1538,7 +1538,7 @@ class DuckHuntBot:
usage_command='usage_ignore', usage_command='usage_ignore',
private_usage='!ignore <player>', private_usage='!ignore <player>',
message_key='admin_ignore', 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): async def handle_unignore(self, nick, channel, args):
@@ -1548,7 +1548,7 @@ class DuckHuntBot:
usage_command='usage_unignore', usage_command='usage_unignore',
private_usage='!unignore <player>', private_usage='!unignore <player>',
message_key='admin_unignore', 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): async def handle_ducklaunch(self, nick, channel, args):

View File

@@ -20,6 +20,16 @@ class DuckGame:
self.spawn_task = None self.spawn_task = None
self.timeout_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): async def start_game_loops(self):
"""Start the game loops""" """Start the game loops"""
self.spawn_task = asyncio.create_task(self.duck_spawn_loop()) self.spawn_task = asyncio.create_task(self.duck_spawn_loop())
@@ -108,22 +118,36 @@ class DuckGame:
async def spawn_duck(self, channel): async def spawn_duck(self, channel):
"""Spawn a duck in the channel""" """Spawn a duck in the channel"""
if channel not in self.ducks: channel_key = self._channel_key(channel)
self.ducks[channel] = [] if channel_key not in self.ducks:
self.ducks[channel_key] = []
# Don't spawn if there's already a duck # Don't spawn if there's already a duck
if self.ducks[channel]: if self.ducks[channel_key]:
return return
# Determine duck type randomly # Determine duck type randomly.
golden_chance = self.bot.get_config('golden_duck_chance', 0.15) # Prefer the newer config structure (duck_types.*) but keep legacy keys for compatibility.
fast_chance = self.bot.get_config('fast_duck_chance', 0.25) 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() rand = random.random()
if rand < golden_chance: if rand < golden_chance:
# Golden duck - high HP, high XP # Golden duck - high HP, high XP
min_hp = self.bot.get_config('golden_duck_min_hp', 3) min_hp = self.bot.get_config(
max_hp = self.bot.get_config('golden_duck_max_hp', 5) '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) hp = random.randint(min_hp, max_hp)
duck_type = 'golden' duck_type = 'golden'
duck = { duck = {
@@ -134,7 +158,7 @@ class DuckGame:
'max_hp': hp, 'max_hp': hp,
'current_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: elif rand < golden_chance + fast_chance:
# Fast duck - normal HP, flies away faster # Fast duck - normal HP, flies away faster
duck_type = 'fast' duck_type = 'fast'
@@ -146,7 +170,7 @@ class DuckGame:
'max_hp': 1, 'max_hp': 1,
'current_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: else:
# Normal duck # Normal duck
duck_type = 'normal' duck_type = 'normal'
@@ -158,15 +182,16 @@ class DuckGame:
'max_hp': 1, 'max_hp': 1,
'current_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! # All duck types use the same spawn message - type is hidden!
message = self.bot.messages.get('duck_spawn') message = self.bot.messages.get('duck_spawn')
self.ducks[channel].append(duck) self.ducks[channel_key].append(duck)
self.bot.send_message(channel, message) self.bot.send_message(channel, message)
def shoot_duck(self, nick, channel, player): def shoot_duck(self, nick, channel, player):
"""Handle shooting at a duck""" """Handle shooting at a duck"""
channel_key = self._channel_key(channel)
# Check if gun is confiscated # Check if gun is confiscated
if player.get('gun_confiscated', False): if player.get('gun_confiscated', False):
return { return {
@@ -204,7 +229,7 @@ class DuckGame:
} }
# Check for duck # 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 # Wild shot - gun confiscated for unsafe shooting
player['shots_fired'] = player.get('shots_fired', 0) + 1 # Track wild shots too 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 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: if random.random() < hit_chance:
# Hit! Get the duck and reveal its type # 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') duck_type = duck.get('duck_type', 'normal')
if duck_type == 'golden': if duck_type == 'golden':
@@ -265,17 +290,17 @@ class DuckGame:
} }
else: else:
# Golden duck killed! # 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 xp_gained = xp_gained * duck['max_hp'] # Bonus XP for killing
message_key = 'bang_hit_golden_killed' message_key = 'bang_hit_golden_killed'
elif duck_type == 'fast': elif duck_type == 'fast':
# Fast duck - normal HP but higher XP # 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) xp_gained = self.bot.get_config('fast_duck_xp', 12)
message_key = 'bang_hit_fast' message_key = 'bang_hit_fast'
else: else:
# Normal duck # Normal duck
self.ducks[channel].pop(0) self.ducks[channel_key].pop(0)
xp_gained = self.bot.get_config('normal_duck_xp', 10) xp_gained = self.bot.get_config('normal_duck_xp', 10)
message_key = 'bang_hit' message_key = 'bang_hit'
@@ -395,8 +420,9 @@ class DuckGame:
def befriend_duck(self, nick, channel, player): def befriend_duck(self, nick, channel, player):
"""Handle befriending a duck""" """Handle befriending a duck"""
channel_key = self._channel_key(channel)
# Check for duck # 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 { return {
'success': False, 'success': False,
'message_key': 'bef_no_duck', 'message_key': 'bef_no_duck',
@@ -428,7 +454,7 @@ class DuckGame:
if random.random() < success_rate: if random.random() < success_rate:
# Success - befriend the duck # Success - befriend the duck
duck = self.ducks[channel].pop(0) duck = self.ducks[channel_key].pop(0)
# Lower XP gain than shooting # Lower XP gain than shooting
xp_gained = self.bot.get_config('gameplay.befriend_xp', 5) xp_gained = self.bot.get_config('gameplay.befriend_xp', 5)
@@ -458,7 +484,7 @@ class DuckGame:
} }
else: else:
# Failure - duck flies away, remove from channel # Failure - duck flies away, remove from channel
duck = self.ducks[channel].pop(0) duck = self.ducks[channel_key].pop(0)
self.db.save_database() self.db.save_database()
return { return {