Files
Code_of_Conquest/api/app/services/inventory_service.py
Phillip Tarrant 76f67c4a22 feat(api): implement inventory service with equipment system
Add InventoryService for managing character inventory, equipment, and
consumable usage. Key features:

- Add/remove items with inventory capacity checks
- Equipment slot validation (weapon, off_hand, helmet, chest, gloves,
  boots, accessory_1, accessory_2)
- Level and class requirement validation for equipment
- Consumable usage with instant and duration-based effects
- Combat-specific consumable method returning effects for combat system
- Bulk operations (add_items, get_items_by_type, get_equippable_items)

Design decision: Uses full Item object storage (not IDs) to support
procedurally generated items with unique identifiers.

Files added:
- /api/app/services/inventory_service.py (560 lines)
- /api/tests/test_inventory_service.py (51 tests passing)

Task 2.3 of Phase 4 Combat Implementation complete.
2025-11-26 18:38:39 -06:00

868 lines
28 KiB
Python

"""
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