From 0c8b4f9543339261181aaad2156dba1b28d9ae43 Mon Sep 17 00:00:00 2001 From: ComputerTech312 Date: Tue, 23 Sep 2025 20:13:01 +0100 Subject: [PATCH] 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) --- levels.json | 52 +++-- messages.json | 18 +- shop.json | 17 +- src/db.py | 37 +++- src/duckhuntbot.py | 491 ++++++++++++++++++++++----------------------- src/game.py | 201 ++++++++++++++++++- src/levels.py | 92 +++++++-- src/shop.py | 323 +++++++++++++++++++++++++++-- 8 files changed, 892 insertions(+), 339 deletions(-) diff --git a/levels.json b/levels.json index 39a06ad..f4b46a1 100644 --- a/levels.json +++ b/levels.json @@ -1,79 +1,95 @@ { "level_calculation": { - "method": "total_ducks", - "description": "Level based on total ducks interacted with (shot + befriended)" + "method": "xp", + "description": "Level based on XP earned from hunting and befriending ducks" }, "levels": { "1": { "name": "Duck Novice", - "min_ducks": 0, - "max_ducks": 9, + "min_xp": 0, + "max_xp": 49, "befriend_success_rate": 85, "accuracy_modifier": 5, "duck_spawn_speed_modifier": 1.0, + "magazines": 3, + "bullets_per_magazine": 6, "description": "Just starting out, ducks are trusting and easier to hit" }, "2": { "name": "Pond Visitor", - "min_ducks": 10, - "max_ducks": 24, + "min_xp": 50, + "max_xp": 149, "befriend_success_rate": 80, "accuracy_modifier": 0, "duck_spawn_speed_modifier": 1.0, + "magazines": 3, + "bullets_per_magazine": 6, "description": "Ducks are getting wary of you" }, "3": { "name": "Duck Hunter", - "min_ducks": 25, - "max_ducks": 49, + "min_xp": 150, + "max_xp": 299, "befriend_success_rate": 75, "accuracy_modifier": -5, "duck_spawn_speed_modifier": 0.9, + "magazines": 2, + "bullets_per_magazine": 6, "description": "Your reputation precedes you, ducks are more cautious" }, "4": { "name": "Wetland Stalker", - "min_ducks": 50, - "max_ducks": 99, + "min_xp": 300, + "max_xp": 599, "befriend_success_rate": 70, "accuracy_modifier": -10, "duck_spawn_speed_modifier": 0.8, + "magazines": 2, + "bullets_per_magazine": 6, "description": "Ducks flee at your approach, spawns are less frequent" }, "5": { "name": "Apex Predator", - "min_ducks": 100, - "max_ducks": 199, + "min_xp": 600, + "max_xp": 999, "befriend_success_rate": 65, "accuracy_modifier": -15, "duck_spawn_speed_modifier": 0.7, + "magazines": 2, + "bullets_per_magazine": 6, "description": "You're feared throughout the pond, ducks are very elusive" }, "6": { "name": "Duck Whisperer", - "min_ducks": 200, - "max_ducks": 399, + "min_xp": 1000, + "max_xp": 1999, "befriend_success_rate": 60, "accuracy_modifier": -20, "duck_spawn_speed_modifier": 0.6, + "magazines": 1, + "bullets_per_magazine": 6, "description": "Only the bravest ducks dare show themselves" }, "7": { "name": "Legendary Hunter", - "min_ducks": 400, - "max_ducks": 999, + "min_xp": 2000, + "max_xp": 4999, "befriend_success_rate": 55, "accuracy_modifier": -25, "duck_spawn_speed_modifier": 0.5, + "magazines": 1, + "bullets_per_magazine": 6, "description": "Duck folklore speaks of your prowess, they're extremely rare" }, "8": { "name": "Duck Deity", - "min_ducks": 1000, - "max_ducks": 999999, + "min_xp": 5000, + "max_xp": 999999, "befriend_success_rate": 50, "accuracy_modifier": -30, "duck_spawn_speed_modifier": 0.4, + "magazines": 1, + "bullets_per_magazine": 6, "description": "You've transcended mortal hunting, ducks are mythically scarce" } } diff --git a/messages.json b/messages.json index ae10cfa..c0fda3e 100644 --- a/messages.json +++ b/messages.json @@ -1,30 +1,32 @@ { "duck_spawn": [ "・゜゜・。。・゜゜\\_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. ·°'`'°-.,¸¸.·°'`", - "bang_hit": "{nick} > {red}*BANG*{reset} You shot the {cyan}duck{reset}! [{green}+{xp_gained} xp{reset}] [Total ducks: {blue}{ducks_shot}{reset}]", + "duck_flies_away": "The {bold}duck{reset} flies away. ·°'`'°-.,¸¸.·°'`", + "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_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_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_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...", - "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_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}.", "shop_display": "DuckHunt Shop: {items} | You have {green}{xp} XP{reset}", "shop_item_format": "({blue}{id}{reset}) {cyan}{name}{reset} - {green}{price} XP{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_admin_commands": "{red}Admin:{reset} {blue}!rearm {reset} | {blue}!disarm {reset} | {blue}!ignore {reset} | {blue}!unignore {reset} | {blue}!ducklaunch{reset} | {blue}!reloadshop{reset}", + "help_admin_commands": "{red}Admin:{reset} {blue}!rearm {reset} | {blue}!disarm {reset} | {blue}!ignore {reset} | {blue}!unignore {reset} | {blue}!ducklaunch{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_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_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}", diff --git a/shop.json b/shop.json index c462d74..251501c 100644 --- a/shop.json +++ b/shop.json @@ -8,18 +8,11 @@ "amount": 1 }, "2": { - "name": "Accuracy Boost", - "price": 20, - "description": "+10% accuracy", - "type": "accuracy", - "amount": 10 - }, - "3": { - "name": "Lucky Charm", - "price": 30, - "description": "+5% duck spawn chance", - "type": "luck", - "amount": 5 + "name": "Magazine", + "price": 15, + "description": "1 extra magazine", + "type": "magazine", + "amount": 1 } } } \ No newline at end of file diff --git a/src/db.py b/src/db.py index 50ba69b..821e600 100644 --- a/src/db.py +++ b/src/db.py @@ -64,10 +64,34 @@ class DuckDB: if nick_lower not in self.players: self.players[nick_lower] = self.create_player(nick) else: - # Ensure existing players have new fields + # Ensure existing players have new fields and migrate from old system player = self.players[nick_lower] if 'ducks_befriended' not in player: 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] @@ -78,10 +102,11 @@ class DuckDB: 'xp': 0, 'ducks_shot': 0, 'ducks_befriended': 0, - 'ammo': 6, - 'max_ammo': 6, - 'chargers': 2, - 'max_chargers': 2, + 'current_ammo': 6, # Bullets in current magazine + 'magazines': 3, # Total magazines (including current) + 'bullets_per_magazine': 6, # Bullets per magazine 'accuracy': 65, - 'gun_confiscated': False + 'gun_confiscated': False, + 'inventory': {}, # {item_id: quantity} + 'temporary_effects': [] # List of temporary effects } \ No newline at end of file diff --git a/src/duckhuntbot.py b/src/duckhuntbot.py index e38f463..c86ca16 100644 --- a/src/duckhuntbot.py +++ b/src/duckhuntbot.py @@ -1,7 +1,6 @@ import asyncio import ssl import json -import random import logging import sys import os @@ -15,6 +14,7 @@ from .db import DuckDB from .game import DuckGame from .sasl import SASLHandler from .shop import ShopManager +from .levels import LevelManager class DuckHuntBot: @@ -41,6 +41,10 @@ class DuckHuntBot: shop_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'shop.json') 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): """Get configuration value using dot notation""" keys = path.split('.') @@ -59,12 +63,39 @@ class DuckHuntBot: nick = user.split('!')[0].lower() 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): - """Setup signal handlers for graceful shutdown""" + """Setup signal handlers for immediate shutdown""" def signal_handler(signum, frame): 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 + + # 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.SIGTERM, signal_handler) @@ -147,6 +178,10 @@ class DuckHuntBot: await self.handle_reload(nick, channel, player) elif cmd == "shop": 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": await self.handle_duckhelp(nick, channel, player) elif cmd == "rearm" and self.is_admin(user): @@ -159,157 +194,66 @@ class DuckHuntBot: await self.handle_unignore(nick, channel, args) elif cmd == "ducklaunch" and self.is_admin(user): 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): """Handle !bang command""" - # Check if gun is confiscated - if player.get('gun_confiscated', False): - message = self.messages.get('bang_not_armed', nick=nick) - 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() + result = self.game.shoot_duck(nick, channel, player) + message = self.messages.get(result['message_key'], **result['message_args']) + self.send_message(channel, message) async def handle_bef(self, nick, channel, player): """Handle !bef (befriend) command""" - # Check for duck - if channel not in self.game.ducks or not self.game.ducks[channel]: - message = self.messages.get('bef_no_duck', nick=nick) - 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() + result = self.game.befriend_duck(nick, channel, player) + message = self.messages.get(result['message_key'], **result['message_args']) + self.send_message(channel, message) async def handle_reload(self, nick, channel, player): """Handle !reload command""" - if player.get('gun_confiscated', False): - message = self.messages.get('reload_not_armed', nick=nick) - 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']) + result = self.game.reload_gun(nick, channel, player) + message = self.messages.get(result['message_key'], **result['message_args']) self.send_message(channel, message) - self.db.save_database() async def handle_shop(self, nick, channel, player, args=None): """Handle !shop command""" - # Handle buying: !shop buy - if args and len(args) >= 2 and args[0].lower() == "buy": - try: - item_id = int(args[1]) - await self.handle_shop_buy(nick, channel, player, item_id) - return - except (ValueError, IndexError): - message = self.messages.get('shop_buy_usage', nick=nick) + # Handle buying: !shop buy [target] or !shop [target] + if args and len(args) >= 1: + # Check for "buy" subcommand or direct item ID + start_idx = 0 + if args[0].lower() == "buy": + start_idx = 1 + + 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) 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 - 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"]: # Handle different error types @@ -321,6 +265,10 @@ class DuckHuntBot: item_name=result["item_name"], price=result["price"], 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: message = f"{nick} > Error: {result['message']}" @@ -328,11 +276,17 @@ class DuckHuntBot: return # Purchase successful - message = self.messages.get('shop_buy_success', - nick=nick, - item_name=result["item_name"], - price=result["price"], - remaining_xp=result["remaining_xp"]) + if result.get("stored_in_inventory"): + message = f"{nick} > Successfully purchased {result['item_name']} for {result['price']} XP! Stored in inventory (x{result['inventory_count']}). Remaining XP: {result['remaining_xp']}" + elif result.get("target_affected"): + message = f"{nick} > Used {result['item_name']} on {target_nick}! 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.db.save_database() @@ -351,71 +305,130 @@ class DuckHuntBot: for line in help_lines: 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 [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): """Handle !rearm command (admin only)""" if args: target = args[0].lower() player = self.db.get_player(target) 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) self.send_message(channel, message) else: - # Rearm everyone - for player_data in self.db.players.values(): - player_data['gun_confiscated'] = False - player_data['ammo'] = player_data.get('max_ammo', 6) - player_data['chargers'] = 2 - message = self.messages.get('admin_rearm_all', admin=nick) + # Rearm the admin themselves + player = self.db.get_player(nick) + player['gun_confiscated'] = False + + # Update magazines based on admin's level + 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.db.save_database() async def handle_disarm(self, nick, channel, args): """Handle !disarm command (admin only)""" - if not args: - message = self.messages.get('usage_disarm') - self.send_message(channel, message) - return + def disarm_player(player): + player['gun_confiscated'] = True - target = args[0].lower() - player = self.db.get_player(target) - player['gun_confiscated'] = True - - message = self.messages.get('admin_disarm', target=target, admin=nick) - self.send_message(channel, message) - self.db.save_database() + self._handle_single_target_admin_command( + args, 'usage_disarm', disarm_player, 'admin_disarm', nick, channel + ) async def handle_ignore(self, nick, channel, args): """Handle !ignore command (admin only)""" - if not args: - message = self.messages.get('usage_ignore') - self.send_message(channel, message) - return + def ignore_player(player): + player['ignored'] = True - target = args[0].lower() - player = self.db.get_player(target) - player['ignored'] = True - - message = self.messages.get('admin_ignore', target=target, admin=nick) - self.send_message(channel, message) - self.db.save_database() + self._handle_single_target_admin_command( + args, 'usage_ignore', ignore_player, 'admin_ignore', nick, channel + ) async def handle_unignore(self, nick, channel, args): """Handle !unignore command (admin only)""" - if not args: - message = self.messages.get('usage_unignore') - self.send_message(channel, message) - return + def unignore_player(player): + player['ignored'] = False - target = args[0].lower() - player = self.db.get_player(target) - player['ignored'] = False - - message = self.messages.get('admin_unignore', target=target, admin=nick) - self.send_message(channel, message) - self.db.save_database() + self._handle_single_target_admin_command( + args, 'usage_unignore', unignore_player, 'admin_unignore', nick, channel + ) async def handle_ducklaunch(self, nick, channel, args): """Handle !ducklaunch command (admin only)""" @@ -428,27 +441,23 @@ class DuckHuntBot: if channel not in self.game.ducks: self.game.ducks[channel] = [] 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') - self.send_message(channel, admin_message) + # Only send the duck spawn message, no admin notification 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): - """Main message processing loop""" + """Main message processing loop with responsive shutdown""" try: 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: break @@ -470,7 +479,7 @@ class DuckHuntBot: self.logger.info("Message loop ended") async def run(self): - """Main bot loop with improved shutdown handling""" + """Main bot loop with fast shutdown handling""" self.setup_signal_handlers() game_task = None @@ -486,79 +495,69 @@ class DuckHuntBot: self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.") - # Wait for shutdown signal or task completion - done, pending = await asyncio.wait( - [game_task, message_task], - return_when=asyncio.FIRST_COMPLETED - ) + # Wait for shutdown signal or task completion with frequent checks + while not self.shutdown_requested: + done, pending = await asyncio.wait( + [game_task, message_task], + timeout=0.1, # Check every 100ms for shutdown + return_when=asyncio.FIRST_COMPLETED + ) + + # If any task completed, break out + if done: + break + + self.logger.info("🔄 Shutdown initiated, cleaning up...") - # Cancel remaining tasks - for task in pending: - if not task.done(): - task.cancel() - try: - 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: self.logger.info("🛑 Main loop cancelled") except Exception as e: self.logger.error(f"❌ Bot error: {e}") 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 - for task in [game_task, message_task]: - if task and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass + # Wait briefly for tasks to cancel + if tasks_to_cancel: + try: + await asyncio.wait_for( + asyncio.gather(*tasks_to_cancel, return_exceptions=True), + timeout=1.0 + ) + except asyncio.TimeoutError: + self.logger.warning("⚠️ Task cancellation timed out") - # Final database save + # Quick database save try: self.db.save_database() self.logger.info("💾 Database saved") except Exception as e: self.logger.error(f"❌ Error saving database: {e}") - # Close IRC connection + # Fast connection close await self._close_connection() 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): - """Close IRC connection safely""" + """Close IRC connection quickly""" if self.writer: try: 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() - 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") except asyncio.TimeoutError: - self.logger.warning("⚠️ Connection close timed out") + self.logger.warning("⚠️ Connection close timed out - forcing close") except Exception as e: self.logger.error(f"❌ Error closing connection: {e}") \ No newline at end of file diff --git a/src/game.py b/src/game.py index ae4942b..b1592de 100644 --- a/src/game.py +++ b/src/game.py @@ -1,6 +1,6 @@ """ -Simplified Game mechanics for DuckHunt Bot -Basic duck spawning and timeout only +Game mechanics for DuckHunt Bot +Handles duck spawning, shooting, befriending, and other game actions """ import asyncio @@ -10,7 +10,7 @@ import logging class DuckGame: - """Simplified game mechanics - just duck spawning""" + """Game mechanics for DuckHunt - shooting, befriending, reloading""" def __init__(self, bot, db): self.bot = bot @@ -31,15 +31,17 @@ class DuckGame: self.logger.info("Game loops cancelled") async def duck_spawn_loop(self): - """Simple duck spawning loop""" + """Duck spawning loop with responsive shutdown""" try: 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 max_wait = self.bot.get_config('duck_spawn_max', 900) # 15 minutes 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 channels = list(self.bot.channels_joined) @@ -51,10 +53,11 @@ class DuckGame: self.logger.info("Duck spawning loop cancelled") async def duck_timeout_loop(self): - """Simple duck timeout loop""" + """Duck timeout loop with responsive shutdown""" try: 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() channels_to_clear = [] @@ -102,4 +105,184 @@ class DuckGame: message = self.bot.messages.get('duck_spawn') self.bot.send_message(channel, message) - self.logger.info(f"Duck spawned in {channel}") \ No newline at end of file + 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) + } + } \ No newline at end of file diff --git a/src/levels.py b/src/levels.py index b95b2c9..d1c1432 100644 --- a/src/levels.py +++ b/src/levels.py @@ -38,14 +38,14 @@ class LevelManager: """Default fallback level system""" return { "level_calculation": { - "method": "total_ducks", - "description": "Level based on total ducks interacted with" + "method": "xp", + "description": "Level based on XP earned" }, "levels": { "1": { "name": "Duck Novice", - "min_ducks": 0, - "max_ducks": 9, + "min_xp": 0, + "max_xp": 49, "befriend_success_rate": 85, "accuracy_modifier": 5, "duck_spawn_speed_modifier": 1.0, @@ -53,8 +53,8 @@ class LevelManager: }, "2": { "name": "Duck Hunter", - "min_ducks": 10, - "max_ducks": 99, + "min_xp": 50, + "max_xp": 299, "befriend_success_rate": 75, "accuracy_modifier": 0, "duck_spawn_speed_modifier": 0.8, @@ -65,20 +65,24 @@ class LevelManager: def calculate_player_level(self, player: Dict[str, Any]) -> int: """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) - elif method == 'xp': - total_ducks = player.get('xp', 0) // 10 # 10 XP per "duck equivalent" + player_xp = total_ducks # Use duck count as if it were XP else: - total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0) + player_xp = player.get('xp', 0) # Find the appropriate level levels = self.levels_data.get('levels', {}) for level_num in sorted(levels.keys(), key=int, reverse=True): 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 1 # Default to level 1 @@ -102,15 +106,23 @@ class LevelManager: "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 next_level_data = self.get_level_data(level + 1) 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}") else: - ducks_needed = 0 + needed_for_next = 0 next_level_name = "Max Level" return { @@ -120,9 +132,11 @@ class LevelManager: "befriend_success_rate": level_data.get('befriend_success_rate', 75), "accuracy_modifier": level_data.get('accuracy_modifier', 0), "duck_spawn_speed_modifier": level_data.get('duck_spawn_speed_modifier', 1.0), - "total_ducks": total_ducks, - "ducks_needed_for_next": max(0, ducks_needed), - "next_level_name": next_level_name + "current_xp": player.get('xp', 0), + "total_ducks": player.get('ducks_shot', 0) + player.get('ducks_befriended', 0), + "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: @@ -163,4 +177,44 @@ class LevelManager: self.load_levels() new_count = len(self.levels_data.get('levels', {})) self.logger.info(f"Levels reloaded: {old_count} -> {new_count} levels") - return new_count \ No newline at end of file + 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'] + } \ No newline at end of file diff --git a/src/shop.py b/src/shop.py index fd014bf..3169631 100644 --- a/src/shop.py +++ b/src/shop.py @@ -1,10 +1,11 @@ """ 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 os +import time import logging from typing import Dict, Any, Optional @@ -62,15 +63,33 @@ class ShopManager: return False 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 """ item = self.get_item(item_id) if not item: 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) if player_xp < item['price']: return { @@ -85,16 +104,47 @@ class ShopManager: # Deduct XP player['xp'] = player_xp - item['price'] - # Apply item effect - 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 - } + if store_in_inventory: + # 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 { + "success": True, + "item_name": item['name'], + "price": item['price'], + "remaining_xp": player['xp'], + "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]: """Apply the effect of an item to a player""" @@ -102,16 +152,28 @@ class ShopManager: amount = item.get('amount', 0) if item_type == 'ammo': - # Add ammo up to max capacity - current_ammo = player.get('ammo', 0) - max_ammo = player.get('max_ammo', 6) - new_ammo = min(current_ammo + amount, max_ammo) - player['ammo'] = new_ammo + # Add bullets to current magazine + current_ammo = player.get('current_ammo', 0) + bullets_per_mag = player.get('bullets_per_magazine', 6) + new_ammo = min(current_ammo + amount, bullets_per_mag) + added_bullets = new_ammo - current_ammo + player['current_ammo'] = new_ammo return { "type": "ammo", - "added": new_ammo - current_ammo, + "added": added_bullets, "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': @@ -136,14 +198,233 @@ class ShopManager: "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: self.logger.warning(f"Unknown item 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: """Reload items from file and return count""" old_count = len(self.items) self.load_items() new_count = len(self.items) self.logger.info(f"Shop reloaded: {old_count} -> {new_count} items") - return new_count \ No newline at end of file + 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 \ No newline at end of file