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:
2025-11-26 18:38:39 -06:00
parent 185be7fee0
commit 76f67c4a22
5 changed files with 2207 additions and 79 deletions

View 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

View File

@@ -708,6 +708,149 @@ success = service.soft_delete_message(
- **Consumable:** One-time use (potions, scrolls)
- **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
Abilities represent actions that can be taken in combat (attacks, spells, skills, item uses).

View File

@@ -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 Types

View 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