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.
868 lines
28 KiB
Python
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
|