Files
duckhunt/src/shop.py
2026-01-01 10:45:59 -06:00

710 lines
28 KiB
Python

"""
Shop system for DuckHunt Bot
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
class ShopManager:
"""Manages the DuckHunt shop system"""
def __init__(self, shop_file: str = "shop.json", levels_manager=None):
self.shop_file = shop_file
self.levels = levels_manager
self.items = {}
self.logger = logging.getLogger('DuckHuntBot.Shop')
self.load_items()
def load_items(self):
"""Load shop items from JSON file"""
try:
if os.path.exists(self.shop_file):
with open(self.shop_file, 'r', encoding='utf-8') as f:
shop_data = json.load(f)
# Convert string keys to integers for easier handling
self.items = {int(k): v for k, v in shop_data.get('items', {}).items()}
self.logger.info(f"Loaded {len(self.items)} shop items from {self.shop_file}")
else:
# Fallback items if file doesn't exist
self.items = self._get_default_items()
self.logger.warning(f"{self.shop_file} not found, using default items")
except Exception as e:
self.logger.error(f"Error loading shop items: {e}, using defaults")
self.items = self._get_default_items()
def _get_default_items(self) -> Dict[int, Dict[str, Any]]:
"""Default fallback shop items"""
return {
1: {"name": "Single Bullet", "price": 5, "description": "1 extra bullet", "type": "ammo", "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}
}
def get_items(self) -> Dict[int, Dict[str, Any]]:
"""Get all shop items"""
return self.items.copy()
def get_item(self, item_id: int) -> Optional[Dict[str, Any]]:
"""Get a specific shop item by ID"""
return self.items.get(item_id)
def is_valid_item(self, item_id: int) -> bool:
"""Check if item ID exists"""
return item_id in self.items
def can_afford(self, player_xp: int, item_id: int) -> bool:
"""Check if player can afford an item"""
item = self.get_item(item_id)
if not item:
return False
return player_xp >= item['price']
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 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 {
"success": False,
"error": "insufficient_xp",
"message": f"Need {item['price']} XP, have {player_xp} XP",
"item_name": item['name'],
"price": item['price'],
"current_xp": player_xp
}
# Deduct XP
player['xp'] = player_xp - item['price']
if store_in_inventory:
# Add to inventory with bounds checking
inventory = player.get('inventory', {})
item_id_str = str(item_id)
current_count = inventory.get(item_id_str, 0)
# Load inventory limits from config
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config.json')
max_per_item = 99 # Default limit per item type
max_total_items = 20 # Default total items limit
try:
with open(config_path, 'r') as f:
config = json.load(f)
max_total_items = config.get('gameplay', {}).get('max_inventory_items', 20)
max_per_item = config.get('gameplay', {}).get('max_per_item_type', 99)
except:
pass # Use defaults
# Check individual item limit
if current_count >= max_per_item:
return {
"success": False,
"error": "item_limit_reached",
"message": f"Cannot hold more than {max_per_item} {item['name']}s",
"item_name": item['name']
}
# Check total inventory size limit
total_items = sum(inventory.values())
if total_items >= max_total_items:
return {
"success": False,
"error": "inventory_full",
"message": f"Inventory full! (max {max_total_items} items)",
"item_name": item['name']
}
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"""
item_type = item.get('type', 'unknown')
amount = item.get('amount', 0)
if item_type == '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": added_bullets,
"new_total": new_ammo,
"max": bullets_per_mag
}
elif item_type == 'magazine':
# Add magazines (limit checking is done before this function is called)
current_magazines = player.get('magazines', 1)
if self.levels:
level_info = self.levels.get_player_level_info(player)
max_magazines = level_info.get('magazines', 3)
# Don't exceed maximum magazines for level
magazines_to_add = min(amount, max_magazines - current_magazines)
else:
# Fallback if levels not available
magazines_to_add = amount
new_magazines = current_magazines + magazines_to_add
player['magazines'] = new_magazines
return {
"type": "magazine",
"added": magazines_to_add,
"new_total": new_magazines
}
elif item_type == 'accuracy':
# Increase accuracy up to 100%
current_accuracy = player.get('accuracy', 75)
new_accuracy = min(current_accuracy + amount, 100)
player['accuracy'] = new_accuracy
return {
"type": "accuracy",
"added": new_accuracy - current_accuracy,
"new_total": new_accuracy
}
elif item_type == 'luck':
# Store luck bonus (would be used in duck spawning logic)
current_luck = player.get('luck_bonus', 0)
new_luck = min(max(current_luck + amount, -50), 100) # Bounded between -50 and +100
player['luck_bonus'] = new_luck
return {
"type": "luck",
"added": new_luck - current_luck,
"new_total": new_luck
}
elif item_type == 'jam_resistance':
# Reduce gun jamming chance (lower is better)
current_jam = player.get('jam_chance', 5) # Default 5% jam chance
new_jam = max(current_jam - amount, 0) # Can't go below 0%
player['jam_chance'] = new_jam
return {
"type": "jam_resistance",
"reduced": current_jam - new_jam,
"new_total": new_jam
}
elif item_type == 'max_ammo':
# Increase maximum ammo capacity
current_max = player.get('max_ammo', 6)
new_max = current_max + amount
player['max_ammo'] = new_max
return {
"type": "max_ammo",
"added": amount,
"new_total": new_max
}
elif item_type == 'chargers':
# Add reload chargers
current_chargers = player.get('chargers', 2)
new_chargers = current_chargers + amount
player['chargers'] = new_chargers
return {
"type": "chargers",
"added": amount,
"new_total": new_chargers
}
elif item_type == 'duck_attraction':
# Increase chance of ducks appearing when this player is online
current_attraction = player.get('duck_attraction', 0)
new_attraction = current_attraction + amount
player['duck_attraction'] = new_attraction
return {
"type": "duck_attraction",
"added": amount,
"new_total": new_attraction
}
elif item_type == 'critical_hit':
# Chance for critical hits (double XP)
current_crit = player.get('critical_chance', 0)
new_crit = min(current_crit + amount, 25) # Max 25% crit chance
player['critical_chance'] = new_crit
return {
"type": "critical_hit",
"added": new_crit - current_crit,
"new_total": new_crit
}
elif item_type == 'sabotage_jam':
# Increase target's gun jamming chance temporarily
current_jam = player.get('jam_chance', 5)
new_jam = min(current_jam + amount, 50) # Max 50% jam chance
player['jam_chance'] = new_jam
# Add temporary effect tracking
if 'temporary_effects' not in player:
player['temporary_effects'] = []
effect = {
'type': 'jam_increase',
'amount': amount,
'expires_at': time.time() + item.get('duration', 5) * 60 # duration in minutes
}
player['temporary_effects'].append(effect)
return {
"type": "sabotage_jam",
"added": new_jam - current_jam,
"new_total": new_jam,
"duration": item.get('duration', 5)
}
elif item_type == 'sabotage_accuracy':
# Reduce target's accuracy temporarily
current_acc = player.get('accuracy', 75)
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']
}
elif item_type == 'clean_gun':
# Clean gun to reduce jamming chance (positive amount reduces jam chance)
current_jam = player.get('jam_chance', 5) # Default 5% jam chance
new_jam = min(max(current_jam + amount, 0), 100) # Bounded between 0% and 100%
player['jam_chance'] = new_jam
return {
"type": "clean_gun",
"reduced": current_jam - new_jam,
"new_total": new_jam
}
elif item_type == 'attract_ducks':
# Add bread effect to increase duck spawn rate
if 'temporary_effects' not in player:
player['temporary_effects'] = []
duration = item.get('duration', 600) # 10 minutes default
spawn_multiplier = item.get('spawn_multiplier', 2.0) # 2x spawn rate default
effect = {
'type': 'attract_ducks',
'spawn_multiplier': spawn_multiplier,
'expires_at': time.time() + duration
}
player['temporary_effects'].append(effect)
return {
"type": "attract_ducks",
"spawn_multiplier": spawn_multiplier,
"duration": duration // 60 # return duration in minutes
}
elif item_type == 'clover_luck':
# Temporarily boost hit + befriend success rates
if 'temporary_effects' not in player or not isinstance(player.get('temporary_effects'), list):
player['temporary_effects'] = []
duration = item.get('duration', 600) # seconds
try:
duration = int(duration)
except (ValueError, TypeError):
duration = 600
duration = max(30, min(duration, 86400))
try:
min_hit = float(item.get('min_hit_chance', 0.95))
except (ValueError, TypeError):
min_hit = 0.95
try:
min_bef = float(item.get('min_befriend_chance', 0.95))
except (ValueError, TypeError):
min_bef = 0.95
min_hit = max(0.0, min(min_hit, 1.0))
min_bef = max(0.0, min(min_bef, 1.0))
now = time.time()
expires_at = now + duration
# If an existing clover effect is active, extend it instead of stacking.
for effect in player['temporary_effects']:
if isinstance(effect, dict) and effect.get('type') == 'clover_luck' and effect.get('expires_at', 0) > now:
effect['expires_at'] = max(effect.get('expires_at', now), now) + duration
effect['min_hit_chance'] = max(float(effect.get('min_hit_chance', 0.0) or 0.0), min_hit)
effect['min_befriend_chance'] = max(float(effect.get('min_befriend_chance', 0.0) or 0.0), min_bef)
return {
"type": "clover_luck",
"duration": duration // 60,
"min_hit_chance": min_hit,
"min_befriend_chance": min_bef,
"extended": True
}
effect = {
'type': 'clover_luck',
'min_hit_chance': min_hit,
'min_befriend_chance': min_bef,
'expires_at': expires_at
}
player['temporary_effects'].append(effect)
return {
"type": "clover_luck",
"duration": duration // 60,
"min_hit_chance": min_hit,
"min_befriend_chance": min_bef,
"extended": False
}
elif item_type == 'insurance':
# Add insurance protection against friendly fire
if 'temporary_effects' not in player:
player['temporary_effects'] = []
duration = item.get('duration', 86400) # 24 hours default
protection_type = item.get('protection', 'friendly_fire')
effect = {
'type': 'insurance',
'protection': protection_type,
'expires_at': time.time() + duration,
'name': 'Hunter\'s Insurance'
}
player['temporary_effects'].append(effect)
return {
"type": "insurance",
"protection": protection_type,
"duration": duration // 3600 # return duration in hours
}
elif item_type == 'buy_gun_back':
# Restore confiscated gun with original ammo
was_confiscated = player.get('gun_confiscated', False)
if was_confiscated:
player['gun_confiscated'] = False
# Restore original ammo and magazines from when gun was confiscated
restored_ammo = player.get('confiscated_ammo', 0)
restored_magazines = player.get('confiscated_magazines', 1)
player['current_ammo'] = restored_ammo
player['magazines'] = restored_magazines
# Clean up the stored values
player.pop('confiscated_ammo', None)
player.pop('confiscated_magazines', None)
return {
"type": "buy_gun_back",
"restored": True,
"ammo_restored": restored_ammo
}
else:
return {
"type": "buy_gun_back",
"restored": False,
"message": "Your gun is not confiscated"
}
elif item_type == 'dry_clothes':
# Remove wet clothes effect
# Remove any wet clothes effects
if 'temporary_effects' in player:
original_count = len(player['temporary_effects'])
player['temporary_effects'] = [
effect for effect in player['temporary_effects']
if effect.get('type') != 'wet_clothes'
]
new_count = len(player['temporary_effects'])
was_wet = original_count > new_count
else:
was_wet = False
return {
"type": "dry_clothes",
"was_wet": was_wet,
"message": "You changed into dry clothes!" if was_wet else "You weren't wet!"
}
else:
self.logger.warning(f"Unknown item type: {item_type}")
return {"type": "unknown", "message": f"Unknown effect type: {item_type}"}
def _apply_splash_water_effect(self, target_player: Dict[str, Any], item: Dict[str, Any]) -> Dict[str, Any]:
"""Apply splash water effect to target player"""
# Load config directly without import issues
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config.json')
try:
with open(config_path, 'r') as f:
config = json.load(f)
wet_duration = config.get('gameplay', {}).get('wet_clothes_duration', 300) # 5 minutes default
except:
wet_duration = 300 # Default 5 minutes
if 'temporary_effects' not in target_player:
target_player['temporary_effects'] = []
# Add wet clothes effect
wet_effect = {
'type': 'wet_clothes',
'expires_at': time.time() + wet_duration
}
target_player['temporary_effects'].append(wet_effect)
return {
"type": "splash_water",
"target_soaked": True,
"duration": wet_duration // 60 # return duration in minutes
}
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']
}
# Special restrictions: Some items require targets, bread cannot have targets
if item['type'] == 'attract_ducks' and target_player:
return {
"success": False,
"error": "bread_no_target",
"message": "Bread affects everyone in the channel - you cannot target a specific player",
"item_name": item['name']
}
# Items that must have targets when used (but can be stored in inventory)
target_required_items = ['sabotage_jam', 'splash_water']
if item['type'] in target_required_items and not target_player:
return {
"success": False,
"error": "target_required",
"message": f"{item['name']} requires a target player to use",
"item_name": item['name']
}
# Special checks for ammo/magazine limits
if item['type'] == 'magazine' and self.levels:
affected_player = target_player if target_player else player
current_magazines = affected_player.get('magazines', 1)
level_info = self.levels.get_player_level_info(affected_player)
max_magazines = level_info.get('magazines', 3)
if current_magazines >= max_magazines:
return {
"success": False,
"error": "max_magazines_reached",
"message": f"Already at maximum magazines ({max_magazines}) for current level!",
"item_name": item['name']
}
elif item['type'] == 'ammo':
affected_player = target_player if target_player else player
current_ammo = affected_player.get('current_ammo', 0)
bullets_per_mag = affected_player.get('bullets_per_magazine', 6)
if current_ammo >= bullets_per_mag:
return {
"success": False,
"error": "magazine_full",
"message": f"Current magazine is already full ({bullets_per_mag}/{bullets_per_mag})!",
"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
# Determine who gets the effect
if target_player:
# Special handling for harmful effects
if item['type'] == 'splash_water':
effect_result = self._apply_splash_water_effect(target_player, item)
target_affected = True
elif item['type'] == 'sabotage_jam':
effect_result = self._apply_item_effect(target_player, item)
target_affected = True
else:
# Beneficial items - give to target (gifting)
effect_result = self._apply_item_effect(target_player, item)
target_affected = True
# Mark as gift in the result
effect_result['is_gift'] = True
return {
"success": True,
"item_name": item['name'],
"effect": effect_result,
"target_affected": target_affected,
"remaining_in_inventory": inventory.get(item_id_str, 0)
}
else:
# Apply effect to user (no target specified)
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
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