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
|
## 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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
40
src/db.py
40
src/db.py
@@ -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."""
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -1519,8 +1519,8 @@ 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):
|
||||||
|
|||||||
66
src/game.py
66
src/game.py
@@ -19,6 +19,16 @@ class DuckGame:
|
|||||||
self.logger = logging.getLogger('DuckHuntBot.Game')
|
self.logger = logging.getLogger('DuckHuntBot.Game')
|
||||||
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"""
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user