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.
This commit is contained in:
867
api/app/services/inventory_service.py
Normal file
867
api/app/services/inventory_service.py
Normal file
@@ -0,0 +1,867 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user