Implement magazine system and inventory management

- Add level-based magazine system (3 mags at L1, 2 at L3-5, 1 at L6-8)
- Replace ammo/chargers with current_ammo/magazines/bullets_per_magazine
- Add inventory system for storing and using shop items
- Add Magazine item to shop (15 XP, adds 1 magazine)
- Auto-migrate existing players from old ammo system
- Auto-update magazines when players level up
- Fix method name bugs (get_player_level -> calculate_player_level)
This commit is contained in:
2025-09-23 20:13:01 +01:00
parent 3aaf0d0bb4
commit 0c8b4f9543
8 changed files with 892 additions and 339 deletions

View File

@@ -1,79 +1,95 @@
{ {
"level_calculation": { "level_calculation": {
"method": "total_ducks", "method": "xp",
"description": "Level based on total ducks interacted with (shot + befriended)" "description": "Level based on XP earned from hunting and befriending ducks"
}, },
"levels": { "levels": {
"1": { "1": {
"name": "Duck Novice", "name": "Duck Novice",
"min_ducks": 0, "min_xp": 0,
"max_ducks": 9, "max_xp": 49,
"befriend_success_rate": 85, "befriend_success_rate": 85,
"accuracy_modifier": 5, "accuracy_modifier": 5,
"duck_spawn_speed_modifier": 1.0, "duck_spawn_speed_modifier": 1.0,
"magazines": 3,
"bullets_per_magazine": 6,
"description": "Just starting out, ducks are trusting and easier to hit" "description": "Just starting out, ducks are trusting and easier to hit"
}, },
"2": { "2": {
"name": "Pond Visitor", "name": "Pond Visitor",
"min_ducks": 10, "min_xp": 50,
"max_ducks": 24, "max_xp": 149,
"befriend_success_rate": 80, "befriend_success_rate": 80,
"accuracy_modifier": 0, "accuracy_modifier": 0,
"duck_spawn_speed_modifier": 1.0, "duck_spawn_speed_modifier": 1.0,
"magazines": 3,
"bullets_per_magazine": 6,
"description": "Ducks are getting wary of you" "description": "Ducks are getting wary of you"
}, },
"3": { "3": {
"name": "Duck Hunter", "name": "Duck Hunter",
"min_ducks": 25, "min_xp": 150,
"max_ducks": 49, "max_xp": 299,
"befriend_success_rate": 75, "befriend_success_rate": 75,
"accuracy_modifier": -5, "accuracy_modifier": -5,
"duck_spawn_speed_modifier": 0.9, "duck_spawn_speed_modifier": 0.9,
"magazines": 2,
"bullets_per_magazine": 6,
"description": "Your reputation precedes you, ducks are more cautious" "description": "Your reputation precedes you, ducks are more cautious"
}, },
"4": { "4": {
"name": "Wetland Stalker", "name": "Wetland Stalker",
"min_ducks": 50, "min_xp": 300,
"max_ducks": 99, "max_xp": 599,
"befriend_success_rate": 70, "befriend_success_rate": 70,
"accuracy_modifier": -10, "accuracy_modifier": -10,
"duck_spawn_speed_modifier": 0.8, "duck_spawn_speed_modifier": 0.8,
"magazines": 2,
"bullets_per_magazine": 6,
"description": "Ducks flee at your approach, spawns are less frequent" "description": "Ducks flee at your approach, spawns are less frequent"
}, },
"5": { "5": {
"name": "Apex Predator", "name": "Apex Predator",
"min_ducks": 100, "min_xp": 600,
"max_ducks": 199, "max_xp": 999,
"befriend_success_rate": 65, "befriend_success_rate": 65,
"accuracy_modifier": -15, "accuracy_modifier": -15,
"duck_spawn_speed_modifier": 0.7, "duck_spawn_speed_modifier": 0.7,
"magazines": 2,
"bullets_per_magazine": 6,
"description": "You're feared throughout the pond, ducks are very elusive" "description": "You're feared throughout the pond, ducks are very elusive"
}, },
"6": { "6": {
"name": "Duck Whisperer", "name": "Duck Whisperer",
"min_ducks": 200, "min_xp": 1000,
"max_ducks": 399, "max_xp": 1999,
"befriend_success_rate": 60, "befriend_success_rate": 60,
"accuracy_modifier": -20, "accuracy_modifier": -20,
"duck_spawn_speed_modifier": 0.6, "duck_spawn_speed_modifier": 0.6,
"magazines": 1,
"bullets_per_magazine": 6,
"description": "Only the bravest ducks dare show themselves" "description": "Only the bravest ducks dare show themselves"
}, },
"7": { "7": {
"name": "Legendary Hunter", "name": "Legendary Hunter",
"min_ducks": 400, "min_xp": 2000,
"max_ducks": 999, "max_xp": 4999,
"befriend_success_rate": 55, "befriend_success_rate": 55,
"accuracy_modifier": -25, "accuracy_modifier": -25,
"duck_spawn_speed_modifier": 0.5, "duck_spawn_speed_modifier": 0.5,
"magazines": 1,
"bullets_per_magazine": 6,
"description": "Duck folklore speaks of your prowess, they're extremely rare" "description": "Duck folklore speaks of your prowess, they're extremely rare"
}, },
"8": { "8": {
"name": "Duck Deity", "name": "Duck Deity",
"min_ducks": 1000, "min_xp": 5000,
"max_ducks": 999999, "max_xp": 999999,
"befriend_success_rate": 50, "befriend_success_rate": 50,
"accuracy_modifier": -30, "accuracy_modifier": -30,
"duck_spawn_speed_modifier": 0.4, "duck_spawn_speed_modifier": 0.4,
"magazines": 1,
"bullets_per_magazine": 6,
"description": "You've transcended mortal hunting, ducks are mythically scarce" "description": "You've transcended mortal hunting, ducks are mythically scarce"
} }
} }

View File

@@ -1,30 +1,32 @@
{ {
"duck_spawn": [ "duck_spawn": [
"・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}", "・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}",
"・゜゜・。。・゜゜\\_O> {yellow}*flap flap*" "・゜゜・。。・゜゜\\_o< {light_grey}quack~{reset}",
"・゜゜・。。・゜゜\\_O> {bold}*flap flap*{reset}"
], ],
"duck_flies_away": "The {cyan}duck{reset} flies away. ·°'`'°-.,¸¸.·°'`", "duck_flies_away": "The {bold}duck{reset} flies away. ·°'`'°-.,¸¸.·°'`",
"bang_hit": "{nick} > {red}*BANG*{reset} You shot the {cyan}duck{reset}! [{green}+{xp_gained} xp{reset}] [Total ducks: {blue}{ducks_shot}{reset}]", "bang_hit": "{nick} > {green}*BANG*{reset} You shot the duck! [{green}+{xp_gained} xp{reset}] [Total ducks: {bold}{ducks_shot}{reset}]",
"bang_miss": "{nick} > {red}*BANG*{reset} You missed the {cyan}duck{reset}!", "bang_miss": "{nick} > {red}*BANG*{reset} You missed the {cyan}duck{reset}!",
"bang_no_duck": "{nick} > {red}*BANG*{reset} What did you shoot at? There is {red}no duck{reset} in the area... [{red}GUN CONFISCATED{reset}]", "bang_no_duck": "{nick} > {red}*BANG*{reset} What did you shoot at? There is {red}no duck{reset} in the area... [{red}GUN CONFISCATED{reset}]",
"bang_no_ammo": "{nick} > {orange}*click*{reset} You're out of ammo! Use {blue}!reload{reset}", "bang_no_ammo": "{nick} > {orange}*click*{reset} You're out of ammo! Use {blue}!reload{reset}",
"bang_not_armed": "{nick} > You are {red}not armed{reset}.", "bang_not_armed": "{nick} > You are {red}not armed{reset}.",
"bef_success": "{nick} > {green}*befriend*{reset} You befriended the {cyan}duck{reset}! [{green}+{xp_gained} xp{reset}] [Ducks befriended: {pink}{ducks_befriended}{reset}]", "bef_success": "{nick} > {orange}*befriend*{reset} You befriended the duck! [{green}+{xp_gained} xp{reset}] [Ducks befriended: {bold}{ducks_befriended}{reset}]",
"bef_failed": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} doesn't trust you and {yellow}flies away{reset}...", "bef_failed": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} doesn't trust you and {yellow}flies away{reset}...",
"bef_no_duck": "{nick} > {pink}*gentle approach*{reset} There is {red}no duck{reset} to befriend in the area...", "bef_no_duck": "{nick} > {pink}*gentle approach*{reset} There is {red}no duck{reset} to befriend in the area...",
"bef_duck_shot": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} is {red}already dead{reset}! You can't befriend it now...", "bef_duck_shot": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} is {red}already dead{reset}! You can't befriend it now...",
"reload_success": "{nick} > {orange}*click*{reset} Reloaded! [Ammo: {green}{ammo}{reset}/{green}{max_ammo}{reset}] [Chargers: {blue}{chargers}{reset}]", "reload_success": "{nick} > {orange}*click*{reset} New magazine loaded! [Ammo: {green}{ammo}{reset}/{green}{max_ammo}{reset}] [Spare magazines: {blue}{chargers}{reset}]",
"reload_already_loaded": "{nick} > Your gun is {green}already loaded{reset}!", "reload_already_loaded": "{nick} > Your gun is {green}already loaded{reset}!",
"reload_no_chargers": "{nick} > You're out of {red}chargers{reset}!", "reload_no_chargers": "{nick} > You're out of {red}spare magazines{reset}!",
"reload_not_armed": "{nick} > You are {red}not armed{reset}.", "reload_not_armed": "{nick} > You are {red}not armed{reset}.",
"shop_display": "DuckHunt Shop: {items} | You have {green}{xp} XP{reset}", "shop_display": "DuckHunt Shop: {items} | You have {green}{xp} XP{reset}",
"shop_item_format": "({blue}{id}{reset}) {cyan}{name}{reset} - {green}{price} XP{reset}", "shop_item_format": "({blue}{id}{reset}) {cyan}{name}{reset} - {green}{price} XP{reset}",
"help_header": "{blue}DuckHunt Commands:{reset}", "help_header": "{blue}DuckHunt Commands:{reset}",
"help_user_commands": "{blue}!bang{reset} - Shoot at ducks | {blue}!bef{reset} - Befriend ducks | {blue}!reload{reset} - Reload your gun | {blue}!shop{reset} - View the shop", "help_user_commands": "{blue}!bang{reset} - Shoot at ducks | {blue}!bef{reset} - Befriend ducks | {blue}!reload{reset} - Reload your gun | {blue}!shop{reset} - View/buy from shop | {blue}!duckstats{reset} - View your stats and items | {blue}!use{reset} - Use inventory items",
"help_help_command": "{blue}!duckhelp{reset} - Show this help", "help_help_command": "{blue}!duckhelp{reset} - Show this help",
"help_admin_commands": "{red}Admin:{reset} {blue}!rearm <player>{reset} | {blue}!disarm <player>{reset} | {blue}!ignore <player>{reset} | {blue}!unignore <player>{reset} | {blue}!ducklaunch{reset} | {blue}!reloadshop{reset}", "help_admin_commands": "{red}Admin:{reset} {blue}!rearm <player>{reset} | {blue}!disarm <player>{reset} | {blue}!ignore <player>{reset} | {blue}!unignore <player>{reset} | {blue}!ducklaunch{reset}",
"admin_rearm_player": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been rearmed by {blue}{admin}{reset}", "admin_rearm_player": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been rearmed by {blue}{admin}{reset}",
"admin_rearm_all": "[{red}ADMIN{reset}] All players have been rearmed by {blue}{admin}{reset}", "admin_rearm_all": "[{red}ADMIN{reset}] All players have been rearmed by {blue}{admin}{reset}",
"admin_rearm_self": "[{red}ADMIN{reset}] {blue}{admin}{reset} has rearmed themselves",
"admin_disarm": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been disarmed by {blue}{admin}{reset}", "admin_disarm": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been disarmed by {blue}{admin}{reset}",
"admin_ignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is now ignored by {blue}{admin}{reset}", "admin_ignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is now ignored by {blue}{admin}{reset}",
"admin_unignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is no longer ignored by {blue}{admin}{reset}", "admin_unignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is no longer ignored by {blue}{admin}{reset}",

View File

@@ -8,18 +8,11 @@
"amount": 1 "amount": 1
}, },
"2": { "2": {
"name": "Accuracy Boost", "name": "Magazine",
"price": 20, "price": 15,
"description": "+10% accuracy", "description": "1 extra magazine",
"type": "accuracy", "type": "magazine",
"amount": 10 "amount": 1
},
"3": {
"name": "Lucky Charm",
"price": 30,
"description": "+5% duck spawn chance",
"type": "luck",
"amount": 5
} }
} }
} }

View File

@@ -64,10 +64,34 @@ class DuckDB:
if nick_lower not in self.players: if nick_lower not in self.players:
self.players[nick_lower] = self.create_player(nick) self.players[nick_lower] = self.create_player(nick)
else: else:
# Ensure existing players have new fields # Ensure existing players have new fields and migrate from old system
player = self.players[nick_lower] player = self.players[nick_lower]
if 'ducks_befriended' not in player: if 'ducks_befriended' not in player:
player['ducks_befriended'] = 0 player['ducks_befriended'] = 0
if 'inventory' not in player:
player['inventory'] = {}
if 'temporary_effects' not in player:
player['temporary_effects'] = []
# Migrate from old ammo/chargers system to magazine system
if 'magazines' not in player:
# Convert old system: assume they had full magazines
old_ammo = player.get('ammo', 6)
old_chargers = player.get('chargers', 2)
player['current_ammo'] = old_ammo
player['magazines'] = old_chargers + 1 # +1 for current loaded magazine
player['bullets_per_magazine'] = 6
# Remove old fields
if 'ammo' in player:
del player['ammo']
if 'max_ammo' in player:
del player['max_ammo']
if 'chargers' in player:
del player['chargers']
if 'max_chargers' in player:
del player['max_chargers']
return self.players[nick_lower] return self.players[nick_lower]
@@ -78,10 +102,11 @@ class DuckDB:
'xp': 0, 'xp': 0,
'ducks_shot': 0, 'ducks_shot': 0,
'ducks_befriended': 0, 'ducks_befriended': 0,
'ammo': 6, 'current_ammo': 6, # Bullets in current magazine
'max_ammo': 6, 'magazines': 3, # Total magazines (including current)
'chargers': 2, 'bullets_per_magazine': 6, # Bullets per magazine
'max_chargers': 2,
'accuracy': 65, 'accuracy': 65,
'gun_confiscated': False 'gun_confiscated': False,
'inventory': {}, # {item_id: quantity}
'temporary_effects': [] # List of temporary effects
} }

View File

@@ -1,7 +1,6 @@
import asyncio import asyncio
import ssl import ssl
import json import json
import random
import logging import logging
import sys import sys
import os import os
@@ -15,6 +14,7 @@ from .db import DuckDB
from .game import DuckGame from .game import DuckGame
from .sasl import SASLHandler from .sasl import SASLHandler
from .shop import ShopManager from .shop import ShopManager
from .levels import LevelManager
class DuckHuntBot: class DuckHuntBot:
@@ -41,6 +41,10 @@ class DuckHuntBot:
shop_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'shop.json') shop_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'shop.json')
self.shop = ShopManager(shop_file) self.shop = ShopManager(shop_file)
# Initialize level manager
levels_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'levels.json')
self.levels = LevelManager(levels_file)
def get_config(self, path, default=None): def get_config(self, path, default=None):
"""Get configuration value using dot notation""" """Get configuration value using dot notation"""
keys = path.split('.') keys = path.split('.')
@@ -59,13 +63,40 @@ class DuckHuntBot:
nick = user.split('!')[0].lower() nick = user.split('!')[0].lower()
return nick in self.admins return nick in self.admins
def _handle_single_target_admin_command(self, args, usage_message_key, action_func, success_message_key, nick, channel):
"""Helper for admin commands that target a single player"""
if not args:
message = self.messages.get(usage_message_key)
self.send_message(channel, message)
return False
target = args[0].lower()
player = self.db.get_player(target)
action_func(player)
message = self.messages.get(success_message_key, target=target, admin=nick)
self.send_message(channel, message)
self.db.save_database()
return True
def setup_signal_handlers(self): def setup_signal_handlers(self):
"""Setup signal handlers for graceful shutdown""" """Setup signal handlers for immediate shutdown"""
def signal_handler(signum, frame): def signal_handler(signum, frame):
signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM" signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
self.logger.info(f"🛑 Received {signal_name} (Ctrl+C), initiating graceful shutdown...") self.logger.info(f"🛑 Received {signal_name} (Ctrl+C), shutting down immediately...")
self.shutdown_requested = True self.shutdown_requested = True
# Cancel all running tasks immediately
try:
# Get the current event loop and cancel all tasks
loop = asyncio.get_running_loop()
tasks = [t for t in asyncio.all_tasks(loop) if not t.done()]
for task in tasks:
task.cancel()
self.logger.info(f"🔄 Cancelled {len(tasks)} running tasks")
except Exception as e:
self.logger.error(f"Error cancelling tasks: {e}")
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
@@ -147,6 +178,10 @@ class DuckHuntBot:
await self.handle_reload(nick, channel, player) await self.handle_reload(nick, channel, player)
elif cmd == "shop": elif cmd == "shop":
await self.handle_shop(nick, channel, player, args) await self.handle_shop(nick, channel, player, args)
elif cmd == "duckstats":
await self.handle_duckstats(nick, channel, player)
elif cmd == "use":
await self.handle_use(nick, channel, player, args)
elif cmd == "duckhelp": elif cmd == "duckhelp":
await self.handle_duckhelp(nick, channel, player) await self.handle_duckhelp(nick, channel, player)
elif cmd == "rearm" and self.is_admin(user): elif cmd == "rearm" and self.is_admin(user):
@@ -159,157 +194,66 @@ class DuckHuntBot:
await self.handle_unignore(nick, channel, args) await self.handle_unignore(nick, channel, args)
elif cmd == "ducklaunch" and self.is_admin(user): elif cmd == "ducklaunch" and self.is_admin(user):
await self.handle_ducklaunch(nick, channel, args) await self.handle_ducklaunch(nick, channel, args)
elif cmd == "reloadshop" and self.is_admin(user):
await self.handle_reloadshop(nick, channel, args)
async def handle_bang(self, nick, channel, player): async def handle_bang(self, nick, channel, player):
"""Handle !bang command""" """Handle !bang command"""
# Check if gun is confiscated result = self.game.shoot_duck(nick, channel, player)
if player.get('gun_confiscated', False): message = self.messages.get(result['message_key'], **result['message_args'])
message = self.messages.get('bang_not_armed', nick=nick) self.send_message(channel, message)
self.send_message(channel, message)
return
# Check ammo
if player['ammo'] <= 0:
message = self.messages.get('bang_no_ammo', nick=nick)
self.send_message(channel, message)
return
# Check for duck
if channel not in self.game.ducks or not self.game.ducks[channel]:
# Wild shot - gun confiscated
player['ammo'] -= 1
player['gun_confiscated'] = True
message = self.messages.get('bang_no_duck', nick=nick)
self.send_message(channel, message)
self.db.save_database()
return
# Shoot at duck
player['ammo'] -= 1
# Calculate hit chance
hit_chance = player.get('accuracy', 65) / 100.0
if random.random() < hit_chance:
# Hit! Remove the duck
duck = self.game.ducks[channel].pop(0)
xp_gained = 10
player['xp'] = player.get('xp', 0) + xp_gained
player['ducks_shot'] = player.get('ducks_shot', 0) + 1
player['accuracy'] = min(player.get('accuracy', 65) + 1, 100)
message = self.messages.get('bang_hit',
nick=nick,
xp_gained=xp_gained,
ducks_shot=player['ducks_shot'])
self.send_message(channel, message)
else:
# Miss! Duck stays in the channel
player['accuracy'] = max(player.get('accuracy', 65) - 2, 10)
message = self.messages.get('bang_miss', nick=nick)
self.send_message(channel, message)
self.db.save_database()
async def handle_bef(self, nick, channel, player): async def handle_bef(self, nick, channel, player):
"""Handle !bef (befriend) command""" """Handle !bef (befriend) command"""
# Check for duck result = self.game.befriend_duck(nick, channel, player)
if channel not in self.game.ducks or not self.game.ducks[channel]: message = self.messages.get(result['message_key'], **result['message_args'])
message = self.messages.get('bef_no_duck', nick=nick) self.send_message(channel, message)
self.send_message(channel, message)
return
# Check befriend success rate from config (default 75%)
success_rate_config = self.get_config('befriend_success_rate', 75)
try:
success_rate = float(success_rate_config) / 100.0
except (ValueError, TypeError):
success_rate = 0.75 # 75% default
if random.random() < success_rate:
# Success - befriend the duck
duck = self.game.ducks[channel].pop(0)
# Lower XP gain than shooting (5 instead of 10)
xp_gained = 5
player['xp'] = player.get('xp', 0) + xp_gained
player['ducks_befriended'] = player.get('ducks_befriended', 0) + 1
message = self.messages.get('bef_success',
nick=nick,
xp_gained=xp_gained,
ducks_befriended=player['ducks_befriended'])
self.send_message(channel, message)
else:
# Failure - duck flies away, remove from channel
duck = self.game.ducks[channel].pop(0)
message = self.messages.get('bef_failed', nick=nick)
self.send_message(channel, message)
self.db.save_database()
async def handle_reload(self, nick, channel, player): async def handle_reload(self, nick, channel, player):
"""Handle !reload command""" """Handle !reload command"""
if player.get('gun_confiscated', False): result = self.game.reload_gun(nick, channel, player)
message = self.messages.get('reload_not_armed', nick=nick) message = self.messages.get(result['message_key'], **result['message_args'])
self.send_message(channel, message)
return
if player['ammo'] >= player.get('max_ammo', 6):
message = self.messages.get('reload_already_loaded', nick=nick)
self.send_message(channel, message)
return
if player.get('chargers', 2) <= 0:
message = self.messages.get('reload_no_chargers', nick=nick)
self.send_message(channel, message)
return
player['ammo'] = player.get('max_ammo', 6)
player['chargers'] = player.get('chargers', 2) - 1
message = self.messages.get('reload_success',
nick=nick,
ammo=player['ammo'],
max_ammo=player.get('max_ammo', 6),
chargers=player['chargers'])
self.send_message(channel, message) self.send_message(channel, message)
self.db.save_database()
async def handle_shop(self, nick, channel, player, args=None): async def handle_shop(self, nick, channel, player, args=None):
"""Handle !shop command""" """Handle !shop command"""
# Handle buying: !shop buy <item_id> # Handle buying: !shop buy <item_id> [target] or !shop <item_id> [target]
if args and len(args) >= 2 and args[0].lower() == "buy": if args and len(args) >= 1:
try: # Check for "buy" subcommand or direct item ID
item_id = int(args[1]) start_idx = 0
await self.handle_shop_buy(nick, channel, player, item_id) if args[0].lower() == "buy":
return start_idx = 1
except (ValueError, IndexError):
message = self.messages.get('shop_buy_usage', nick=nick) if len(args) > start_idx:
try:
item_id = int(args[start_idx])
target_nick = args[start_idx + 1] if len(args) > start_idx + 1 else None
# If no target specified, store in inventory. If target specified, use immediately.
store_in_inventory = target_nick is None
await self.handle_shop_buy(nick, channel, player, item_id, target_nick, store_in_inventory)
return
except (ValueError, IndexError):
message = self.messages.get('shop_buy_usage', nick=nick)
self.send_message(channel, message)
return
# Display shop items using ShopManager
shop_text = self.shop.get_shop_display(player, self.messages)
self.send_message(channel, shop_text)
async def handle_shop_buy(self, nick, channel, player, item_id, target_nick=None, store_in_inventory=False):
"""Handle buying an item from the shop"""
target_player = None
# Get target player if specified
if target_nick:
target_player = self.db.get_player(target_nick)
if not target_player:
message = f"{nick} > Target player '{target_nick}' not found"
self.send_message(channel, message) self.send_message(channel, message)
return return
# Display shop items
items = []
for item_id, item in self.shop.get_items().items():
item_text = self.messages.get('shop_item_format',
id=item_id,
name=item['name'],
price=item['price'])
items.append(item_text)
shop_text = self.messages.get('shop_display',
items=" | ".join(items),
xp=player.get('xp', 0))
self.send_message(channel, shop_text)
async def handle_shop_buy(self, nick, channel, player, item_id):
"""Handle buying an item from the shop"""
# Use ShopManager to handle the purchase # Use ShopManager to handle the purchase
result = self.shop.purchase_item(player, item_id) result = self.shop.purchase_item(player, item_id, target_player, store_in_inventory)
if not result["success"]: if not result["success"]:
# Handle different error types # Handle different error types
@@ -321,6 +265,10 @@ class DuckHuntBot:
item_name=result["item_name"], item_name=result["item_name"],
price=result["price"], price=result["price"],
current_xp=result["current_xp"]) current_xp=result["current_xp"])
elif result["error"] == "target_required":
message = f"{nick} > {result['message']}"
elif result["error"] == "invalid_storage":
message = f"{nick} > {result['message']}"
else: else:
message = f"{nick} > Error: {result['message']}" message = f"{nick} > Error: {result['message']}"
@@ -328,11 +276,17 @@ class DuckHuntBot:
return return
# Purchase successful # Purchase successful
message = self.messages.get('shop_buy_success', if result.get("stored_in_inventory"):
nick=nick, message = f"{nick} > Successfully purchased {result['item_name']} for {result['price']} XP! Stored in inventory (x{result['inventory_count']}). Remaining XP: {result['remaining_xp']}"
item_name=result["item_name"], elif result.get("target_affected"):
price=result["price"], message = f"{nick} > Used {result['item_name']} on {target_nick}! Remaining XP: {result['remaining_xp']}"
remaining_xp=result["remaining_xp"]) else:
message = self.messages.get('shop_buy_success',
nick=nick,
item_name=result["item_name"],
price=result["price"],
remaining_xp=result["remaining_xp"])
self.send_message(channel, message) self.send_message(channel, message)
self.db.save_database() self.db.save_database()
@@ -351,71 +305,130 @@ class DuckHuntBot:
for line in help_lines: for line in help_lines:
self.send_message(channel, line) self.send_message(channel, line)
async def handle_duckstats(self, nick, channel, player):
"""Handle !duckstats command - show player stats and inventory"""
# Get player level info
level_info = self.levels.get_player_level_info(player)
level = self.levels.calculate_player_level(player)
# Build stats message
stats_parts = [
f"Level {level} {level_info.get('name', 'Unknown')}",
f"XP: {player.get('xp', 0)}",
f"Ducks Shot: {player.get('ducks_shot', 0)}",
f"Ducks Befriended: {player.get('ducks_befriended', 0)}",
f"Accuracy: {player.get('accuracy', 65)}%",
f"Ammo: {player.get('current_ammo', 0)}/{player.get('bullets_per_magazine', 6)}",
f"Magazines: {player.get('magazines', 1)}"
]
stats_message = f"{nick} > Stats: {' | '.join(stats_parts)}"
self.send_message(channel, stats_message)
# Show inventory if not empty
inventory_info = self.shop.get_inventory_display(player)
if not inventory_info["empty"]:
items_text = []
for item in inventory_info["items"]:
items_text.append(f"{item['id']}: {item['name']} x{item['quantity']}")
inventory_message = f"{nick} > Inventory: {' | '.join(items_text)}"
self.send_message(channel, inventory_message)
async def handle_use(self, nick, channel, player, args):
"""Handle !use command"""
if not args:
message = f"{nick} > Usage: !use <item_id> [target]"
self.send_message(channel, message)
return
try:
item_id = int(args[0])
target_nick = args[1] if len(args) > 1 else None
target_player = None
# Get target player if specified
if target_nick:
target_player = self.db.get_player(target_nick)
if not target_player:
message = f"{nick} > Target player '{target_nick}' not found"
self.send_message(channel, message)
return
# Use item from inventory
result = self.shop.use_inventory_item(player, item_id, target_player)
if not result["success"]:
message = f"{nick} > {result['message']}"
else:
if result.get("target_affected"):
message = f"{nick} > Used {result['item_name']} on {target_nick}!"
else:
message = f"{nick} > Used {result['item_name']}!"
# Add remaining count if any
if result.get("remaining_in_inventory", 0) > 0:
message += f" ({result['remaining_in_inventory']} remaining)"
self.send_message(channel, message)
self.db.save_database()
except ValueError:
message = f"{nick} > Invalid item ID. Use !duckstats to see your items."
self.send_message(channel, message)
async def handle_rearm(self, nick, channel, args): async def handle_rearm(self, nick, channel, args):
"""Handle !rearm command (admin only)""" """Handle !rearm command (admin only)"""
if args: if args:
target = args[0].lower() target = args[0].lower()
player = self.db.get_player(target) player = self.db.get_player(target)
player['gun_confiscated'] = False player['gun_confiscated'] = False
player['ammo'] = player.get('max_ammo', 6)
player['chargers'] = 2 # Update magazines based on player level
self.levels.update_player_magazines(player)
player['current_ammo'] = player.get('bullets_per_magazine', 6)
message = self.messages.get('admin_rearm_player', target=target, admin=nick) message = self.messages.get('admin_rearm_player', target=target, admin=nick)
self.send_message(channel, message) self.send_message(channel, message)
else: else:
# Rearm everyone # Rearm the admin themselves
for player_data in self.db.players.values(): player = self.db.get_player(nick)
player_data['gun_confiscated'] = False player['gun_confiscated'] = False
player_data['ammo'] = player_data.get('max_ammo', 6)
player_data['chargers'] = 2 # Update magazines based on admin's level
message = self.messages.get('admin_rearm_all', admin=nick) self.levels.update_player_magazines(player)
player['current_ammo'] = player.get('bullets_per_magazine', 6)
message = self.messages.get('admin_rearm_self', admin=nick)
self.send_message(channel, message) self.send_message(channel, message)
self.db.save_database() self.db.save_database()
async def handle_disarm(self, nick, channel, args): async def handle_disarm(self, nick, channel, args):
"""Handle !disarm command (admin only)""" """Handle !disarm command (admin only)"""
if not args: def disarm_player(player):
message = self.messages.get('usage_disarm') player['gun_confiscated'] = True
self.send_message(channel, message)
return
target = args[0].lower() self._handle_single_target_admin_command(
player = self.db.get_player(target) args, 'usage_disarm', disarm_player, 'admin_disarm', nick, channel
player['gun_confiscated'] = True )
message = self.messages.get('admin_disarm', target=target, admin=nick)
self.send_message(channel, message)
self.db.save_database()
async def handle_ignore(self, nick, channel, args): async def handle_ignore(self, nick, channel, args):
"""Handle !ignore command (admin only)""" """Handle !ignore command (admin only)"""
if not args: def ignore_player(player):
message = self.messages.get('usage_ignore') player['ignored'] = True
self.send_message(channel, message)
return
target = args[0].lower() self._handle_single_target_admin_command(
player = self.db.get_player(target) args, 'usage_ignore', ignore_player, 'admin_ignore', nick, channel
player['ignored'] = True )
message = self.messages.get('admin_ignore', target=target, admin=nick)
self.send_message(channel, message)
self.db.save_database()
async def handle_unignore(self, nick, channel, args): async def handle_unignore(self, nick, channel, args):
"""Handle !unignore command (admin only)""" """Handle !unignore command (admin only)"""
if not args: def unignore_player(player):
message = self.messages.get('usage_unignore') player['ignored'] = False
self.send_message(channel, message)
return
target = args[0].lower() self._handle_single_target_admin_command(
player = self.db.get_player(target) args, 'usage_unignore', unignore_player, 'admin_unignore', nick, channel
player['ignored'] = False )
message = self.messages.get('admin_unignore', target=target, admin=nick)
self.send_message(channel, message)
self.db.save_database()
async def handle_ducklaunch(self, nick, channel, args): async def handle_ducklaunch(self, nick, channel, args):
"""Handle !ducklaunch command (admin only)""" """Handle !ducklaunch command (admin only)"""
@@ -428,27 +441,23 @@ class DuckHuntBot:
if channel not in self.game.ducks: if channel not in self.game.ducks:
self.game.ducks[channel] = [] self.game.ducks[channel] = []
self.game.ducks[channel].append({"spawn_time": time.time()}) self.game.ducks[channel].append({"spawn_time": time.time()})
admin_message = self.messages.get('admin_ducklaunch', admin=nick)
duck_message = self.messages.get('duck_spawn') duck_message = self.messages.get('duck_spawn')
self.send_message(channel, admin_message) # Only send the duck spawn message, no admin notification
self.send_message(channel, duck_message) self.send_message(channel, duck_message)
async def handle_reloadshop(self, nick, channel, args):
"""Handle !reloadshop admin command"""
old_count = len(self.shop.get_items())
new_count = self.shop.reload_items()
message = f"[ADMIN] Shop reloaded by {nick} - {new_count} items loaded"
self.send_message(channel, message)
self.logger.info(f"Shop reloaded by admin {nick}: {old_count} -> {new_count} items")
async def message_loop(self): async def message_loop(self):
"""Main message processing loop""" """Main message processing loop with responsive shutdown"""
try: try:
while not self.shutdown_requested and self.reader: while not self.shutdown_requested and self.reader:
line = await self.reader.readline() try:
# Use a timeout on readline to make it more responsive to shutdown
line = await asyncio.wait_for(self.reader.readline(), timeout=1.0)
except asyncio.TimeoutError:
# Check shutdown flag and continue
continue
if not line: if not line:
break break
@@ -470,7 +479,7 @@ class DuckHuntBot:
self.logger.info("Message loop ended") self.logger.info("Message loop ended")
async def run(self): async def run(self):
"""Main bot loop with improved shutdown handling""" """Main bot loop with fast shutdown handling"""
self.setup_signal_handlers() self.setup_signal_handlers()
game_task = None game_task = None
@@ -486,79 +495,69 @@ class DuckHuntBot:
self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.") self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.")
# Wait for shutdown signal or task completion # Wait for shutdown signal or task completion with frequent checks
done, pending = await asyncio.wait( while not self.shutdown_requested:
[game_task, message_task], done, pending = await asyncio.wait(
return_when=asyncio.FIRST_COMPLETED [game_task, message_task],
) timeout=0.1, # Check every 100ms for shutdown
return_when=asyncio.FIRST_COMPLETED
)
# Cancel remaining tasks # If any task completed, break out
for task in pending: if done:
if not task.done(): break
task.cancel()
try: self.logger.info("🔄 Shutdown initiated, cleaning up...")
await task
except asyncio.CancelledError:
self.logger.debug(f"Task cancelled: {task}")
except asyncio.TimeoutError:
self.logger.debug(f"Task timed out: {task}")
except asyncio.CancelledError: except asyncio.CancelledError:
self.logger.info("🛑 Main loop cancelled") self.logger.info("🛑 Main loop cancelled")
except Exception as e: except Exception as e:
self.logger.error(f"❌ Bot error: {e}") self.logger.error(f"❌ Bot error: {e}")
finally: finally:
self.logger.info("🔄 Final cleanup...") # Fast cleanup - cancel tasks immediately with short timeout
tasks_to_cancel = [task for task in [game_task, message_task] if task and not task.done()]
for task in tasks_to_cancel:
task.cancel()
# Ensure tasks are cancelled # Wait briefly for tasks to cancel
for task in [game_task, message_task]: if tasks_to_cancel:
if task and not task.done(): try:
task.cancel() await asyncio.wait_for(
try: asyncio.gather(*tasks_to_cancel, return_exceptions=True),
await task timeout=1.0
except asyncio.CancelledError: )
pass except asyncio.TimeoutError:
self.logger.warning("⚠️ Task cancellation timed out")
# Final database save # Quick database save
try: try:
self.db.save_database() self.db.save_database()
self.logger.info("💾 Database saved") self.logger.info("💾 Database saved")
except Exception as e: except Exception as e:
self.logger.error(f"❌ Error saving database: {e}") self.logger.error(f"❌ Error saving database: {e}")
# Close IRC connection # Fast connection close
await self._close_connection() await self._close_connection()
self.logger.info("✅ Bot shutdown complete") self.logger.info("✅ Bot shutdown complete")
async def _graceful_shutdown(self):
"""Perform graceful shutdown steps"""
try:
# Send quit message to IRC
if self.writer and not self.writer.is_closing():
self.logger.info("📤 Sending QUIT message to IRC...")
quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down')
self.send_raw(f"QUIT :{quit_message}")
# Give IRC server time to process quit
await asyncio.sleep(0.5)
# Save database
self.logger.info("💾 Saving database...")
self.db.save_database()
except Exception as e:
self.logger.error(f"❌ Error during graceful shutdown: {e}")
async def _close_connection(self): async def _close_connection(self):
"""Close IRC connection safely""" """Close IRC connection quickly"""
if self.writer: if self.writer:
try: try:
if not self.writer.is_closing(): if not self.writer.is_closing():
# Send quit message quickly without waiting
try:
quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down')
self.send_raw(f"QUIT :{quit_message}")
await asyncio.sleep(0.1) # Very brief wait
except:
pass # Don't block on quit message
self.writer.close() self.writer.close()
await asyncio.wait_for(self.writer.wait_closed(), timeout=3.0) await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
self.logger.info("🔌 IRC connection closed") self.logger.info("🔌 IRC connection closed")
except asyncio.TimeoutError: except asyncio.TimeoutError:
self.logger.warning("⚠️ Connection close timed out") self.logger.warning("⚠️ Connection close timed out - forcing close")
except Exception as e: except Exception as e:
self.logger.error(f"❌ Error closing connection: {e}") self.logger.error(f"❌ Error closing connection: {e}")

View File

@@ -1,6 +1,6 @@
""" """
Simplified Game mechanics for DuckHunt Bot Game mechanics for DuckHunt Bot
Basic duck spawning and timeout only Handles duck spawning, shooting, befriending, and other game actions
""" """
import asyncio import asyncio
@@ -10,7 +10,7 @@ import logging
class DuckGame: class DuckGame:
"""Simplified game mechanics - just duck spawning""" """Game mechanics for DuckHunt - shooting, befriending, reloading"""
def __init__(self, bot, db): def __init__(self, bot, db):
self.bot = bot self.bot = bot
@@ -31,15 +31,17 @@ class DuckGame:
self.logger.info("Game loops cancelled") self.logger.info("Game loops cancelled")
async def duck_spawn_loop(self): async def duck_spawn_loop(self):
"""Simple duck spawning loop""" """Duck spawning loop with responsive shutdown"""
try: try:
while True: while True:
# Wait random time between spawns # Wait random time between spawns, but in small chunks for responsiveness
min_wait = self.bot.get_config('duck_spawn_min', 300) # 5 minutes min_wait = self.bot.get_config('duck_spawn_min', 300) # 5 minutes
max_wait = self.bot.get_config('duck_spawn_max', 900) # 15 minutes max_wait = self.bot.get_config('duck_spawn_max', 900) # 15 minutes
wait_time = random.randint(min_wait, max_wait) wait_time = random.randint(min_wait, max_wait)
await asyncio.sleep(wait_time) # Sleep in 1-second intervals to allow for quick cancellation
for _ in range(wait_time):
await asyncio.sleep(1)
# Spawn duck in random channel # Spawn duck in random channel
channels = list(self.bot.channels_joined) channels = list(self.bot.channels_joined)
@@ -51,10 +53,11 @@ class DuckGame:
self.logger.info("Duck spawning loop cancelled") self.logger.info("Duck spawning loop cancelled")
async def duck_timeout_loop(self): async def duck_timeout_loop(self):
"""Simple duck timeout loop""" """Duck timeout loop with responsive shutdown"""
try: try:
while True: while True:
await asyncio.sleep(10) # Check every 10 seconds # Check every 2 seconds instead of 10 for more responsiveness
await asyncio.sleep(2)
current_time = time.time() current_time = time.time()
channels_to_clear = [] channels_to_clear = []
@@ -103,3 +106,183 @@ class DuckGame:
self.bot.send_message(channel, message) self.bot.send_message(channel, message)
self.logger.info(f"Duck spawned in {channel}") self.logger.info(f"Duck spawned in {channel}")
def shoot_duck(self, nick, channel, player):
"""Handle shooting at a duck"""
# Check if gun is confiscated
if player.get('gun_confiscated', False):
return {
'success': False,
'message_key': 'bang_not_armed',
'message_args': {'nick': nick}
}
# Check ammo
if player.get('current_ammo', 0) <= 0:
return {
'success': False,
'message_key': 'bang_no_ammo',
'message_args': {'nick': nick}
}
# Check for duck
if channel not in self.ducks or not self.ducks[channel]:
# Wild shot - gun confiscated
player['current_ammo'] = player.get('current_ammo', 1) - 1
player['gun_confiscated'] = True
self.db.save_database()
return {
'success': False,
'message_key': 'bang_no_duck',
'message_args': {'nick': nick}
}
# Shoot at duck
player['current_ammo'] = player.get('current_ammo', 1) - 1
# Calculate hit chance using level-modified accuracy
modified_accuracy = self.bot.levels.get_modified_accuracy(player)
hit_chance = modified_accuracy / 100.0
if random.random() < hit_chance:
# Hit! Remove the duck
duck = self.ducks[channel].pop(0)
xp_gained = 10
old_level = self.bot.levels.calculate_player_level(player)
player['xp'] = player.get('xp', 0) + xp_gained
player['ducks_shot'] = player.get('ducks_shot', 0) + 1
player['accuracy'] = min(player.get('accuracy', 65) + 1, 100)
# Check if player leveled up and update magazines if needed
new_level = self.bot.levels.calculate_player_level(player)
if new_level != old_level:
self.bot.levels.update_player_magazines(player)
self.db.save_database()
return {
'success': True,
'hit': True,
'message_key': 'bang_hit',
'message_args': {
'nick': nick,
'xp_gained': xp_gained,
'ducks_shot': player['ducks_shot']
}
}
else:
# Miss! Duck stays in the channel
player['accuracy'] = max(player.get('accuracy', 65) - 2, 10)
self.db.save_database()
return {
'success': True,
'hit': False,
'message_key': 'bang_miss',
'message_args': {'nick': nick}
}
def befriend_duck(self, nick, channel, player):
"""Handle befriending a duck"""
# Check for duck
if channel not in self.ducks or not self.ducks[channel]:
return {
'success': False,
'message_key': 'bef_no_duck',
'message_args': {'nick': nick}
}
# Check befriend success rate from config and level modifiers
base_rate = self.bot.get_config('befriend_success_rate', 75)
try:
if base_rate is not None:
base_rate = float(base_rate)
else:
base_rate = 75.0
except (ValueError, TypeError):
base_rate = 75.0
# Apply level-based modification to befriend rate
level_modified_rate = self.bot.levels.get_modified_befriend_rate(player, base_rate)
success_rate = level_modified_rate / 100.0
if random.random() < success_rate:
# Success - befriend the duck
duck = self.ducks[channel].pop(0)
# Lower XP gain than shooting (5 instead of 10)
xp_gained = 5
old_level = self.bot.levels.calculate_player_level(player)
player['xp'] = player.get('xp', 0) + xp_gained
player['ducks_befriended'] = player.get('ducks_befriended', 0) + 1
# Check if player leveled up and update magazines if needed
new_level = self.bot.levels.calculate_player_level(player)
if new_level != old_level:
self.bot.levels.update_player_magazines(player)
self.db.save_database()
return {
'success': True,
'befriended': True,
'message_key': 'bef_success',
'message_args': {
'nick': nick,
'xp_gained': xp_gained,
'ducks_befriended': player['ducks_befriended']
}
}
else:
# Failure - duck flies away, remove from channel
duck = self.ducks[channel].pop(0)
self.db.save_database()
return {
'success': True,
'befriended': False,
'message_key': 'bef_failed',
'message_args': {'nick': nick}
}
def reload_gun(self, nick, channel, player):
"""Handle reloading a gun (switching to a new magazine)"""
if player.get('gun_confiscated', False):
return {
'success': False,
'message_key': 'reload_not_armed',
'message_args': {'nick': nick}
}
current_ammo = player.get('current_ammo', 0)
bullets_per_mag = player.get('bullets_per_magazine', 6)
# Check if current magazine is already full
if current_ammo >= bullets_per_mag:
return {
'success': False,
'message_key': 'reload_already_loaded',
'message_args': {'nick': nick}
}
# Check if they have spare magazines
total_magazines = player.get('magazines', 1)
if total_magazines <= 1: # Only the current magazine
return {
'success': False,
'message_key': 'reload_no_chargers',
'message_args': {'nick': nick}
}
# Reload: discard current magazine and load a new full one
player['current_ammo'] = bullets_per_mag
player['magazines'] = total_magazines - 1
self.db.save_database()
return {
'success': True,
'message_key': 'reload_success',
'message_args': {
'nick': nick,
'ammo': player['current_ammo'],
'max_ammo': bullets_per_mag,
'chargers': player['magazines'] - 1 # Spare magazines (excluding current)
}
}

View File

@@ -38,14 +38,14 @@ class LevelManager:
"""Default fallback level system""" """Default fallback level system"""
return { return {
"level_calculation": { "level_calculation": {
"method": "total_ducks", "method": "xp",
"description": "Level based on total ducks interacted with" "description": "Level based on XP earned"
}, },
"levels": { "levels": {
"1": { "1": {
"name": "Duck Novice", "name": "Duck Novice",
"min_ducks": 0, "min_xp": 0,
"max_ducks": 9, "max_xp": 49,
"befriend_success_rate": 85, "befriend_success_rate": 85,
"accuracy_modifier": 5, "accuracy_modifier": 5,
"duck_spawn_speed_modifier": 1.0, "duck_spawn_speed_modifier": 1.0,
@@ -53,8 +53,8 @@ class LevelManager:
}, },
"2": { "2": {
"name": "Duck Hunter", "name": "Duck Hunter",
"min_ducks": 10, "min_xp": 50,
"max_ducks": 99, "max_xp": 299,
"befriend_success_rate": 75, "befriend_success_rate": 75,
"accuracy_modifier": 0, "accuracy_modifier": 0,
"duck_spawn_speed_modifier": 0.8, "duck_spawn_speed_modifier": 0.8,
@@ -65,20 +65,24 @@ class LevelManager:
def calculate_player_level(self, player: Dict[str, Any]) -> int: def calculate_player_level(self, player: Dict[str, Any]) -> int:
"""Calculate a player's current level based on their stats""" """Calculate a player's current level based on their stats"""
method = self.levels_data.get('level_calculation', {}).get('method', 'total_ducks') method = self.levels_data.get('level_calculation', {}).get('method', 'xp')
if method == 'total_ducks': if method == 'xp':
player_xp = player.get('xp', 0)
elif method == 'total_ducks':
# Fallback to duck-based calculation if specified
total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0) total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0)
elif method == 'xp': player_xp = total_ducks # Use duck count as if it were XP
total_ducks = player.get('xp', 0) // 10 # 10 XP per "duck equivalent"
else: else:
total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0) player_xp = player.get('xp', 0)
# Find the appropriate level # Find the appropriate level
levels = self.levels_data.get('levels', {}) levels = self.levels_data.get('levels', {})
for level_num in sorted(levels.keys(), key=int, reverse=True): for level_num in sorted(levels.keys(), key=int, reverse=True):
level_data = levels[level_num] level_data = levels[level_num]
if total_ducks >= level_data.get('min_ducks', 0): # Check for XP-based thresholds first, fallback to duck-based
min_threshold = level_data.get('min_xp', level_data.get('min_ducks', 0))
if player_xp >= min_threshold:
return int(level_num) return int(level_num)
return 1 # Default to level 1 return 1 # Default to level 1
@@ -102,15 +106,23 @@ class LevelManager:
"duck_spawn_speed_modifier": 1.0 "duck_spawn_speed_modifier": 1.0
} }
total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0) method = self.levels_data.get('level_calculation', {}).get('method', 'xp')
if method == 'xp':
current_value = player.get('xp', 0)
value_type = "xp"
else:
current_value = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0)
value_type = "ducks"
# Calculate progress to next level # Calculate progress to next level
next_level_data = self.get_level_data(level + 1) next_level_data = self.get_level_data(level + 1)
if next_level_data: if next_level_data:
ducks_needed = next_level_data.get('min_ducks', 0) - total_ducks threshold_key = f'min_{value_type}' if value_type == 'xp' else 'min_ducks'
next_threshold = next_level_data.get(threshold_key, 0)
needed_for_next = next_threshold - current_value
next_level_name = next_level_data.get('name', f"Level {level + 1}") next_level_name = next_level_data.get('name', f"Level {level + 1}")
else: else:
ducks_needed = 0 needed_for_next = 0
next_level_name = "Max Level" next_level_name = "Max Level"
return { return {
@@ -120,9 +132,11 @@ class LevelManager:
"befriend_success_rate": level_data.get('befriend_success_rate', 75), "befriend_success_rate": level_data.get('befriend_success_rate', 75),
"accuracy_modifier": level_data.get('accuracy_modifier', 0), "accuracy_modifier": level_data.get('accuracy_modifier', 0),
"duck_spawn_speed_modifier": level_data.get('duck_spawn_speed_modifier', 1.0), "duck_spawn_speed_modifier": level_data.get('duck_spawn_speed_modifier', 1.0),
"total_ducks": total_ducks, "current_xp": player.get('xp', 0),
"ducks_needed_for_next": max(0, ducks_needed), "total_ducks": player.get('ducks_shot', 0) + player.get('ducks_befriended', 0),
"next_level_name": next_level_name "needed_for_next": max(0, needed_for_next),
"next_level_name": next_level_name,
"value_type": value_type
} }
def get_modified_accuracy(self, player: Dict[str, Any]) -> int: def get_modified_accuracy(self, player: Dict[str, Any]) -> int:
@@ -164,3 +178,43 @@ class LevelManager:
new_count = len(self.levels_data.get('levels', {})) new_count = len(self.levels_data.get('levels', {}))
self.logger.info(f"Levels reloaded: {old_count} -> {new_count} levels") self.logger.info(f"Levels reloaded: {old_count} -> {new_count} levels")
return new_count return new_count
def update_player_magazines(self, player: Dict[str, Any]) -> Dict[str, Any]:
"""Update player's magazine count based on their current level"""
level_info = self.get_player_level_info(player)
level_magazines = level_info.get('magazines', 3)
level_bullets_per_mag = level_info.get('bullets_per_magazine', 6)
# Get current magazine status
current_magazines = player.get('magazines', 3)
current_ammo = player.get('current_ammo', 6)
current_bullets_per_mag = player.get('bullets_per_magazine', 6)
# Calculate total bullets they currently have
total_current_bullets = current_ammo + (current_magazines - 1) * current_bullets_per_mag
# Update magazine system to level requirements
player['magazines'] = level_magazines
player['bullets_per_magazine'] = level_bullets_per_mag
# Redistribute bullets across new magazine system
max_total_bullets = level_magazines * level_bullets_per_mag
new_total_bullets = min(total_current_bullets, max_total_bullets)
# Calculate how to distribute bullets
if new_total_bullets <= 0:
player['current_ammo'] = 0
elif new_total_bullets <= level_bullets_per_mag:
# All bullets fit in current magazine
player['current_ammo'] = new_total_bullets
else:
# Fill current magazine, save rest for other magazines
player['current_ammo'] = level_bullets_per_mag
return {
'old_magazines': current_magazines,
'new_magazines': level_magazines,
'old_total_bullets': total_current_bullets,
'new_total_bullets': new_total_bullets,
'current_ammo': player['current_ammo']
}

View File

@@ -1,10 +1,11 @@
""" """
Shop system for DuckHunt Bot Shop system for DuckHunt Bot
Handles loading items, purchasing, and item effects Handles loading items, purchasing, and item effects including player-vs-player actions
""" """
import json import json
import os import os
import time
import logging import logging
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
@@ -62,15 +63,33 @@ class ShopManager:
return False return False
return player_xp >= item['price'] return player_xp >= item['price']
def purchase_item(self, player: Dict[str, Any], item_id: int) -> Dict[str, Any]: def purchase_item(self, player: Dict[str, Any], item_id: int, target_player: Optional[Dict[str, Any]] = None, store_in_inventory: bool = False) -> Dict[str, Any]:
""" """
Purchase an item and apply its effects to the player Purchase an item and either store in inventory or apply immediately
Returns a result dictionary with success status and details Returns a result dictionary with success status and details
""" """
item = self.get_item(item_id) item = self.get_item(item_id)
if not item: if not item:
return {"success": False, "error": "invalid_id", "message": "Invalid item ID"} return {"success": False, "error": "invalid_id", "message": "Invalid item ID"}
# If storing in inventory and item requires a target, that's invalid
if store_in_inventory and item.get('target_required', False):
return {
"success": False,
"error": "invalid_storage",
"message": f"{item['name']} cannot be stored - it targets other players",
"item_name": item['name']
}
# Check if item requires a target (only when not storing)
if not store_in_inventory and item.get('target_required', False) and not target_player:
return {
"success": False,
"error": "target_required",
"message": f"{item['name']} requires a target player",
"item_name": item['name']
}
player_xp = player.get('xp', 0) player_xp = player.get('xp', 0)
if player_xp < item['price']: if player_xp < item['price']:
return { return {
@@ -85,16 +104,47 @@ class ShopManager:
# Deduct XP # Deduct XP
player['xp'] = player_xp - item['price'] player['xp'] = player_xp - item['price']
# Apply item effect if store_in_inventory:
effect_result = self._apply_item_effect(player, item) # Add to inventory
inventory = player.get('inventory', {})
item_id_str = str(item_id)
current_count = inventory.get(item_id_str, 0)
inventory[item_id_str] = current_count + 1
player['inventory'] = inventory
return { return {
"success": True, "success": True,
"item_name": item['name'], "item_name": item['name'],
"price": item['price'], "price": item['price'],
"remaining_xp": player['xp'], "remaining_xp": player['xp'],
"effect": effect_result "stored_in_inventory": True,
} "inventory_count": inventory[item_id_str]
}
else:
# Apply effect immediately
if item.get('target_required', False) and target_player:
effect_result = self._apply_item_effect(target_player, item)
return {
"success": True,
"item_name": item['name'],
"price": item['price'],
"remaining_xp": player['xp'],
"effect": effect_result,
"target_affected": True
}
else:
# Apply effect to purchaser
effect_result = self._apply_item_effect(player, item)
return {
"success": True,
"item_name": item['name'],
"price": item['price'],
"remaining_xp": player['xp'],
"effect": effect_result,
"target_affected": False
}
def _apply_item_effect(self, player: Dict[str, Any], item: Dict[str, Any]) -> Dict[str, Any]: def _apply_item_effect(self, player: Dict[str, Any], item: Dict[str, Any]) -> Dict[str, Any]:
"""Apply the effect of an item to a player""" """Apply the effect of an item to a player"""
@@ -102,16 +152,28 @@ class ShopManager:
amount = item.get('amount', 0) amount = item.get('amount', 0)
if item_type == 'ammo': if item_type == 'ammo':
# Add ammo up to max capacity # Add bullets to current magazine
current_ammo = player.get('ammo', 0) current_ammo = player.get('current_ammo', 0)
max_ammo = player.get('max_ammo', 6) bullets_per_mag = player.get('bullets_per_magazine', 6)
new_ammo = min(current_ammo + amount, max_ammo) new_ammo = min(current_ammo + amount, bullets_per_mag)
player['ammo'] = new_ammo added_bullets = new_ammo - current_ammo
player['current_ammo'] = new_ammo
return { return {
"type": "ammo", "type": "ammo",
"added": new_ammo - current_ammo, "added": added_bullets,
"new_total": new_ammo, "new_total": new_ammo,
"max": max_ammo "max": bullets_per_mag
}
elif item_type == 'magazine':
# Add magazines to player's inventory
current_magazines = player.get('magazines', 1)
new_magazines = current_magazines + amount
player['magazines'] = new_magazines
return {
"type": "magazine",
"added": amount,
"new_total": new_magazines
} }
elif item_type == 'accuracy': elif item_type == 'accuracy':
@@ -136,10 +198,213 @@ class ShopManager:
"new_total": new_luck "new_total": new_luck
} }
elif item_type == 'jam_resistance':
# Reduce gun jamming chance (lower is better)
current_jam = player.get('jam_chance', 5) # Default 5% jam chance
new_jam = max(current_jam - amount, 0) # Can't go below 0%
player['jam_chance'] = new_jam
return {
"type": "jam_resistance",
"reduced": current_jam - new_jam,
"new_total": new_jam
}
elif item_type == 'max_ammo':
# Increase maximum ammo capacity
current_max = player.get('max_ammo', 6)
new_max = current_max + amount
player['max_ammo'] = new_max
return {
"type": "max_ammo",
"added": amount,
"new_total": new_max
}
elif item_type == 'chargers':
# Add reload chargers
current_chargers = player.get('chargers', 2)
new_chargers = current_chargers + amount
player['chargers'] = new_chargers
return {
"type": "chargers",
"added": amount,
"new_total": new_chargers
}
elif item_type == 'duck_attraction':
# Increase chance of ducks appearing when this player is online
current_attraction = player.get('duck_attraction', 0)
new_attraction = current_attraction + amount
player['duck_attraction'] = new_attraction
return {
"type": "duck_attraction",
"added": amount,
"new_total": new_attraction
}
elif item_type == 'critical_hit':
# Chance for critical hits (double XP)
current_crit = player.get('critical_chance', 0)
new_crit = min(current_crit + amount, 25) # Max 25% crit chance
player['critical_chance'] = new_crit
return {
"type": "critical_hit",
"added": new_crit - current_crit,
"new_total": new_crit
}
elif item_type == 'sabotage_jam':
# Increase target's gun jamming chance temporarily
current_jam = player.get('jam_chance', 5)
new_jam = min(current_jam + amount, 50) # Max 50% jam chance
player['jam_chance'] = new_jam
# Add temporary effect tracking
if 'temporary_effects' not in player:
player['temporary_effects'] = []
effect = {
'type': 'jam_increase',
'amount': amount,
'expires_at': time.time() + item.get('duration', 5) * 60 # duration in minutes
}
player['temporary_effects'].append(effect)
return {
"type": "sabotage_jam",
"added": new_jam - current_jam,
"new_total": new_jam,
"duration": item.get('duration', 5)
}
elif item_type == 'sabotage_accuracy':
# Reduce target's accuracy temporarily
current_acc = player.get('accuracy', 65)
new_acc = max(current_acc + amount, 10) # Min 10% accuracy (amount is negative)
player['accuracy'] = new_acc
# Add temporary effect tracking
if 'temporary_effects' not in player:
player['temporary_effects'] = []
effect = {
'type': 'accuracy_reduction',
'amount': amount,
'expires_at': time.time() + item.get('duration', 3) * 60
}
player['temporary_effects'].append(effect)
return {
"type": "sabotage_accuracy",
"reduced": current_acc - new_acc,
"new_total": new_acc,
"duration": item.get('duration', 3)
}
elif item_type == 'steal_ammo':
# Steal ammo from target player
current_ammo = player.get('ammo', 0)
stolen = min(amount, current_ammo)
player['ammo'] = max(current_ammo - stolen, 0)
return {
"type": "steal_ammo",
"stolen": stolen,
"remaining": player['ammo']
}
else: else:
self.logger.warning(f"Unknown item type: {item_type}") self.logger.warning(f"Unknown item type: {item_type}")
return {"type": "unknown", "message": f"Unknown effect type: {item_type}"} return {"type": "unknown", "message": f"Unknown effect type: {item_type}"}
def use_inventory_item(self, player: Dict[str, Any], item_id: int, target_player: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Use an item from player's inventory
Returns a result dictionary with success status and details
"""
item = self.get_item(item_id)
if not item:
return {"success": False, "error": "invalid_id", "message": "Invalid item ID"}
inventory = player.get('inventory', {})
item_id_str = str(item_id)
if item_id_str not in inventory or inventory[item_id_str] <= 0:
return {
"success": False,
"error": "not_in_inventory",
"message": f"You don't have any {item['name']} in your inventory",
"item_name": item['name']
}
# Check if item requires a target
if item.get('target_required', False) and not target_player:
return {
"success": False,
"error": "target_required",
"message": f"{item['name']} requires a target player",
"item_name": item['name']
}
# Remove item from inventory
inventory[item_id_str] -= 1
if inventory[item_id_str] <= 0:
del inventory[item_id_str]
player['inventory'] = inventory
# Apply effect
if item.get('target_required', False) and target_player:
effect_result = self._apply_item_effect(target_player, item)
return {
"success": True,
"item_name": item['name'],
"effect": effect_result,
"target_affected": True,
"remaining_in_inventory": inventory.get(item_id_str, 0)
}
else:
# Apply effect to user
effect_result = self._apply_item_effect(player, item)
return {
"success": True,
"item_name": item['name'],
"effect": effect_result,
"target_affected": False,
"remaining_in_inventory": inventory.get(item_id_str, 0)
}
def get_inventory_display(self, player: Dict[str, Any]) -> Dict[str, Any]:
"""
Get formatted inventory display for a player
Returns dict with inventory info
"""
inventory = player.get('inventory', {})
if not inventory:
return {
"empty": True,
"message": "Your inventory is empty"
}
items = []
for item_id_str, quantity in inventory.items():
item_id = int(item_id_str)
item = self.get_item(item_id)
if item:
items.append({
"id": item_id,
"name": item['name'],
"quantity": quantity,
"description": item.get('description', 'No description')
})
return {
"empty": False,
"items": items,
"total_items": len(items)
}
def reload_items(self) -> int: def reload_items(self) -> int:
"""Reload items from file and return count""" """Reload items from file and return count"""
old_count = len(self.items) old_count = len(self.items)
@@ -147,3 +412,19 @@ class ShopManager:
new_count = len(self.items) new_count = len(self.items)
self.logger.info(f"Shop reloaded: {old_count} -> {new_count} items") self.logger.info(f"Shop reloaded: {old_count} -> {new_count} items")
return new_count return new_count
def get_shop_display(self, player, message_manager):
"""Get formatted shop display"""
items = []
for item_id, item in self.get_items().items():
item_text = message_manager.get('shop_item_format',
id=item_id,
name=item['name'],
price=item['price'])
items.append(item_text)
shop_text = message_manager.get('shop_display',
items=" | ".join(items),
xp=player.get('xp', 0))
return shop_text