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": {
"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"
}
}

View File

@@ -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 <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_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}",

View File

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

View File

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

View File

@@ -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,13 +63,40 @@ 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 <item_id>
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 <item_id> [target] or !shop <item_id> [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 <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):
"""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
)
# 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}")
# If any task completed, break out
if done:
break
self.logger.info("🔄 Shutdown initiated, cleaning up...")
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}")

View File

@@ -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 = []
@@ -103,3 +106,183 @@ class DuckGame:
self.bot.send_message(channel, message)
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"""
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:
@@ -164,3 +178,43 @@ class LevelManager:
new_count = len(self.levels_data.get('levels', {}))
self.logger.info(f"Levels reloaded: {old_count} -> {new_count} levels")
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
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)
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'],
"effect": effect_result
}
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,10 +198,213 @@ 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)
@@ -147,3 +412,19 @@ class ShopManager:
new_count = len(self.items)
self.logger.info(f"Shop reloaded: {old_count} -> {new_count} items")
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