""" Inventory Service - Manages character inventory, equipment, and consumable usage. This service provides an orchestration layer on top of the Character model's inventory methods, adding: - Input validation and error handling - Equipment slot validation (weapon vs armor slots) - Level and class requirement checks - Consumable effect application - Integration with CharacterService for persistence Usage: from app.services.inventory_service import get_inventory_service inventory_service = get_inventory_service() inventory_service.equip_item(character, item, "weapon", user_id) inventory_service.use_consumable(character, "health_potion_small", user_id) """ from typing import List, Optional, Dict, Any, Tuple from dataclasses import dataclass from app.models.character import Character from app.models.items import Item from app.models.effects import Effect from app.models.enums import ItemType, EffectType from app.services.character_service import get_character_service, CharacterService from app.utils.logging import get_logger logger = get_logger(__file__) # ============================================================================= # Custom Exceptions # ============================================================================= class InventoryError(Exception): """Base exception for inventory operations.""" pass class ItemNotFoundError(InventoryError): """Raised when an item is not found in the character's inventory.""" pass class CannotEquipError(InventoryError): """Raised when an item cannot be equipped (wrong slot, level requirement, etc.).""" pass class InvalidSlotError(InventoryError): """Raised when an invalid equipment slot is specified.""" pass class CannotUseItemError(InventoryError): """Raised when an item cannot be used (not consumable, etc.).""" pass class InventoryFullError(InventoryError): """Raised when inventory capacity is exceeded.""" pass # ============================================================================= # Equipment Slot Configuration # ============================================================================= # Valid equipment slots in the game VALID_SLOTS = { "weapon", # Primary weapon "off_hand", # Shield or secondary weapon "helmet", # Head armor "chest", # Chest armor "gloves", # Hand armor "boots", # Foot armor "accessory_1", # Ring, amulet, etc. "accessory_2", # Secondary accessory } # Map item types to allowed slots ITEM_TYPE_SLOTS = { ItemType.WEAPON: {"weapon", "off_hand"}, ItemType.ARMOR: {"helmet", "chest", "gloves", "boots"}, } # Maximum inventory size (0 = unlimited) MAX_INVENTORY_SIZE = 100 # ============================================================================= # Consumable Effect Result # ============================================================================= @dataclass class ConsumableResult: """Result of using a consumable item.""" item_name: str effects_applied: List[Dict[str, Any]] hp_restored: int = 0 mp_restored: int = 0 message: str = "" def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary for API response.""" return { "item_name": self.item_name, "effects_applied": self.effects_applied, "hp_restored": self.hp_restored, "mp_restored": self.mp_restored, "message": self.message, } # ============================================================================= # Inventory Service # ============================================================================= class InventoryService: """ Service for managing character inventory and equipment. This service wraps the Character model's inventory methods with additional validation, error handling, and persistence integration. All methods that modify state will persist changes via CharacterService. """ def __init__(self, character_service: Optional[CharacterService] = None): """ Initialize inventory service. Args: character_service: Optional CharacterService instance (uses global if not provided) """ self._character_service = character_service logger.info("InventoryService initialized") @property def character_service(self) -> CharacterService: """Get CharacterService instance (lazy-loaded).""" if self._character_service is None: self._character_service = get_character_service() return self._character_service # ========================================================================= # Read Operations # ========================================================================= def get_inventory(self, character: Character) -> List[Item]: """ Get all items in character's inventory. Args: character: Character instance Returns: List of Item objects in inventory """ return list(character.inventory) def get_equipped_items(self, character: Character) -> Dict[str, Item]: """ Get all equipped items. Args: character: Character instance Returns: Dictionary mapping slot names to equipped Item objects """ return dict(character.equipped) def get_item_by_id(self, character: Character, item_id: str) -> Optional[Item]: """ Find an item in inventory by ID. Args: character: Character instance item_id: Item ID to find Returns: Item if found, None otherwise """ for item in character.inventory: if item.item_id == item_id: return item return None def get_equipped_item(self, character: Character, slot: str) -> Optional[Item]: """ Get the item equipped in a specific slot. Args: character: Character instance slot: Equipment slot name Returns: Item if slot is occupied, None otherwise """ return character.equipped.get(slot) def get_inventory_count(self, character: Character) -> int: """ Get the number of items in inventory. Args: character: Character instance Returns: Number of items in inventory """ return len(character.inventory) # ========================================================================= # Add/Remove Operations # ========================================================================= def add_item( self, character: Character, item: Item, user_id: str, save: bool = True ) -> None: """ Add an item to character's inventory. Args: character: Character instance item: Item to add user_id: User ID for persistence authorization save: Whether to persist changes (default True) Raises: InventoryFullError: If inventory is at maximum capacity """ # Check inventory capacity if MAX_INVENTORY_SIZE > 0 and len(character.inventory) >= MAX_INVENTORY_SIZE: raise InventoryFullError( f"Inventory is full ({MAX_INVENTORY_SIZE} items max)" ) # Add to inventory character.add_item(item) logger.info("Item added to inventory", character_id=character.character_id, item_id=item.item_id, item_name=item.get_display_name()) # Persist changes if save: self.character_service.update_character(character, user_id) def remove_item( self, character: Character, item_id: str, user_id: str, save: bool = True ) -> Item: """ Remove an item from character's inventory. Args: character: Character instance item_id: ID of item to remove user_id: User ID for persistence authorization save: Whether to persist changes (default True) Returns: The removed Item Raises: ItemNotFoundError: If item is not in inventory """ # Find item first (for better error message) item = self.get_item_by_id(character, item_id) if item is None: raise ItemNotFoundError(f"Item not found in inventory: {item_id}") # Remove from inventory removed_item = character.remove_item(item_id) logger.info("Item removed from inventory", character_id=character.character_id, item_id=item_id, item_name=item.get_display_name()) # Persist changes if save: self.character_service.update_character(character, user_id) return removed_item def drop_item( self, character: Character, item_id: str, user_id: str ) -> Item: """ Drop an item (remove permanently with no return). This is an alias for remove_item, but semantically indicates the item is being discarded rather than transferred. Args: character: Character instance item_id: ID of item to drop user_id: User ID for persistence authorization Returns: The dropped Item (for logging/notification purposes) Raises: ItemNotFoundError: If item is not in inventory """ return self.remove_item(character, item_id, user_id, save=True) # ========================================================================= # Equipment Operations # ========================================================================= def equip_item( self, character: Character, item_id: str, slot: str, user_id: str ) -> Optional[Item]: """ Equip an item from inventory to a specific slot. Args: character: Character instance item_id: ID of item to equip (must be in inventory) slot: Equipment slot to use user_id: User ID for persistence authorization Returns: Previously equipped item in that slot (or None) Raises: ItemNotFoundError: If item is not in inventory InvalidSlotError: If slot name is invalid CannotEquipError: If item cannot be equipped (wrong type, level, etc.) """ # Validate slot if slot not in VALID_SLOTS: raise InvalidSlotError( f"Invalid equipment slot: '{slot}'. " f"Valid slots: {', '.join(sorted(VALID_SLOTS))}" ) # Find item in inventory item = self.get_item_by_id(character, item_id) if item is None: raise ItemNotFoundError(f"Item not found in inventory: {item_id}") # Validate item can be equipped self._validate_equip(character, item, slot) # Perform equip (Character.equip_item handles inventory management) previous_item = character.equip_item(item, slot) logger.info("Item equipped", character_id=character.character_id, item_id=item_id, slot=slot, previous_item=previous_item.item_id if previous_item else None) # Persist changes self.character_service.update_character(character, user_id) return previous_item def unequip_item( self, character: Character, slot: str, user_id: str ) -> Optional[Item]: """ Unequip an item from a specific slot (returns to inventory). Args: character: Character instance slot: Equipment slot to unequip from user_id: User ID for persistence authorization Returns: The unequipped Item (or None if slot was empty) Raises: InvalidSlotError: If slot name is invalid InventoryFullError: If inventory is full and cannot receive the item """ # Validate slot if slot not in VALID_SLOTS: raise InvalidSlotError( f"Invalid equipment slot: '{slot}'. " f"Valid slots: {', '.join(sorted(VALID_SLOTS))}" ) # Check if slot has an item equipped_item = character.equipped.get(slot) if equipped_item is None: logger.debug("Unequip from empty slot", character_id=character.character_id, slot=slot) return None # Check inventory capacity (item will return to inventory) if MAX_INVENTORY_SIZE > 0 and len(character.inventory) >= MAX_INVENTORY_SIZE: raise InventoryFullError( "Cannot unequip: inventory is full" ) # Perform unequip (Character.unequip_item handles inventory management) unequipped_item = character.unequip_item(slot) logger.info("Item unequipped", character_id=character.character_id, item_id=unequipped_item.item_id if unequipped_item else None, slot=slot) # Persist changes self.character_service.update_character(character, user_id) return unequipped_item def swap_equipment( self, character: Character, item_id: str, slot: str, user_id: str ) -> Optional[Item]: """ Swap equipment: equip item and return the previous item. This is semantically the same as equip_item but makes the swap intention explicit. Args: character: Character instance item_id: ID of item to equip slot: Equipment slot to use user_id: User ID for persistence authorization Returns: Previously equipped item (or None) """ return self.equip_item(character, item_id, slot, user_id) def _validate_equip(self, character: Character, item: Item, slot: str) -> None: """ Validate that an item can be equipped to a slot. Args: character: Character instance item: Item to validate slot: Target equipment slot Raises: CannotEquipError: If item cannot be equipped """ # Check item type is equippable if item.item_type not in ITEM_TYPE_SLOTS: raise CannotEquipError( f"Cannot equip {item.item_type.value} items. " f"Only weapons and armor can be equipped." ) # Check slot matches item type allowed_slots = ITEM_TYPE_SLOTS[item.item_type] if slot not in allowed_slots: raise CannotEquipError( f"Cannot equip {item.item_type.value} to '{slot}' slot. " f"Allowed slots: {', '.join(sorted(allowed_slots))}" ) # Check level requirement if not item.can_equip(character.level, character.class_id): if character.level < item.required_level: raise CannotEquipError( f"Cannot equip '{item.get_display_name()}': " f"requires level {item.required_level} (you are level {character.level})" ) if item.required_class and item.required_class != character.class_id: raise CannotEquipError( f"Cannot equip '{item.get_display_name()}': " f"requires class '{item.required_class}'" ) # ========================================================================= # Consumable Operations # ========================================================================= def use_consumable( self, character: Character, item_id: str, user_id: str, current_hp: Optional[int] = None, max_hp: Optional[int] = None, current_mp: Optional[int] = None, max_mp: Optional[int] = None ) -> ConsumableResult: """ Use a consumable item and apply its effects. For HP/MP restoration effects, provide current/max values to calculate actual restoration (clamped to max). If not provided, uses character's computed max_hp from stats. Note: Outside of combat, characters are always at full HP. During combat, HP tracking is handled by the combat system and current_hp should be passed. Args: character: Character instance item_id: ID of consumable to use user_id: User ID for persistence authorization current_hp: Current HP (for healing calculations) max_hp: Maximum HP (for healing cap) current_mp: Current MP (for mana restore calculations) max_mp: Maximum MP (for mana restore cap) Returns: ConsumableResult with details of effects applied Raises: ItemNotFoundError: If item is not in inventory CannotUseItemError: If item is not a consumable """ # Find item in inventory item = self.get_item_by_id(character, item_id) if item is None: raise ItemNotFoundError(f"Item not found in inventory: {item_id}") # Validate item is consumable if not item.is_consumable(): raise CannotUseItemError( f"Cannot use '{item.get_display_name()}': not a consumable item" ) # Use character computed values if not provided if max_hp is None: max_hp = character.max_hp if current_hp is None: current_hp = max_hp # Outside combat, assume full HP # MP handling (if character has MP system) effective_stats = character.get_effective_stats() if max_mp is None: max_mp = getattr(effective_stats, 'magic_points', 100) if current_mp is None: current_mp = max_mp # Outside combat, assume full MP # Apply effects result = self._apply_consumable_effects( item, current_hp, max_hp, current_mp, max_mp ) # Remove consumable from inventory (it's used up) character.remove_item(item_id) logger.info("Consumable used", character_id=character.character_id, item_id=item_id, item_name=item.get_display_name(), hp_restored=result.hp_restored, mp_restored=result.mp_restored) # Persist changes self.character_service.update_character(character, user_id) return result def _apply_consumable_effects( self, item: Item, current_hp: int, max_hp: int, current_mp: int, max_mp: int ) -> ConsumableResult: """ Apply consumable effects and calculate results. Args: item: Consumable item current_hp: Current HP max_hp: Maximum HP current_mp: Current MP max_mp: Maximum MP Returns: ConsumableResult with effect details """ effects_applied = [] total_hp_restored = 0 total_mp_restored = 0 messages = [] for effect in item.effects_on_use: effect_result = { "effect_name": effect.name, "effect_type": effect.effect_type.value, } if effect.effect_type == EffectType.HOT: # Instant heal (for potions, treat HOT as instant outside combat) heal_amount = effect.power * effect.stacks actual_heal = min(heal_amount, max_hp - current_hp) current_hp += actual_heal total_hp_restored += actual_heal effect_result["value"] = actual_heal effect_result["message"] = f"Restored {actual_heal} HP" messages.append(f"Restored {actual_heal} HP") elif effect.effect_type == EffectType.BUFF: # Stat buff - would be applied in combat context stat_name = effect.stat_affected.value if effect.stat_affected else "unknown" effect_result["stat_affected"] = stat_name effect_result["modifier"] = effect.power effect_result["duration"] = effect.duration effect_result["message"] = f"+{effect.power} {stat_name} for {effect.duration} turns" messages.append(f"+{effect.power} {stat_name}") elif effect.effect_type == EffectType.SHIELD: # Apply shield effect shield_power = effect.power * effect.stacks effect_result["shield_power"] = shield_power effect_result["duration"] = effect.duration effect_result["message"] = f"Shield for {shield_power} damage" messages.append(f"Shield: {shield_power}") else: # Other effect types (DOT, DEBUFF, STUN - unusual for consumables) effect_result["power"] = effect.power effect_result["duration"] = effect.duration effect_result["message"] = f"{effect.name} applied" effects_applied.append(effect_result) # Build summary message summary = f"Used {item.get_display_name()}" if messages: summary += f": {', '.join(messages)}" return ConsumableResult( item_name=item.get_display_name(), effects_applied=effects_applied, hp_restored=total_hp_restored, mp_restored=total_mp_restored, message=summary, ) def use_consumable_in_combat( self, character: Character, item_id: str, user_id: str, current_hp: int, max_hp: int, current_mp: int = 0, max_mp: int = 0 ) -> Tuple[ConsumableResult, List[Effect]]: """ Use a consumable during combat. Returns both the result summary and a list of Effect objects that should be applied to the combatant for duration-based effects. Args: character: Character instance item_id: ID of consumable to use user_id: User ID for persistence authorization current_hp: Current combat HP max_hp: Maximum combat HP current_mp: Current combat MP max_mp: Maximum combat MP Returns: Tuple of (ConsumableResult, List[Effect]) for combat system Raises: ItemNotFoundError: If item not in inventory CannotUseItemError: If item is not consumable """ # Find item item = self.get_item_by_id(character, item_id) if item is None: raise ItemNotFoundError(f"Item not found in inventory: {item_id}") if not item.is_consumable(): raise CannotUseItemError( f"Cannot use '{item.get_display_name()}': not a consumable" ) # Separate instant effects from duration effects instant_effects = [] duration_effects = [] for effect in item.effects_on_use: # HOT effects in combat should tick, not instant heal if effect.duration > 1 or effect.effect_type in [ EffectType.BUFF, EffectType.DEBUFF, EffectType.DOT, EffectType.HOT, EffectType.SHIELD, EffectType.STUN ]: # Copy effect for combat tracking combat_effect = Effect( effect_id=f"{item.item_id}_{effect.effect_id}", name=effect.name, effect_type=effect.effect_type, duration=effect.duration, power=effect.power, stat_affected=effect.stat_affected, stacks=effect.stacks, max_stacks=effect.max_stacks, source=item.item_id, ) duration_effects.append(combat_effect) else: instant_effects.append(effect) # Calculate instant effect results result = self._apply_consumable_effects( item, current_hp, max_hp, current_mp, max_mp ) # Remove from inventory character.remove_item(item_id) logger.info("Consumable used in combat", character_id=character.character_id, item_id=item_id, duration_effects=len(duration_effects)) # Persist self.character_service.update_character(character, user_id) return result, duration_effects # ========================================================================= # Bulk Operations # ========================================================================= def add_items( self, character: Character, items: List[Item], user_id: str ) -> int: """ Add multiple items to inventory (e.g., loot drop). Args: character: Character instance items: List of items to add user_id: User ID for persistence Returns: Number of items actually added Note: Stops adding if inventory becomes full. Does not raise error for partial success. """ added_count = 0 for item in items: try: self.add_item(character, item, user_id, save=False) added_count += 1 except InventoryFullError: logger.warning("Inventory full, dropping remaining loot", character_id=character.character_id, items_dropped=len(items) - added_count) break # Save once after all items added if added_count > 0: self.character_service.update_character(character, user_id) return added_count def get_items_by_type( self, character: Character, item_type: ItemType ) -> List[Item]: """ Get all inventory items of a specific type. Args: character: Character instance item_type: Type to filter by Returns: List of matching items """ return [ item for item in character.inventory if item.item_type == item_type ] def get_equippable_items( self, character: Character, slot: Optional[str] = None ) -> List[Item]: """ Get all items that can be equipped. Args: character: Character instance slot: Optional slot to filter by Returns: List of equippable items (optionally filtered by slot) """ equippable = [] for item in character.inventory: # Skip non-equippable types if item.item_type not in ITEM_TYPE_SLOTS: continue # Skip items that don't meet requirements if not item.can_equip(character.level, character.class_id): continue # Filter by slot if specified if slot: allowed_slots = ITEM_TYPE_SLOTS[item.item_type] if slot not in allowed_slots: continue equippable.append(item) return equippable # ============================================================================= # Global Instance # ============================================================================= _service_instance: Optional[InventoryService] = None def get_inventory_service() -> InventoryService: """ Get the global InventoryService instance. Returns: Singleton InventoryService instance """ global _service_instance if _service_instance is None: _service_instance = InventoryService() return _service_instance