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