352 lines
11 KiB
Python
352 lines
11 KiB
Python
"""
|
|
Item validation service for AI-granted items.
|
|
|
|
This module validates and resolves items that the AI grants to players during
|
|
gameplay, ensuring they meet character requirements and game balance rules.
|
|
"""
|
|
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import structlog
|
|
import yaml
|
|
|
|
from app.models.items import Item
|
|
from app.models.enums import ItemType
|
|
from app.models.character import Character
|
|
from app.ai.response_parser import ItemGrant
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
|
|
class ItemValidationError(Exception):
|
|
"""
|
|
Exception raised when an item fails validation.
|
|
|
|
Attributes:
|
|
message: Human-readable error message
|
|
item_grant: The ItemGrant that failed validation
|
|
reason: Machine-readable reason code
|
|
"""
|
|
|
|
def __init__(self, message: str, item_grant: ItemGrant, reason: str):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.item_grant = item_grant
|
|
self.reason = reason
|
|
|
|
|
|
class ItemValidator:
|
|
"""
|
|
Validates and resolves items granted by the AI.
|
|
|
|
This service:
|
|
1. Resolves item references (by ID or creates generic items)
|
|
2. Validates items against character requirements
|
|
3. Logs validation failures for review
|
|
"""
|
|
|
|
# Map of generic item type strings to ItemType enums
|
|
TYPE_MAP = {
|
|
"weapon": ItemType.WEAPON,
|
|
"armor": ItemType.ARMOR,
|
|
"consumable": ItemType.CONSUMABLE,
|
|
"quest_item": ItemType.QUEST_ITEM,
|
|
}
|
|
|
|
def __init__(self, data_path: Optional[Path] = None):
|
|
"""
|
|
Initialize the item validator.
|
|
|
|
Args:
|
|
data_path: Path to game data directory. Defaults to app/data/
|
|
"""
|
|
if data_path is None:
|
|
# Default to api/app/data/
|
|
data_path = Path(__file__).parent.parent / "data"
|
|
|
|
self.data_path = data_path
|
|
self._item_registry: dict[str, dict] = {}
|
|
self._generic_templates: dict[str, dict] = {}
|
|
self._load_data()
|
|
|
|
logger.info(
|
|
"ItemValidator initialized",
|
|
items_loaded=len(self._item_registry),
|
|
generic_templates_loaded=len(self._generic_templates)
|
|
)
|
|
|
|
def _load_data(self) -> None:
|
|
"""Load item data from YAML files."""
|
|
# Load main item registry if it exists
|
|
items_file = self.data_path / "items.yaml"
|
|
if items_file.exists():
|
|
with open(items_file) as f:
|
|
data = yaml.safe_load(f) or {}
|
|
self._item_registry = data.get("items", {})
|
|
|
|
# Load generic item templates
|
|
generic_file = self.data_path / "generic_items.yaml"
|
|
if generic_file.exists():
|
|
with open(generic_file) as f:
|
|
data = yaml.safe_load(f) or {}
|
|
self._generic_templates = data.get("templates", {})
|
|
|
|
def resolve_item(self, item_grant: ItemGrant) -> Item:
|
|
"""
|
|
Resolve an ItemGrant to an actual Item instance.
|
|
|
|
For existing items (by item_id), looks up from item registry.
|
|
For generic items (by name/type), creates a new Item.
|
|
|
|
Args:
|
|
item_grant: The ItemGrant from AI response
|
|
|
|
Returns:
|
|
Resolved Item instance
|
|
|
|
Raises:
|
|
ItemValidationError: If item cannot be resolved
|
|
"""
|
|
if item_grant.is_existing_item():
|
|
return self._resolve_existing_item(item_grant)
|
|
elif item_grant.is_generic_item():
|
|
return self._create_generic_item(item_grant)
|
|
else:
|
|
raise ItemValidationError(
|
|
"ItemGrant has neither item_id nor name",
|
|
item_grant,
|
|
"INVALID_ITEM_GRANT"
|
|
)
|
|
|
|
def _resolve_existing_item(self, item_grant: ItemGrant) -> Item:
|
|
"""
|
|
Look up an existing item by ID.
|
|
|
|
Args:
|
|
item_grant: ItemGrant with item_id set
|
|
|
|
Returns:
|
|
Item instance from registry
|
|
|
|
Raises:
|
|
ItemValidationError: If item not found
|
|
"""
|
|
item_id = item_grant.item_id
|
|
|
|
if item_id not in self._item_registry:
|
|
logger.warning(
|
|
"Item not found in registry",
|
|
item_id=item_id
|
|
)
|
|
raise ItemValidationError(
|
|
f"Unknown item_id: {item_id}",
|
|
item_grant,
|
|
"ITEM_NOT_FOUND"
|
|
)
|
|
|
|
item_data = self._item_registry[item_id]
|
|
|
|
# Convert to Item instance
|
|
return Item.from_dict({
|
|
"item_id": item_id,
|
|
**item_data
|
|
})
|
|
|
|
def _create_generic_item(self, item_grant: ItemGrant) -> Item:
|
|
"""
|
|
Create a generic item from AI-provided details.
|
|
|
|
Generic items are simple items with no special stats,
|
|
suitable for mundane objects like torches, food, etc.
|
|
|
|
Args:
|
|
item_grant: ItemGrant with name, type, description
|
|
|
|
Returns:
|
|
New Item instance
|
|
|
|
Raises:
|
|
ItemValidationError: If item type is invalid
|
|
"""
|
|
# Validate item type
|
|
item_type_str = (item_grant.item_type or "consumable").lower()
|
|
if item_type_str not in self.TYPE_MAP:
|
|
logger.warning(
|
|
"Invalid item type from AI",
|
|
item_type=item_type_str,
|
|
item_name=item_grant.name
|
|
)
|
|
# Default to consumable for unknown types
|
|
item_type_str = "consumable"
|
|
|
|
item_type = self.TYPE_MAP[item_type_str]
|
|
|
|
# Generate unique ID for this item instance
|
|
item_id = f"generic_{uuid.uuid4().hex[:8]}"
|
|
|
|
# Check if we have a template for this item name
|
|
template = self._find_template(item_grant.name or "")
|
|
|
|
if template:
|
|
# Use template values as defaults
|
|
return Item(
|
|
item_id=item_id,
|
|
name=item_grant.name or template.get("name", "Unknown Item"),
|
|
item_type=item_type,
|
|
description=item_grant.description or template.get("description", ""),
|
|
value=item_grant.value or template.get("value", 0),
|
|
is_tradeable=template.get("is_tradeable", True),
|
|
required_level=template.get("required_level", 1),
|
|
)
|
|
else:
|
|
# Create with provided values only
|
|
return Item(
|
|
item_id=item_id,
|
|
name=item_grant.name or "Unknown Item",
|
|
item_type=item_type,
|
|
description=item_grant.description or "A simple item.",
|
|
value=item_grant.value,
|
|
is_tradeable=True,
|
|
required_level=1,
|
|
)
|
|
|
|
def _find_template(self, item_name: str) -> Optional[dict]:
|
|
"""
|
|
Find a generic item template by name.
|
|
|
|
Uses case-insensitive partial matching.
|
|
|
|
Args:
|
|
item_name: Name of the item to find
|
|
|
|
Returns:
|
|
Template dict or None if not found
|
|
"""
|
|
name_lower = item_name.lower()
|
|
|
|
# Exact match first
|
|
if name_lower in self._generic_templates:
|
|
return self._generic_templates[name_lower]
|
|
|
|
# Partial match
|
|
for template_name, template in self._generic_templates.items():
|
|
if template_name in name_lower or name_lower in template_name:
|
|
return template
|
|
|
|
return None
|
|
|
|
def validate_item_for_character(
|
|
self,
|
|
item: Item,
|
|
character: Character
|
|
) -> tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate that a character can receive an item.
|
|
|
|
Checks:
|
|
- Level requirements
|
|
- Class restrictions
|
|
|
|
Args:
|
|
item: The Item to validate
|
|
character: The Character to receive the item
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
# Check level requirement
|
|
if item.required_level > character.level:
|
|
error_msg = (
|
|
f"Item '{item.name}' requires level {item.required_level}, "
|
|
f"but character is level {character.level}"
|
|
)
|
|
logger.warning(
|
|
"Item validation failed: level requirement",
|
|
item_name=item.name,
|
|
required_level=item.required_level,
|
|
character_level=character.level,
|
|
character_name=character.name
|
|
)
|
|
return False, error_msg
|
|
|
|
# Check class restriction
|
|
if item.required_class:
|
|
character_class = character.player_class.class_id
|
|
if item.required_class.lower() != character_class.lower():
|
|
error_msg = (
|
|
f"Item '{item.name}' requires class {item.required_class}, "
|
|
f"but character is {character_class}"
|
|
)
|
|
logger.warning(
|
|
"Item validation failed: class restriction",
|
|
item_name=item.name,
|
|
required_class=item.required_class,
|
|
character_class=character_class,
|
|
character_name=character.name
|
|
)
|
|
return False, error_msg
|
|
|
|
return True, None
|
|
|
|
def validate_and_resolve_item(
|
|
self,
|
|
item_grant: ItemGrant,
|
|
character: Character
|
|
) -> tuple[Optional[Item], Optional[str]]:
|
|
"""
|
|
Resolve an item grant and validate it for a character.
|
|
|
|
This is the main entry point for processing AI-granted items.
|
|
|
|
Args:
|
|
item_grant: The ItemGrant from AI response
|
|
character: The Character to receive the item
|
|
|
|
Returns:
|
|
Tuple of (Item if valid else None, error_message if invalid else None)
|
|
"""
|
|
try:
|
|
# Resolve the item
|
|
item = self.resolve_item(item_grant)
|
|
|
|
# Validate for character
|
|
is_valid, error_msg = self.validate_item_for_character(item, character)
|
|
|
|
if not is_valid:
|
|
return None, error_msg
|
|
|
|
logger.info(
|
|
"Item validated successfully",
|
|
item_name=item.name,
|
|
item_id=item.item_id,
|
|
character_name=character.name
|
|
)
|
|
return item, None
|
|
|
|
except ItemValidationError as e:
|
|
logger.warning(
|
|
"Item resolution failed",
|
|
error=e.message,
|
|
reason=e.reason
|
|
)
|
|
return None, e.message
|
|
|
|
|
|
# Global instance for convenience
|
|
_validator_instance: Optional[ItemValidator] = None
|
|
|
|
|
|
def get_item_validator() -> ItemValidator:
|
|
"""
|
|
Get or create the global ItemValidator instance.
|
|
|
|
Returns:
|
|
ItemValidator singleton instance
|
|
"""
|
|
global _validator_instance
|
|
if _validator_instance is None:
|
|
_validator_instance = ItemValidator()
|
|
return _validator_instance
|