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
- **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
```

View File

@@ -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."""

View File

@@ -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):

View File

@@ -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 {