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
|
||||||
@@ -708,6 +708,149 @@ success = service.soft_delete_message(
|
|||||||
- **Consumable:** One-time use (potions, scrolls)
|
- **Consumable:** One-time use (potions, scrolls)
|
||||||
- **Quest Item:** Story-related, non-tradeable
|
- **Quest Item:** Story-related, non-tradeable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedural Item Generation (Affix System)
|
||||||
|
|
||||||
|
The game uses a Diablo-style procedural item generation system where weapons and armor
|
||||||
|
are created by combining base templates with random affixes.
|
||||||
|
|
||||||
|
### Core Models
|
||||||
|
|
||||||
|
#### Affix
|
||||||
|
|
||||||
|
Represents a prefix or suffix that modifies an item's stats and name.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `affix_id` | str | Unique identifier |
|
||||||
|
| `name` | str | Display name ("Flaming", "of Strength") |
|
||||||
|
| `affix_type` | AffixType | PREFIX or SUFFIX |
|
||||||
|
| `tier` | AffixTier | MINOR, MAJOR, or LEGENDARY |
|
||||||
|
| `description` | str | Affix description |
|
||||||
|
| `stat_bonuses` | Dict[str, int] | Stat modifications |
|
||||||
|
| `damage_bonus` | int | Flat damage increase |
|
||||||
|
| `defense_bonus` | int | Flat defense increase |
|
||||||
|
| `resistance_bonus` | int | Flat resistance increase |
|
||||||
|
| `damage_type` | DamageType | For elemental affixes |
|
||||||
|
| `elemental_ratio` | float | Portion of damage converted to element |
|
||||||
|
| `crit_chance_bonus` | float | Critical hit chance modifier |
|
||||||
|
| `crit_multiplier_bonus` | float | Critical damage modifier |
|
||||||
|
| `allowed_item_types` | List[str] | Item types this affix can apply to |
|
||||||
|
| `required_rarity` | str | Minimum rarity required (for legendary affixes) |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `applies_elemental_damage() -> bool` - Check if affix adds elemental damage
|
||||||
|
- `is_legendary_only() -> bool` - Check if requires legendary rarity
|
||||||
|
- `can_apply_to(item_type, rarity) -> bool` - Check if affix can be applied
|
||||||
|
|
||||||
|
#### BaseItemTemplate
|
||||||
|
|
||||||
|
Foundation template for procedural item generation.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `template_id` | str | Unique identifier |
|
||||||
|
| `name` | str | Base item name ("Dagger") |
|
||||||
|
| `item_type` | str | "weapon" or "armor" |
|
||||||
|
| `description` | str | Template description |
|
||||||
|
| `base_damage` | int | Starting damage value |
|
||||||
|
| `base_defense` | int | Starting defense value |
|
||||||
|
| `base_resistance` | int | Starting resistance value |
|
||||||
|
| `base_value` | int | Base gold value |
|
||||||
|
| `damage_type` | str | Physical, fire, etc. |
|
||||||
|
| `crit_chance` | float | Base critical chance |
|
||||||
|
| `crit_multiplier` | float | Base critical multiplier |
|
||||||
|
| `required_level` | int | Minimum level to use |
|
||||||
|
| `min_rarity` | str | Minimum rarity this generates as |
|
||||||
|
| `drop_weight` | int | Relative drop probability |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `can_generate_at_rarity(rarity) -> bool` - Check if template supports rarity
|
||||||
|
- `can_drop_for_level(level) -> bool` - Check level requirement
|
||||||
|
|
||||||
|
### Item Model Updates for Generated Items
|
||||||
|
|
||||||
|
The `Item` dataclass includes fields for tracking generated items:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `applied_affixes` | List[str] | IDs of affixes on this item |
|
||||||
|
| `base_template_id` | str | ID of base template used |
|
||||||
|
| `generated_name` | str | Full name with affixes (e.g., "Flaming Dagger of Strength") |
|
||||||
|
| `is_generated` | bool | True if procedurally generated |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `get_display_name() -> str` - Returns generated_name if available, otherwise base name
|
||||||
|
|
||||||
|
### Generation Enumerations
|
||||||
|
|
||||||
|
#### ItemRarity
|
||||||
|
|
||||||
|
Item quality tiers affecting affix count and value:
|
||||||
|
|
||||||
|
| Value | Affix Count | Value Multiplier |
|
||||||
|
|-------|-------------|------------------|
|
||||||
|
| `COMMON` | 0 | 1.0× |
|
||||||
|
| `UNCOMMON` | 0 | 1.5× |
|
||||||
|
| `RARE` | 1 | 2.5× |
|
||||||
|
| `EPIC` | 2 | 5.0× |
|
||||||
|
| `LEGENDARY` | 3 | 10.0× |
|
||||||
|
|
||||||
|
#### AffixType
|
||||||
|
|
||||||
|
| Value | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `PREFIX` | Appears before item name ("Flaming Dagger") |
|
||||||
|
| `SUFFIX` | Appears after item name ("Dagger of Strength") |
|
||||||
|
|
||||||
|
#### AffixTier
|
||||||
|
|
||||||
|
Affix power level, determines eligibility by item rarity:
|
||||||
|
|
||||||
|
| Value | Description | Available For |
|
||||||
|
|-------|-------------|---------------|
|
||||||
|
| `MINOR` | Basic affixes | RARE+ |
|
||||||
|
| `MAJOR` | Stronger affixes | RARE+ (higher weight at EPIC+) |
|
||||||
|
| `LEGENDARY` | Most powerful affixes | LEGENDARY only |
|
||||||
|
|
||||||
|
### Item Generation Service
|
||||||
|
|
||||||
|
**Location:** `/app/services/item_generator.py`
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from app.services.item_generator import get_item_generator
|
||||||
|
from app.models.enums import ItemRarity
|
||||||
|
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate specific item
|
||||||
|
item = generator.generate_item(
|
||||||
|
item_type="weapon",
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
character_level=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate random loot drop with luck influence
|
||||||
|
item = generator.generate_loot_drop(
|
||||||
|
character_level=10,
|
||||||
|
luck_stat=12
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Related Loaders:**
|
||||||
|
- `AffixLoader` (`/app/services/affix_loader.py`) - Loads affix definitions from YAML
|
||||||
|
- `BaseItemLoader` (`/app/services/base_item_loader.py`) - Loads base templates from YAML
|
||||||
|
|
||||||
|
**Data Files:**
|
||||||
|
- `/app/data/affixes/prefixes.yaml` - Prefix definitions
|
||||||
|
- `/app/data/affixes/suffixes.yaml` - Suffix definitions
|
||||||
|
- `/app/data/base_items/weapons.yaml` - Weapon templates
|
||||||
|
- `/app/data/base_items/armor.yaml` - Armor templates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Ability
|
### Ability
|
||||||
|
|
||||||
Abilities represent actions that can be taken in combat (attacks, spells, skills, item uses).
|
Abilities represent actions that can be taken in combat (attacks, spells, skills, item uses).
|
||||||
|
|||||||
@@ -402,6 +402,111 @@ effects_applied:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Procedural Item Generation
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Weapons and armor are procedurally generated using a Diablo-style affix system.
|
||||||
|
Items are created by combining:
|
||||||
|
1. **Base Template** - Defines item type, base stats, level requirement
|
||||||
|
2. **Affixes** - Prefixes and suffixes that add stats and modify the name
|
||||||
|
|
||||||
|
### Generation Process
|
||||||
|
|
||||||
|
1. Select base template (filtered by level, rarity)
|
||||||
|
2. Determine affix count based on rarity (0-3)
|
||||||
|
3. Roll affix tier based on rarity weights
|
||||||
|
4. Select random affixes avoiding duplicates
|
||||||
|
5. Combine stats and generate name
|
||||||
|
|
||||||
|
### Rarity System
|
||||||
|
|
||||||
|
| Rarity | Affixes | Value Multiplier | Color |
|
||||||
|
|--------|---------|------------------|-------|
|
||||||
|
| COMMON | 0 | 1.0× | Gray |
|
||||||
|
| UNCOMMON | 0 | 1.5× | Green |
|
||||||
|
| RARE | 1 | 2.5× | Blue |
|
||||||
|
| EPIC | 2 | 5.0× | Purple |
|
||||||
|
| LEGENDARY | 3 | 10.0× | Orange |
|
||||||
|
|
||||||
|
### Affix Distribution
|
||||||
|
|
||||||
|
| Rarity | Affix Count | Distribution |
|
||||||
|
|--------|-------------|--------------|
|
||||||
|
| RARE | 1 | 50% prefix OR 50% suffix |
|
||||||
|
| EPIC | 2 | 1 prefix AND 1 suffix |
|
||||||
|
| LEGENDARY | 3 | Mix (2+1 or 1+2) |
|
||||||
|
|
||||||
|
### Affix Tiers
|
||||||
|
|
||||||
|
Higher rarity items have better chances at higher tier affixes:
|
||||||
|
|
||||||
|
| Rarity | MINOR | MAJOR | LEGENDARY |
|
||||||
|
|--------|-------|-------|-----------|
|
||||||
|
| RARE | 80% | 20% | 0% |
|
||||||
|
| EPIC | 30% | 70% | 0% |
|
||||||
|
| LEGENDARY | 10% | 40% | 50% |
|
||||||
|
|
||||||
|
### Name Generation Examples
|
||||||
|
|
||||||
|
- **COMMON:** "Dagger"
|
||||||
|
- **RARE (prefix):** "Flaming Dagger"
|
||||||
|
- **RARE (suffix):** "Dagger of Strength"
|
||||||
|
- **EPIC:** "Flaming Dagger of Strength"
|
||||||
|
- **LEGENDARY:** "Blazing Glacial Dagger of the Titan"
|
||||||
|
|
||||||
|
### Luck Influence
|
||||||
|
|
||||||
|
Player's LUK stat affects rarity rolls for loot drops:
|
||||||
|
|
||||||
|
**Base chances at LUK 8:**
|
||||||
|
- COMMON: 50%
|
||||||
|
- UNCOMMON: 30%
|
||||||
|
- RARE: 15%
|
||||||
|
- EPIC: 4%
|
||||||
|
- LEGENDARY: 1%
|
||||||
|
|
||||||
|
**Luck Bonus:**
|
||||||
|
Each point of LUK above 8 adds +0.5% to higher rarity chances.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
- LUK 8 (baseline): 1% legendary chance
|
||||||
|
- LUK 12: ~3% legendary chance
|
||||||
|
- LUK 16: ~5% legendary chance
|
||||||
|
|
||||||
|
### Service Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.item_generator import get_item_generator
|
||||||
|
from app.models.enums import ItemRarity
|
||||||
|
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate item of specific rarity
|
||||||
|
sword = generator.generate_item(
|
||||||
|
item_type="weapon",
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
character_level=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate random loot with luck bonus
|
||||||
|
loot = generator.generate_loot_drop(
|
||||||
|
character_level=10,
|
||||||
|
luck_stat=15
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Files
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `/app/data/base_items/weapons.yaml` | 13 weapon templates |
|
||||||
|
| `/app/data/base_items/armor.yaml` | 12 armor templates |
|
||||||
|
| `/app/data/affixes/prefixes.yaml` | 18 prefix affixes |
|
||||||
|
| `/app/data/affixes/suffixes.yaml` | 11 suffix affixes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quest System (Future)
|
## Quest System (Future)
|
||||||
|
|
||||||
### Quest Types
|
### Quest Types
|
||||||
|
|||||||
819
api/tests/test_inventory_service.py
Normal file
819
api/tests/test_inventory_service.py
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the InventoryService.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Adding and removing items
|
||||||
|
- Equipment slot validation
|
||||||
|
- Level and class requirement checks
|
||||||
|
- Consumable usage and effect application
|
||||||
|
- Bulk operations
|
||||||
|
- Error handling
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.models.character import Character
|
||||||
|
from app.models.items import Item
|
||||||
|
from app.models.effects import Effect
|
||||||
|
from app.models.stats import Stats
|
||||||
|
from app.models.enums import ItemType, ItemRarity, EffectType, StatType, DamageType
|
||||||
|
from app.models.skills import PlayerClass
|
||||||
|
from app.models.origins import Origin
|
||||||
|
from app.services.inventory_service import (
|
||||||
|
InventoryService,
|
||||||
|
ItemNotFoundError,
|
||||||
|
CannotEquipError,
|
||||||
|
InvalidSlotError,
|
||||||
|
CannotUseItemError,
|
||||||
|
InventoryFullError,
|
||||||
|
ConsumableResult,
|
||||||
|
VALID_SLOTS,
|
||||||
|
ITEM_TYPE_SLOTS,
|
||||||
|
MAX_INVENTORY_SIZE,
|
||||||
|
get_inventory_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Test Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_character_service():
|
||||||
|
"""Create a mock CharacterService."""
|
||||||
|
service = Mock()
|
||||||
|
service.update_character = Mock()
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def inventory_service(mock_character_service):
|
||||||
|
"""Create InventoryService with mocked dependencies."""
|
||||||
|
return InventoryService(character_service=mock_character_service)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_origin():
|
||||||
|
"""Create a minimal Origin for testing."""
|
||||||
|
from app.models.origins import StartingLocation, StartingBonus
|
||||||
|
|
||||||
|
starting_location = StartingLocation(
|
||||||
|
id="test_location",
|
||||||
|
name="Test Village",
|
||||||
|
region="Test Region",
|
||||||
|
description="A test location"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Origin(
|
||||||
|
id="test_origin",
|
||||||
|
name="Test Origin",
|
||||||
|
description="A test origin for testing purposes",
|
||||||
|
starting_location=starting_location,
|
||||||
|
narrative_hooks=["test hook"],
|
||||||
|
starting_bonus=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_player_class():
|
||||||
|
"""Create a minimal PlayerClass for testing."""
|
||||||
|
return PlayerClass(
|
||||||
|
class_id="warrior",
|
||||||
|
name="Warrior",
|
||||||
|
description="A mighty warrior",
|
||||||
|
base_stats=Stats(
|
||||||
|
strength=14,
|
||||||
|
dexterity=10,
|
||||||
|
constitution=12,
|
||||||
|
intelligence=8,
|
||||||
|
wisdom=10,
|
||||||
|
charisma=8,
|
||||||
|
luck=8,
|
||||||
|
),
|
||||||
|
skill_trees=[],
|
||||||
|
starting_abilities=["basic_attack"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_character(mock_player_class, mock_origin):
|
||||||
|
"""Create a test character."""
|
||||||
|
return Character(
|
||||||
|
character_id="char_test_123",
|
||||||
|
user_id="user_test_456",
|
||||||
|
name="Test Hero",
|
||||||
|
player_class=mock_player_class,
|
||||||
|
origin=mock_origin,
|
||||||
|
level=5,
|
||||||
|
experience=0,
|
||||||
|
base_stats=mock_player_class.base_stats.copy(),
|
||||||
|
inventory=[],
|
||||||
|
equipped={},
|
||||||
|
gold=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_weapon():
|
||||||
|
"""Create a test weapon item."""
|
||||||
|
return Item(
|
||||||
|
item_id="iron_sword",
|
||||||
|
name="Iron Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="A sturdy iron sword",
|
||||||
|
value=50,
|
||||||
|
damage=10,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
crit_chance=0.05,
|
||||||
|
crit_multiplier=2.0,
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_armor():
|
||||||
|
"""Create a test armor item."""
|
||||||
|
return Item(
|
||||||
|
item_id="leather_chest",
|
||||||
|
name="Leather Chestpiece",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="Simple leather armor",
|
||||||
|
value=40,
|
||||||
|
defense=5,
|
||||||
|
resistance=2,
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_helmet():
|
||||||
|
"""Create a test helmet item."""
|
||||||
|
return Item(
|
||||||
|
item_id="iron_helm",
|
||||||
|
name="Iron Helmet",
|
||||||
|
item_type=ItemType.ARMOR,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
description="A protective iron helmet",
|
||||||
|
value=30,
|
||||||
|
defense=3,
|
||||||
|
resistance=1,
|
||||||
|
required_level=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_consumable():
|
||||||
|
"""Create a test consumable item (health potion)."""
|
||||||
|
return Item(
|
||||||
|
item_id="health_potion_small",
|
||||||
|
name="Small Health Potion",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=ItemRarity.COMMON,
|
||||||
|
description="Restores 25 HP",
|
||||||
|
value=10,
|
||||||
|
effects_on_use=[
|
||||||
|
Effect(
|
||||||
|
effect_id="heal_25",
|
||||||
|
name="Minor Healing",
|
||||||
|
effect_type=EffectType.HOT,
|
||||||
|
duration=1,
|
||||||
|
power=25,
|
||||||
|
stacks=1,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_buff_potion():
|
||||||
|
"""Create a test buff potion."""
|
||||||
|
return Item(
|
||||||
|
item_id="strength_potion",
|
||||||
|
name="Potion of Strength",
|
||||||
|
item_type=ItemType.CONSUMABLE,
|
||||||
|
rarity=ItemRarity.UNCOMMON,
|
||||||
|
description="Increases strength temporarily",
|
||||||
|
value=25,
|
||||||
|
effects_on_use=[
|
||||||
|
Effect(
|
||||||
|
effect_id="str_buff",
|
||||||
|
name="Strength Boost",
|
||||||
|
effect_type=EffectType.BUFF,
|
||||||
|
duration=3,
|
||||||
|
power=5,
|
||||||
|
stat_affected=StatType.STRENGTH,
|
||||||
|
stacks=1,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_quest_item():
|
||||||
|
"""Create a test quest item."""
|
||||||
|
return Item(
|
||||||
|
item_id="ancient_key",
|
||||||
|
name="Ancient Key",
|
||||||
|
item_type=ItemType.QUEST_ITEM,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
description="An ornate key to the ancient tomb",
|
||||||
|
value=0,
|
||||||
|
is_tradeable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def high_level_weapon():
|
||||||
|
"""Create a weapon with high level requirement."""
|
||||||
|
return Item(
|
||||||
|
item_id="legendary_blade",
|
||||||
|
name="Blade of Ages",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.LEGENDARY,
|
||||||
|
description="A blade forged in ancient times",
|
||||||
|
value=5000,
|
||||||
|
damage=50,
|
||||||
|
damage_type=DamageType.PHYSICAL,
|
||||||
|
required_level=20, # Higher than test character's level 5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Read Operation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGetInventory:
|
||||||
|
"""Tests for get_inventory() and related read operations."""
|
||||||
|
|
||||||
|
def test_get_empty_inventory(self, inventory_service, test_character):
|
||||||
|
"""Test getting inventory when it's empty."""
|
||||||
|
items = inventory_service.get_inventory(test_character)
|
||||||
|
assert items == []
|
||||||
|
|
||||||
|
def test_get_inventory_with_items(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test getting inventory with items."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
items = inventory_service.get_inventory(test_character)
|
||||||
|
|
||||||
|
assert len(items) == 2
|
||||||
|
assert test_weapon in items
|
||||||
|
assert test_armor in items
|
||||||
|
|
||||||
|
def test_get_inventory_returns_copy(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test that get_inventory returns a new list (not the original)."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
items = inventory_service.get_inventory(test_character)
|
||||||
|
items.append(test_weapon) # Modify returned list
|
||||||
|
|
||||||
|
# Original inventory should be unchanged
|
||||||
|
assert len(test_character.inventory) == 1
|
||||||
|
|
||||||
|
def test_get_item_by_id_found(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test finding an item by ID."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
item = inventory_service.get_item_by_id(test_character, "iron_sword")
|
||||||
|
|
||||||
|
assert item is test_weapon
|
||||||
|
|
||||||
|
def test_get_item_by_id_not_found(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test item not found returns None."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
item = inventory_service.get_item_by_id(test_character, "nonexistent_item")
|
||||||
|
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
def test_get_equipped_items_empty(self, inventory_service, test_character):
|
||||||
|
"""Test getting equipped items when nothing equipped."""
|
||||||
|
equipped = inventory_service.get_equipped_items(test_character)
|
||||||
|
assert equipped == {}
|
||||||
|
|
||||||
|
def test_get_equipped_items_with_equipment(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test getting equipped items."""
|
||||||
|
test_character.equipped = {"weapon": test_weapon}
|
||||||
|
|
||||||
|
equipped = inventory_service.get_equipped_items(test_character)
|
||||||
|
|
||||||
|
assert "weapon" in equipped
|
||||||
|
assert equipped["weapon"] is test_weapon
|
||||||
|
|
||||||
|
def test_get_equipped_item_specific_slot(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test getting item from a specific slot."""
|
||||||
|
test_character.equipped = {"weapon": test_weapon}
|
||||||
|
|
||||||
|
item = inventory_service.get_equipped_item(test_character, "weapon")
|
||||||
|
|
||||||
|
assert item is test_weapon
|
||||||
|
|
||||||
|
def test_get_equipped_item_empty_slot(self, inventory_service, test_character):
|
||||||
|
"""Test getting item from empty slot returns None."""
|
||||||
|
item = inventory_service.get_equipped_item(test_character, "weapon")
|
||||||
|
assert item is None
|
||||||
|
|
||||||
|
def test_get_inventory_count(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test counting inventory items."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
count = inventory_service.get_inventory_count(test_character)
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Add/Remove Item Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAddItem:
|
||||||
|
"""Tests for add_item()."""
|
||||||
|
|
||||||
|
def test_add_item_success(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test successfully adding an item."""
|
||||||
|
inventory_service.add_item(test_character, test_weapon, "user_test_456")
|
||||||
|
|
||||||
|
assert test_weapon in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_add_item_without_save(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test adding item without persistence."""
|
||||||
|
inventory_service.add_item(test_character, test_weapon, "user_test_456", save=False)
|
||||||
|
|
||||||
|
assert test_weapon in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_not_called()
|
||||||
|
|
||||||
|
def test_add_multiple_items(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service):
|
||||||
|
"""Test adding multiple items."""
|
||||||
|
inventory_service.add_item(test_character, test_weapon, "user_test_456")
|
||||||
|
inventory_service.add_item(test_character, test_armor, "user_test_456")
|
||||||
|
|
||||||
|
assert len(test_character.inventory) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveItem:
|
||||||
|
"""Tests for remove_item()."""
|
||||||
|
|
||||||
|
def test_remove_item_success(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test successfully removing an item."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
removed = inventory_service.remove_item(test_character, "iron_sword", "user_test_456")
|
||||||
|
|
||||||
|
assert removed is test_weapon
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_remove_item_not_found(self, inventory_service, test_character):
|
||||||
|
"""Test removing non-existent item raises error."""
|
||||||
|
with pytest.raises(ItemNotFoundError) as exc:
|
||||||
|
inventory_service.remove_item(test_character, "nonexistent", "user_test_456")
|
||||||
|
|
||||||
|
assert "nonexistent" in str(exc.value)
|
||||||
|
|
||||||
|
def test_remove_item_from_multiple(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test removing one item from multiple."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
inventory_service.remove_item(test_character, "iron_sword", "user_test_456")
|
||||||
|
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
assert test_armor in test_character.inventory
|
||||||
|
|
||||||
|
def test_drop_item_alias(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test drop_item is an alias for remove_item."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
dropped = inventory_service.drop_item(test_character, "iron_sword", "user_test_456")
|
||||||
|
|
||||||
|
assert dropped is test_weapon
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Equipment Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEquipItem:
|
||||||
|
"""Tests for equip_item()."""
|
||||||
|
|
||||||
|
def test_equip_weapon_to_weapon_slot(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test equipping a weapon to weapon slot."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
previous = inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert previous is None
|
||||||
|
assert test_character.equipped.get("weapon") is test_weapon
|
||||||
|
assert test_weapon not in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_equip_armor_to_chest_slot(self, inventory_service, test_character, test_armor, mock_character_service):
|
||||||
|
"""Test equipping armor to chest slot."""
|
||||||
|
test_character.inventory = [test_armor]
|
||||||
|
|
||||||
|
inventory_service.equip_item(test_character, "leather_chest", "chest", "user_test_456")
|
||||||
|
|
||||||
|
assert test_character.equipped.get("chest") is test_armor
|
||||||
|
|
||||||
|
def test_equip_returns_previous_item(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test that equipping returns the previously equipped item."""
|
||||||
|
old_weapon = Item(
|
||||||
|
item_id="old_sword",
|
||||||
|
name="Old Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
damage=5,
|
||||||
|
)
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
test_character.equipped = {"weapon": old_weapon}
|
||||||
|
|
||||||
|
previous = inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert previous is old_weapon
|
||||||
|
assert old_weapon in test_character.inventory # Returned to inventory
|
||||||
|
|
||||||
|
def test_equip_to_invalid_slot_raises_error(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test equipping to invalid slot raises InvalidSlotError."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
with pytest.raises(InvalidSlotError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "invalid_slot", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "invalid_slot" in str(exc.value)
|
||||||
|
assert "Valid slots" in str(exc.value)
|
||||||
|
|
||||||
|
def test_equip_weapon_to_armor_slot_raises_error(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test equipping weapon to armor slot raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "chest", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "weapon" in str(exc.value).lower()
|
||||||
|
|
||||||
|
def test_equip_armor_to_weapon_slot_raises_error(self, inventory_service, test_character, test_armor):
|
||||||
|
"""Test equipping armor to weapon slot raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_armor]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "leather_chest", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "armor" in str(exc.value).lower()
|
||||||
|
|
||||||
|
def test_equip_item_not_in_inventory(self, inventory_service, test_character):
|
||||||
|
"""Test equipping item not in inventory raises ItemNotFoundError."""
|
||||||
|
with pytest.raises(ItemNotFoundError):
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "nonexistent", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_equip_item_level_requirement_not_met(self, inventory_service, test_character, high_level_weapon):
|
||||||
|
"""Test equipping item with unmet level requirement raises error."""
|
||||||
|
test_character.inventory = [high_level_weapon]
|
||||||
|
test_character.level = 5 # Item requires level 20
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "legendary_blade", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "level 20" in str(exc.value)
|
||||||
|
assert "level 5" in str(exc.value)
|
||||||
|
|
||||||
|
def test_equip_consumable_raises_error(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test equipping consumable raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "health_potion_small", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "consumable" in str(exc.value).lower()
|
||||||
|
|
||||||
|
def test_equip_quest_item_raises_error(self, inventory_service, test_character, test_quest_item):
|
||||||
|
"""Test equipping quest item raises CannotEquipError."""
|
||||||
|
test_character.inventory = [test_quest_item]
|
||||||
|
|
||||||
|
with pytest.raises(CannotEquipError) as exc:
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "ancient_key", "weapon", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_equip_weapon_to_off_hand(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test equipping weapon to off_hand slot."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
inventory_service.equip_item(
|
||||||
|
test_character, "iron_sword", "off_hand", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert test_character.equipped.get("off_hand") is test_weapon
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnequipItem:
|
||||||
|
"""Tests for unequip_item()."""
|
||||||
|
|
||||||
|
def test_unequip_item_success(self, inventory_service, test_character, test_weapon, mock_character_service):
|
||||||
|
"""Test successfully unequipping an item."""
|
||||||
|
test_character.equipped = {"weapon": test_weapon}
|
||||||
|
|
||||||
|
unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456")
|
||||||
|
|
||||||
|
assert unequipped is test_weapon
|
||||||
|
assert "weapon" not in test_character.equipped
|
||||||
|
assert test_weapon in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_unequip_empty_slot_returns_none(self, inventory_service, test_character, mock_character_service):
|
||||||
|
"""Test unequipping from empty slot returns None."""
|
||||||
|
unequipped = inventory_service.unequip_item(test_character, "weapon", "user_test_456")
|
||||||
|
|
||||||
|
assert unequipped is None
|
||||||
|
|
||||||
|
def test_unequip_invalid_slot_raises_error(self, inventory_service, test_character):
|
||||||
|
"""Test unequipping from invalid slot raises InvalidSlotError."""
|
||||||
|
with pytest.raises(InvalidSlotError):
|
||||||
|
inventory_service.unequip_item(test_character, "invalid_slot", "user_test_456")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Consumable Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestUseConsumable:
|
||||||
|
"""Tests for use_consumable()."""
|
||||||
|
|
||||||
|
def test_use_health_potion(self, inventory_service, test_character, test_consumable, mock_character_service):
|
||||||
|
"""Test using a health potion restores HP."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, ConsumableResult)
|
||||||
|
assert result.hp_restored == 25 # Potion restores 25, capped at missing HP
|
||||||
|
assert result.item_name == "Small Health Potion"
|
||||||
|
assert test_consumable not in test_character.inventory
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_use_health_potion_capped_at_max(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test HP restoration is capped at max HP."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=90, max_hp=100 # Only missing 10 HP
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.hp_restored == 10 # Only restores missing amount
|
||||||
|
|
||||||
|
def test_use_consumable_at_full_hp(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test using potion at full HP restores 0."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=100, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.hp_restored == 0
|
||||||
|
|
||||||
|
def test_use_buff_potion(self, inventory_service, test_character, test_buff_potion, mock_character_service):
|
||||||
|
"""Test using a buff potion."""
|
||||||
|
test_character.inventory = [test_buff_potion]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "strength_potion", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.item_name == "Potion of Strength"
|
||||||
|
assert len(result.effects_applied) == 1
|
||||||
|
assert result.effects_applied[0]["effect_type"] == "buff"
|
||||||
|
assert result.effects_applied[0]["stat_affected"] == "strength"
|
||||||
|
|
||||||
|
def test_use_non_consumable_raises_error(self, inventory_service, test_character, test_weapon):
|
||||||
|
"""Test using non-consumable item raises CannotUseItemError."""
|
||||||
|
test_character.inventory = [test_weapon]
|
||||||
|
|
||||||
|
with pytest.raises(CannotUseItemError) as exc:
|
||||||
|
inventory_service.use_consumable(
|
||||||
|
test_character, "iron_sword", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "not a consumable" in str(exc.value)
|
||||||
|
|
||||||
|
def test_use_item_not_in_inventory_raises_error(self, inventory_service, test_character):
|
||||||
|
"""Test using item not in inventory raises ItemNotFoundError."""
|
||||||
|
with pytest.raises(ItemNotFoundError):
|
||||||
|
inventory_service.use_consumable(
|
||||||
|
test_character, "nonexistent", "user_test_456"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_consumable_result_to_dict(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test ConsumableResult serialization."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result = inventory_service.use_consumable(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
result_dict = result.to_dict()
|
||||||
|
|
||||||
|
assert "item_name" in result_dict
|
||||||
|
assert "hp_restored" in result_dict
|
||||||
|
assert "effects_applied" in result_dict
|
||||||
|
assert "message" in result_dict
|
||||||
|
|
||||||
|
|
||||||
|
class TestUseConsumableInCombat:
|
||||||
|
"""Tests for use_consumable_in_combat()."""
|
||||||
|
|
||||||
|
def test_combat_consumable_returns_effects(self, inventory_service, test_character, test_buff_potion):
|
||||||
|
"""Test combat consumable returns duration effects."""
|
||||||
|
test_character.inventory = [test_buff_potion]
|
||||||
|
|
||||||
|
result, effects = inventory_service.use_consumable_in_combat(
|
||||||
|
test_character, "strength_potion", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, ConsumableResult)
|
||||||
|
assert len(effects) == 1
|
||||||
|
assert effects[0].effect_type == EffectType.BUFF
|
||||||
|
assert effects[0].duration == 3
|
||||||
|
|
||||||
|
def test_combat_instant_heal_potion(self, inventory_service, test_character, test_consumable):
|
||||||
|
"""Test instant heal in combat."""
|
||||||
|
test_character.inventory = [test_consumable]
|
||||||
|
|
||||||
|
result, effects = inventory_service.use_consumable_in_combat(
|
||||||
|
test_character, "health_potion_small", "user_test_456",
|
||||||
|
current_hp=50, max_hp=100
|
||||||
|
)
|
||||||
|
|
||||||
|
# HOT with duration 1 should be returned as duration effect for combat tracking
|
||||||
|
assert len(effects) >= 0 # Implementation may vary
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Bulk Operation Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestBulkOperations:
|
||||||
|
"""Tests for bulk inventory operations."""
|
||||||
|
|
||||||
|
def test_add_items_bulk(self, inventory_service, test_character, test_weapon, test_armor, mock_character_service):
|
||||||
|
"""Test adding multiple items at once."""
|
||||||
|
items = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
count = inventory_service.add_items(test_character, items, "user_test_456")
|
||||||
|
|
||||||
|
assert count == 2
|
||||||
|
assert len(test_character.inventory) == 2
|
||||||
|
mock_character_service.update_character.assert_called_once()
|
||||||
|
|
||||||
|
def test_get_items_by_type(self, inventory_service, test_character, test_weapon, test_armor, test_consumable):
|
||||||
|
"""Test filtering items by type."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor, test_consumable]
|
||||||
|
|
||||||
|
weapons = inventory_service.get_items_by_type(test_character, ItemType.WEAPON)
|
||||||
|
armor = inventory_service.get_items_by_type(test_character, ItemType.ARMOR)
|
||||||
|
consumables = inventory_service.get_items_by_type(test_character, ItemType.CONSUMABLE)
|
||||||
|
|
||||||
|
assert len(weapons) == 1
|
||||||
|
assert test_weapon in weapons
|
||||||
|
assert len(armor) == 1
|
||||||
|
assert test_armor in armor
|
||||||
|
assert len(consumables) == 1
|
||||||
|
|
||||||
|
def test_get_equippable_items(self, inventory_service, test_character, test_weapon, test_armor, test_consumable):
|
||||||
|
"""Test getting only equippable items."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor, test_consumable]
|
||||||
|
|
||||||
|
equippable = inventory_service.get_equippable_items(test_character)
|
||||||
|
|
||||||
|
assert test_weapon in equippable
|
||||||
|
assert test_armor in equippable
|
||||||
|
assert test_consumable not in equippable
|
||||||
|
|
||||||
|
def test_get_equippable_items_for_slot(self, inventory_service, test_character, test_weapon, test_armor):
|
||||||
|
"""Test getting equippable items for a specific slot."""
|
||||||
|
test_character.inventory = [test_weapon, test_armor]
|
||||||
|
|
||||||
|
for_weapon = inventory_service.get_equippable_items(test_character, slot="weapon")
|
||||||
|
for_chest = inventory_service.get_equippable_items(test_character, slot="chest")
|
||||||
|
|
||||||
|
assert test_weapon in for_weapon
|
||||||
|
assert test_armor not in for_weapon
|
||||||
|
assert test_armor in for_chest
|
||||||
|
assert test_weapon not in for_chest
|
||||||
|
|
||||||
|
def test_get_equippable_items_excludes_high_level(self, inventory_service, test_character, test_weapon, high_level_weapon):
|
||||||
|
"""Test that items above character level are excluded."""
|
||||||
|
test_character.inventory = [test_weapon, high_level_weapon]
|
||||||
|
test_character.level = 5
|
||||||
|
|
||||||
|
equippable = inventory_service.get_equippable_items(test_character)
|
||||||
|
|
||||||
|
assert test_weapon in equippable
|
||||||
|
assert high_level_weapon not in equippable
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Edge Cases and Error Handling
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Tests for edge cases and error handling."""
|
||||||
|
|
||||||
|
def test_valid_slots_constant(self):
|
||||||
|
"""Test VALID_SLOTS contains expected slots."""
|
||||||
|
expected = {"weapon", "off_hand", "helmet", "chest", "gloves", "boots", "accessory_1", "accessory_2"}
|
||||||
|
assert VALID_SLOTS == expected
|
||||||
|
|
||||||
|
def test_item_type_slots_mapping(self):
|
||||||
|
"""Test ITEM_TYPE_SLOTS mapping is correct."""
|
||||||
|
assert ItemType.WEAPON in ITEM_TYPE_SLOTS
|
||||||
|
assert "weapon" in ITEM_TYPE_SLOTS[ItemType.WEAPON]
|
||||||
|
assert "off_hand" in ITEM_TYPE_SLOTS[ItemType.WEAPON]
|
||||||
|
assert ItemType.ARMOR in ITEM_TYPE_SLOTS
|
||||||
|
assert "chest" in ITEM_TYPE_SLOTS[ItemType.ARMOR]
|
||||||
|
assert "helmet" in ITEM_TYPE_SLOTS[ItemType.ARMOR]
|
||||||
|
|
||||||
|
def test_generated_item_with_unique_id(self, inventory_service, test_character, mock_character_service):
|
||||||
|
"""Test handling of generated items with unique IDs."""
|
||||||
|
generated_item = Item(
|
||||||
|
item_id="gen_abc123", # Generated item ID format
|
||||||
|
name="Dagger",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.RARE,
|
||||||
|
damage=15,
|
||||||
|
is_generated=True,
|
||||||
|
generated_name="Flaming Dagger of Strength",
|
||||||
|
base_template_id="dagger",
|
||||||
|
applied_affixes=["flaming", "of_strength"],
|
||||||
|
)
|
||||||
|
|
||||||
|
inventory_service.add_item(test_character, generated_item, "user_test_456")
|
||||||
|
|
||||||
|
assert generated_item in test_character.inventory
|
||||||
|
assert generated_item.get_display_name() == "Flaming Dagger of Strength"
|
||||||
|
|
||||||
|
def test_equip_generated_item(self, inventory_service, test_character, mock_character_service):
|
||||||
|
"""Test equipping a generated item."""
|
||||||
|
generated_item = Item(
|
||||||
|
item_id="gen_xyz789",
|
||||||
|
name="Sword",
|
||||||
|
item_type=ItemType.WEAPON,
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
damage=25,
|
||||||
|
is_generated=True,
|
||||||
|
generated_name="Blazing Sword of Power",
|
||||||
|
required_level=1,
|
||||||
|
)
|
||||||
|
test_character.inventory = [generated_item]
|
||||||
|
|
||||||
|
inventory_service.equip_item(test_character, "gen_xyz789", "weapon", "user_test_456")
|
||||||
|
|
||||||
|
assert test_character.equipped.get("weapon") is generated_item
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Global Instance Tests
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestGlobalInstance:
|
||||||
|
"""Tests for the global singleton pattern."""
|
||||||
|
|
||||||
|
def test_get_inventory_service_returns_instance(self):
|
||||||
|
"""Test get_inventory_service returns InventoryService."""
|
||||||
|
with patch('app.services.inventory_service._service_instance', None):
|
||||||
|
with patch('app.services.inventory_service.get_character_service'):
|
||||||
|
service = get_inventory_service()
|
||||||
|
assert isinstance(service, InventoryService)
|
||||||
|
|
||||||
|
def test_get_inventory_service_returns_same_instance(self):
|
||||||
|
"""Test get_inventory_service returns singleton."""
|
||||||
|
with patch('app.services.inventory_service._service_instance', None):
|
||||||
|
with patch('app.services.inventory_service.get_character_service'):
|
||||||
|
service1 = get_inventory_service()
|
||||||
|
service2 = get_inventory_service()
|
||||||
|
assert service1 is service2
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 4: Combat & Progression Systems - Implementation Plan
|
# Phase 4: Combat & Progression Systems - Implementation Plan
|
||||||
|
|
||||||
**Status:** In Progress - Week 1 Complete
|
**Status:** In Progress - Week 2 In Progress
|
||||||
**Timeline:** 4-5 weeks
|
**Timeline:** 4-5 weeks
|
||||||
**Last Updated:** November 26, 2025
|
**Last Updated:** November 26, 2025
|
||||||
**Document Version:** 1.1
|
**Document Version:** 1.1
|
||||||
@@ -973,108 +973,250 @@ app.register_blueprint(combat_bp, url_prefix='/api/v1/combat')
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Week 2: Inventory & Equipment System ⏳ NEXT
|
### Week 2: Inventory & Equipment System ⏳ IN PROGRESS
|
||||||
|
|
||||||
#### Task 2.1: Verify Item Data Models (2 hours)
|
#### Task 2.1: Item Data Models ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Review item system implementation
|
**Objective:** Implement Diablo-style item generation with affixes
|
||||||
|
|
||||||
**Files to Review:**
|
**Files Implemented:**
|
||||||
- `/api/app/models/items.py` - Item, ItemType, ItemRarity
|
- `/api/app/models/items.py` - Item dataclass with affix support
|
||||||
- `/api/app/models/enums.py` - ItemType enum
|
- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses
|
||||||
|
- `/api/app/models/enums.py` - ItemRarity, AffixType, AffixTier enums
|
||||||
|
|
||||||
**Verification Checklist:**
|
**Item Model - New Fields for Generated Items:**
|
||||||
- [ ] Item dataclass complete with all fields
|
|
||||||
- [ ] ItemType enum: WEAPON, ARMOR, CONSUMABLE, QUEST_ITEM
|
|
||||||
- [ ] Item has `to_dict()` and `from_dict()` methods
|
|
||||||
- [ ] Weapon-specific fields: damage, crit_chance, crit_multiplier
|
|
||||||
- [ ] Armor-specific fields: defense, resistance
|
|
||||||
- [ ] Consumable-specific fields: effects
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
```python
|
||||||
- Item model can represent all item types
|
@dataclass
|
||||||
- Serialization works correctly
|
class Item:
|
||||||
|
# ... existing fields ...
|
||||||
|
|
||||||
|
# Affix tracking (for generated items)
|
||||||
|
applied_affixes: List[str] = field(default_factory=list)
|
||||||
|
base_template_id: Optional[str] = None
|
||||||
|
generated_name: Optional[str] = None
|
||||||
|
is_generated: bool = False
|
||||||
|
|
||||||
|
def get_display_name(self) -> str:
|
||||||
|
"""Return generated name if available, otherwise base name."""
|
||||||
|
return self.generated_name if self.generated_name else self.name
|
||||||
|
```
|
||||||
|
|
||||||
|
**Affix Model:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class Affix:
|
||||||
|
affix_id: str
|
||||||
|
name: str # Display name ("Flaming", "of Strength")
|
||||||
|
affix_type: AffixType # PREFIX or SUFFIX
|
||||||
|
tier: AffixTier # MINOR, MAJOR, LEGENDARY
|
||||||
|
stat_bonuses: Dict[str, int] # {"strength": 3, "dexterity": 2}
|
||||||
|
damage_bonus: int = 0
|
||||||
|
defense_bonus: int = 0
|
||||||
|
damage_type: Optional[DamageType] = None # For elemental prefixes
|
||||||
|
elemental_ratio: float = 0.0
|
||||||
|
allowed_item_types: List[str] = field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
**BaseItemTemplate Model:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class BaseItemTemplate:
|
||||||
|
template_id: str
|
||||||
|
name: str # "Dagger", "Longsword"
|
||||||
|
item_type: str # "weapon" or "armor"
|
||||||
|
base_damage: int = 0
|
||||||
|
base_defense: int = 0
|
||||||
|
base_value: int = 0
|
||||||
|
required_level: int = 1
|
||||||
|
min_rarity: str = "common" # Minimum rarity this can generate as
|
||||||
|
drop_weight: int = 100 # Relative drop chance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:** ✅ MET
|
||||||
|
- Item model supports both static and generated items
|
||||||
|
- Affix system with PREFIX/SUFFIX types
|
||||||
|
- Three affix tiers (MINOR, MAJOR, LEGENDARY)
|
||||||
|
- BaseItemTemplate for procedural generation foundation
|
||||||
|
- All models have to_dict()/from_dict() serialization
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Task 2.2: Create Starting Items YAML (4 hours)
|
#### Task 2.2: Item Data Files ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Define 20-30 basic items in YAML
|
**Objective:** Create YAML data files for item generation system
|
||||||
|
|
||||||
**Directory Structure:**
|
**Directory Structure:**
|
||||||
|
|
||||||
```
|
```
|
||||||
/api/app/data/items/
|
/api/app/data/
|
||||||
├── weapons/
|
├── items/ # Static items (consumables, quest items)
|
||||||
│ ├── swords.yaml
|
│ └── consumables/
|
||||||
│ ├── bows.yaml
|
│ └── potions.yaml
|
||||||
│ └── staves.yaml
|
├── base_items/ # Base templates for generation
|
||||||
├── armor/
|
│ ├── weapons.yaml # 13 weapon templates
|
||||||
│ ├── helmets.yaml
|
│ └── armor.yaml # 12 armor templates
|
||||||
│ ├── chest.yaml
|
└── affixes/ # Prefix/suffix definitions
|
||||||
│ └── boots.yaml
|
├── prefixes.yaml # 18 prefixes
|
||||||
└── consumables/
|
└── suffixes.yaml # 11 suffixes
|
||||||
└── potions.yaml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example: `/api/app/data/items/weapons/swords.yaml`**
|
**Example Base Weapon Template (`/api/app/data/base_items/weapons.yaml`):**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- item_id: "iron_sword"
|
dagger:
|
||||||
name: "Iron Sword"
|
template_id: "dagger"
|
||||||
description: "A sturdy iron blade. Reliable and affordable."
|
name: "Dagger"
|
||||||
item_type: "weapon"
|
item_type: "weapon"
|
||||||
rarity: "common"
|
base_damage: 6
|
||||||
value: 50
|
damage_type: "physical"
|
||||||
damage: 10
|
|
||||||
crit_chance: 0.05
|
|
||||||
crit_multiplier: 2.0
|
|
||||||
required_level: 1
|
|
||||||
is_tradeable: true
|
|
||||||
|
|
||||||
- item_id: "steel_sword"
|
|
||||||
name: "Steel Sword"
|
|
||||||
description: "Forged from high-quality steel. Sharper and more durable."
|
|
||||||
item_type: "weapon"
|
|
||||||
rarity: "uncommon"
|
|
||||||
value: 150
|
|
||||||
damage: 18
|
|
||||||
crit_chance: 0.08
|
crit_chance: 0.08
|
||||||
crit_multiplier: 2.0
|
crit_multiplier: 2.0
|
||||||
required_level: 3
|
base_value: 15
|
||||||
is_tradeable: true
|
required_level: 1
|
||||||
|
drop_weight: 100
|
||||||
- item_id: "enchanted_blade"
|
|
||||||
name: "Enchanted Blade"
|
|
||||||
description: "A sword infused with magical energy."
|
|
||||||
item_type: "weapon"
|
|
||||||
rarity: "rare"
|
|
||||||
value: 500
|
|
||||||
damage: 30
|
|
||||||
crit_chance: 0.12
|
|
||||||
crit_multiplier: 2.5
|
|
||||||
required_level: 7
|
|
||||||
is_tradeable: true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Create Items:**
|
**Example Prefix Affix (`/api/app/data/affixes/prefixes.yaml`):**
|
||||||
- **Weapons** (10 items): Swords, bows, staves, daggers (common → legendary)
|
|
||||||
- **Armor** (10 items): Helmets, chest armor, boots (light/medium/heavy)
|
|
||||||
- **Consumables** (10 items): Health potions (small/medium/large), mana potions, antidotes, scrolls
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
```yaml
|
||||||
- At least 20 items defined
|
flaming:
|
||||||
- Mix of item types and rarities
|
affix_id: "flaming"
|
||||||
- Balanced stats for level requirements
|
name: "Flaming"
|
||||||
- All YAML files valid and loadable
|
affix_type: "prefix"
|
||||||
|
tier: "minor"
|
||||||
|
damage_type: "fire"
|
||||||
|
elemental_ratio: 0.25
|
||||||
|
damage_bonus: 3
|
||||||
|
allowed_item_types: ["weapon"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Suffix Affix (`/api/app/data/affixes/suffixes.yaml`):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
of_strength:
|
||||||
|
affix_id: "of_strength"
|
||||||
|
name: "of Strength"
|
||||||
|
affix_type: "suffix"
|
||||||
|
tier: "minor"
|
||||||
|
stat_bonuses:
|
||||||
|
strength: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Items Created:**
|
||||||
|
- **Base Templates:** 25 total (13 weapons, 12 armor across cloth/leather/chain/plate)
|
||||||
|
- **Prefixes:** 18 total (elemental, material, quality, defensive, legendary)
|
||||||
|
- **Suffixes:** 11 total (stat bonuses, animal totems, defensive, legendary)
|
||||||
|
- **Static Consumables:** Health/mana potions (small/medium/large)
|
||||||
|
|
||||||
|
**Acceptance Criteria:** ✅ MET
|
||||||
|
- Base templates cover all weapon/armor categories
|
||||||
|
- Affixes balanced across tiers
|
||||||
|
- YAML files valid and loadable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Task 2.3: Implement Inventory Service (1 day / 8 hours)
|
#### Task 2.2.1: Item Generator Service ✅ COMPLETE
|
||||||
|
|
||||||
|
**Objective:** Implement procedural item generation with Diablo-style naming
|
||||||
|
|
||||||
|
**Files Implemented:**
|
||||||
|
- `/api/app/services/item_generator.py` - Main generation service (535 lines)
|
||||||
|
- `/api/app/services/affix_loader.py` - Loads affixes from YAML (316 lines)
|
||||||
|
- `/api/app/services/base_item_loader.py` - Loads base templates from YAML (274 lines)
|
||||||
|
|
||||||
|
**ItemGenerator Usage:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.services.item_generator import get_item_generator
|
||||||
|
from app.models.enums import ItemRarity
|
||||||
|
|
||||||
|
generator = get_item_generator()
|
||||||
|
|
||||||
|
# Generate specific item
|
||||||
|
item = generator.generate_item(
|
||||||
|
item_type="weapon",
|
||||||
|
rarity=ItemRarity.EPIC,
|
||||||
|
character_level=5
|
||||||
|
)
|
||||||
|
# Result: "Flaming Longsword of Strength" (1 prefix + 1 suffix)
|
||||||
|
|
||||||
|
# Generate random loot drop with luck influence
|
||||||
|
item = generator.generate_loot_drop(
|
||||||
|
character_level=10,
|
||||||
|
luck_stat=12 # Higher luck = better rarity chance
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Affix Distribution by Rarity:**
|
||||||
|
|
||||||
|
| Rarity | Affix Count | Distribution |
|
||||||
|
|--------|-------------|--------------|
|
||||||
|
| COMMON | 0 | Plain item |
|
||||||
|
| UNCOMMON | 0 | Plain item |
|
||||||
|
| RARE | 1 | 50% prefix OR 50% suffix |
|
||||||
|
| EPIC | 2 | 1 prefix AND 1 suffix |
|
||||||
|
| LEGENDARY | 3 | Mix (2+1 or 1+2) |
|
||||||
|
|
||||||
|
**Name Generation Examples:**
|
||||||
|
- COMMON: "Dagger"
|
||||||
|
- RARE: "Flaming Dagger" or "Dagger of Strength"
|
||||||
|
- EPIC: "Flaming Dagger of Strength"
|
||||||
|
- LEGENDARY: "Blazing Glacial Dagger of the Titan"
|
||||||
|
|
||||||
|
**Tier Weights by Rarity:**
|
||||||
|
|
||||||
|
| Rarity | MINOR | MAJOR | LEGENDARY |
|
||||||
|
|--------|-------|-------|-----------|
|
||||||
|
| RARE | 80% | 20% | 0% |
|
||||||
|
| EPIC | 30% | 70% | 0% |
|
||||||
|
| LEGENDARY | 10% | 40% | 50% |
|
||||||
|
|
||||||
|
**Rarity Rolling (with Luck):**
|
||||||
|
|
||||||
|
Base chances at luck 8:
|
||||||
|
- COMMON: 50%
|
||||||
|
- UNCOMMON: 30%
|
||||||
|
- RARE: 15%
|
||||||
|
- EPIC: 4%
|
||||||
|
- LEGENDARY: 1%
|
||||||
|
|
||||||
|
Luck bonus: `(luck - 8) * 0.005` per threshold
|
||||||
|
|
||||||
|
**Tests:** `/api/tests/test_item_generator.py` (528 lines, comprehensive coverage)
|
||||||
|
|
||||||
|
**Acceptance Criteria:** ✅ MET
|
||||||
|
- Procedural generation works for all rarities
|
||||||
|
- Affix selection respects tier weights
|
||||||
|
- Generated names follow Diablo naming convention
|
||||||
|
- Luck stat influences rarity rolls
|
||||||
|
- Stats properly combined from template + affixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2.3: Implement Inventory Service (1 day / 8 hours) ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Service layer for inventory management
|
**Objective:** Service layer for inventory management
|
||||||
|
|
||||||
**File:** `/api/app/services/inventory_service.py`
|
**File:** `/api/app/services/inventory_service.py`
|
||||||
|
|
||||||
|
**Actual Implementation:**
|
||||||
|
|
||||||
|
The InventoryService was implemented as an orchestration layer on top of the existing Character model inventory methods. Key design decisions:
|
||||||
|
|
||||||
|
1. **Full Object Storage (Not IDs)**: The Character model already stores `List[Item]` for inventory and `Dict[str, Item]` for equipped items. This approach works better for generated items which have unique IDs.
|
||||||
|
|
||||||
|
2. **Validation Layer**: Added comprehensive validation for:
|
||||||
|
- Equipment slot validation (weapon, off_hand, helmet, chest, gloves, boots, accessory_1, accessory_2)
|
||||||
|
- Level and class requirements
|
||||||
|
- Item type to slot mapping
|
||||||
|
|
||||||
|
3. **Consumable Effects**: Supports instant healing (HOT effects) and duration-based buffs for combat integration.
|
||||||
|
|
||||||
|
4. **Tests**: 51 unit tests covering all functionality.
|
||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -1329,12 +1471,30 @@ class ItemLoader:
|
|||||||
return self.items
|
return self.items
|
||||||
```
|
```
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
**Note on Generated Items:**
|
||||||
- Inventory service can add/remove items
|
|
||||||
- Equip/unequip works with validation
|
The inventory service must handle both static items (loaded by ID) and generated items
|
||||||
- Consumables can be used (healing, mana restore)
|
(stored as full objects). Generated items have unique IDs (`gen_<uuid>`) and cannot be
|
||||||
- Item loader caches all items from YAML
|
looked up from YAML - they must be stored/retrieved as complete Item objects.
|
||||||
- Character's equipped items tracked
|
|
||||||
|
```python
|
||||||
|
# For static items (consumables, quest items)
|
||||||
|
item = item_loader.get_item("health_potion_small")
|
||||||
|
|
||||||
|
# For generated items - store full object
|
||||||
|
generated_item = generator.generate_loot_drop(level, luck)
|
||||||
|
character.inventory.append(generated_item.to_dict()) # Store full item data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:** ✅ MET
|
||||||
|
- [x] Inventory service can add/remove items - `add_item()`, `remove_item()`, `drop_item()`
|
||||||
|
- [x] Equip/unequip works with validation - `equip_item()`, `unequip_item()` with slot/level/type checks
|
||||||
|
- [x] Consumables can be used (healing, mana restore) - `use_consumable()`, `use_consumable_in_combat()`
|
||||||
|
- [x] Character's equipped items tracked - via `get_equipped_items()`, `get_equipped_item()`
|
||||||
|
- [x] **Generated items stored as full objects (not just IDs)** - Character model uses `List[Item]`
|
||||||
|
- [x] Bulk operations - `add_items()`, `get_items_by_type()`, `get_equippable_items()`
|
||||||
|
|
||||||
|
**Tests:** `/api/tests/test_inventory_service.py` - 51 tests
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1579,6 +1739,40 @@ def get_effective_stats(self) -> Stats:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Future Work: Combat Loot Integration
|
||||||
|
|
||||||
|
**Status:** Planned for future phase
|
||||||
|
|
||||||
|
The ItemGenerator is ready for integration with combat loot drops. Future implementation will:
|
||||||
|
|
||||||
|
**1. Update Enemy Loot Tables** - Add procedural generation options:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Example enhanced enemy loot entry
|
||||||
|
loot_table:
|
||||||
|
- type: "static"
|
||||||
|
item_id: "health_potion_small"
|
||||||
|
drop_chance: 0.5
|
||||||
|
- type: "generated"
|
||||||
|
item_type: "weapon"
|
||||||
|
rarity_range: ["rare", "epic"]
|
||||||
|
drop_chance: 0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Integrate with CombatService._calculate_rewards()** - Use ItemGenerator for loot rolls
|
||||||
|
|
||||||
|
**3. Boss Guaranteed Drops** - Higher-tier enemies guarantee better rarity
|
||||||
|
|
||||||
|
**4. Luck Stat Integration** - Player luck affects all loot rolls
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Current enemy loot tables use `item_id` references (static items only)
|
||||||
|
- ItemGenerator provides `generate_loot_drop(character_level, luck_stat)` method
|
||||||
|
- Generated items must be stored as full objects (not IDs) in character inventory
|
||||||
|
- Consider adding `LootService` wrapper for consistent loot generation across all sources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Week 3: Combat UI
|
### Week 3: Combat UI
|
||||||
|
|
||||||
#### Task 3.1: Create Combat Template (1 day / 8 hours)
|
#### Task 3.1: Create Combat Template (1 day / 8 hours)
|
||||||
|
|||||||
Reference in New Issue
Block a user