643 lines
21 KiB
Python
643 lines
21 KiB
Python
"""
|
|
Shop Service - NPC shop inventory and transactions.
|
|
|
|
This service manages NPC shop inventories and handles purchase/sell transactions.
|
|
Shop data is loaded from YAML files defining available items and their prices.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Any, Tuple
|
|
import uuid
|
|
import yaml
|
|
|
|
import json
|
|
|
|
from app.models.items import Item
|
|
from app.models.character import Character
|
|
from app.models.enums import ItemRarity
|
|
from app.models.transaction import Transaction
|
|
from app.services.static_item_loader import get_static_item_loader, StaticItemLoader
|
|
from app.services.base_item_loader import get_base_item_loader, BaseItemLoader
|
|
from app.services.item_generator import get_item_generator, ItemGenerator
|
|
from app.services.database_service import get_database_service, DatabaseService
|
|
from app.utils.logging import get_logger
|
|
|
|
logger = get_logger(__file__)
|
|
|
|
|
|
class ShopNotFoundError(Exception):
|
|
"""Raised when a shop ID is not found."""
|
|
pass
|
|
|
|
|
|
class ItemNotInShopError(Exception):
|
|
"""Raised when an item is not available in the shop."""
|
|
pass
|
|
|
|
|
|
class InsufficientGoldError(Exception):
|
|
"""Raised when character doesn't have enough gold."""
|
|
pass
|
|
|
|
|
|
class ItemNotOwnedError(Exception):
|
|
"""Raised when trying to sell an item not in inventory."""
|
|
pass
|
|
|
|
|
|
class ShopService:
|
|
"""
|
|
Service for managing NPC shop operations.
|
|
|
|
Handles:
|
|
- Loading shop inventories from YAML
|
|
- Getting enriched inventory for UI (with can_afford flags)
|
|
- Processing purchases (validate gold, add item, deduct gold)
|
|
- Processing sales (validate ownership, remove item, add gold)
|
|
|
|
Shop items can reference:
|
|
- Static items (consumables, accessories) via StaticItemLoader
|
|
- Base item templates (weapons, armor) via ItemGenerator
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
data_dir: Optional[str] = None,
|
|
static_loader: Optional[StaticItemLoader] = None,
|
|
base_loader: Optional[BaseItemLoader] = None,
|
|
item_generator: Optional[ItemGenerator] = None,
|
|
database_service: Optional[DatabaseService] = None
|
|
):
|
|
"""
|
|
Initialize the shop service.
|
|
|
|
Args:
|
|
data_dir: Path to shop YAML files. Defaults to /app/data/shop/
|
|
static_loader: Optional custom StaticItemLoader instance
|
|
base_loader: Optional custom BaseItemLoader instance
|
|
item_generator: Optional custom ItemGenerator instance
|
|
database_service: Optional custom DatabaseService instance
|
|
"""
|
|
if data_dir is None:
|
|
current_file = Path(__file__)
|
|
app_dir = current_file.parent.parent
|
|
data_dir = str(app_dir / "data" / "shop")
|
|
|
|
self.data_dir = Path(data_dir)
|
|
self.static_loader = static_loader or get_static_item_loader()
|
|
self.base_loader = base_loader or get_base_item_loader()
|
|
self.item_generator = item_generator or get_item_generator()
|
|
self.database_service = database_service or get_database_service()
|
|
|
|
self._shop_cache: Dict[str, dict] = {}
|
|
self._loaded = False
|
|
|
|
logger.info("ShopService initialized", data_dir=str(self.data_dir))
|
|
|
|
def _save_transaction(self, transaction: Transaction) -> None:
|
|
"""
|
|
Persist a transaction to the database.
|
|
|
|
Args:
|
|
transaction: Transaction to save
|
|
"""
|
|
try:
|
|
# Convert transaction to database format
|
|
data = {
|
|
"transaction_id": transaction.transaction_id,
|
|
"transaction_type": transaction.transaction_type.value,
|
|
"character_id": transaction.character_id,
|
|
"session_id": transaction.session_id,
|
|
"amount": transaction.amount,
|
|
"description": transaction.description,
|
|
"timestamp": transaction.timestamp.isoformat(),
|
|
"metadata": json.dumps(transaction.metadata) if transaction.metadata else None,
|
|
}
|
|
|
|
self.database_service.create_row(
|
|
table_id="transactions",
|
|
data=data,
|
|
row_id=transaction.transaction_id
|
|
)
|
|
|
|
logger.info(
|
|
"Transaction saved to database",
|
|
transaction_id=transaction.transaction_id,
|
|
transaction_type=transaction.transaction_type.value
|
|
)
|
|
|
|
except Exception as e:
|
|
# Log error but don't fail the purchase/sale operation
|
|
logger.error(
|
|
"Failed to save transaction to database",
|
|
transaction_id=transaction.transaction_id,
|
|
error=str(e)
|
|
)
|
|
|
|
def _ensure_loaded(self) -> None:
|
|
"""Ensure shop data is loaded before any operation."""
|
|
if not self._loaded:
|
|
self._load_all_shops()
|
|
|
|
def _load_all_shops(self) -> None:
|
|
"""Load all shop YAML files from the data directory."""
|
|
if not self.data_dir.exists():
|
|
logger.warning("Shop data directory not found", path=str(self.data_dir))
|
|
self._loaded = True
|
|
return
|
|
|
|
for yaml_file in self.data_dir.glob("*.yaml"):
|
|
self._load_shop_file(yaml_file)
|
|
|
|
self._loaded = True
|
|
logger.info("Shops loaded", count=len(self._shop_cache))
|
|
|
|
def _load_shop_file(self, yaml_file: Path) -> None:
|
|
"""Load a single shop from YAML file."""
|
|
try:
|
|
with open(yaml_file, 'r') as f:
|
|
shop_data = yaml.safe_load(f)
|
|
|
|
if shop_data is None:
|
|
logger.warning("Empty shop YAML file", file=str(yaml_file))
|
|
return
|
|
|
|
shop_id = shop_data.get("shop_id")
|
|
if not shop_id:
|
|
logger.warning("Shop YAML missing shop_id", file=str(yaml_file))
|
|
return
|
|
|
|
self._shop_cache[shop_id] = shop_data
|
|
logger.debug("Shop loaded", shop_id=shop_id, file=str(yaml_file))
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to load shop file", file=str(yaml_file), error=str(e))
|
|
|
|
def get_shop(self, shop_id: str) -> dict:
|
|
"""
|
|
Get shop data by ID.
|
|
|
|
Args:
|
|
shop_id: Shop identifier
|
|
|
|
Returns:
|
|
Shop data dictionary
|
|
|
|
Raises:
|
|
ShopNotFoundError: If shop doesn't exist
|
|
"""
|
|
self._ensure_loaded()
|
|
|
|
shop = self._shop_cache.get(shop_id)
|
|
if not shop:
|
|
raise ShopNotFoundError(f"Shop '{shop_id}' not found")
|
|
|
|
return shop
|
|
|
|
def _resolve_item(self, item_id: str) -> Optional[Item]:
|
|
"""
|
|
Resolve an item_id to an Item instance.
|
|
|
|
Tries static items first (consumables, accessories), then base templates.
|
|
|
|
Args:
|
|
item_id: Item identifier from shop inventory
|
|
|
|
Returns:
|
|
Item instance or None if not found
|
|
"""
|
|
# Try static item first (consumables, accessories, materials)
|
|
if self.static_loader.has_item(item_id):
|
|
return self.static_loader.get_item(item_id)
|
|
|
|
# Try base template (weapons, armor, shields)
|
|
template = self.base_loader.get_template(item_id)
|
|
if template:
|
|
# Generate a common-rarity item from the template
|
|
return self.item_generator.generate_item(
|
|
item_type=template.item_type,
|
|
rarity=ItemRarity.COMMON,
|
|
character_level=1,
|
|
base_template_id=item_id
|
|
)
|
|
|
|
logger.warning("Could not resolve shop item", item_id=item_id)
|
|
return None
|
|
|
|
def get_shop_inventory_for_character(
|
|
self,
|
|
shop_id: str,
|
|
character: Character
|
|
) -> dict:
|
|
"""
|
|
Get enriched shop inventory with character context.
|
|
|
|
This is the main method for the UI - returns everything needed
|
|
in a single call to avoid multiple API round-trips.
|
|
|
|
Args:
|
|
shop_id: Shop identifier
|
|
character: Character instance (for gold/can_afford calculations)
|
|
|
|
Returns:
|
|
Dictionary containing:
|
|
- shop: Shop metadata (id, name, shopkeeper)
|
|
- character: Character context (id, gold)
|
|
- inventory: List of enriched items with prices and can_afford flags
|
|
- categories: List of unique item type categories
|
|
|
|
Raises:
|
|
ShopNotFoundError: If shop doesn't exist
|
|
"""
|
|
shop = self.get_shop(shop_id)
|
|
|
|
enriched_inventory = []
|
|
categories = set()
|
|
|
|
for shop_item in shop.get("inventory", []):
|
|
item_id = shop_item.get("item_id")
|
|
price = shop_item.get("price", 0)
|
|
stock = shop_item.get("stock", -1) # -1 = unlimited
|
|
|
|
# Resolve the item
|
|
item = self._resolve_item(item_id)
|
|
if not item:
|
|
logger.warning(
|
|
"Skipping unresolved shop item",
|
|
shop_id=shop_id,
|
|
item_id=item_id
|
|
)
|
|
continue
|
|
|
|
# Track category
|
|
categories.add(item.item_type.value)
|
|
|
|
# Build enriched item entry
|
|
enriched_inventory.append({
|
|
"item": item.to_dict(),
|
|
"shop_price": price,
|
|
"stock": stock,
|
|
"can_afford": character.can_afford(price),
|
|
"item_id": item_id, # Original template ID for purchase requests
|
|
})
|
|
|
|
return {
|
|
"shop": {
|
|
"shop_id": shop.get("shop_id"),
|
|
"shop_name": shop.get("shop_name"),
|
|
"shop_description": shop.get("shop_description", ""),
|
|
"shopkeeper_name": shop.get("shopkeeper_name"),
|
|
"sell_rate": shop.get("sell_rate", 0.5),
|
|
},
|
|
"character": {
|
|
"character_id": character.character_id,
|
|
"gold": character.gold,
|
|
},
|
|
"inventory": enriched_inventory,
|
|
"categories": sorted(list(categories)),
|
|
}
|
|
|
|
def get_item_price(self, shop_id: str, item_id: str) -> int:
|
|
"""
|
|
Get the price of an item in a shop.
|
|
|
|
Args:
|
|
shop_id: Shop identifier
|
|
item_id: Item identifier
|
|
|
|
Returns:
|
|
Price in gold
|
|
|
|
Raises:
|
|
ShopNotFoundError: If shop doesn't exist
|
|
ItemNotInShopError: If item is not in the shop
|
|
"""
|
|
shop = self.get_shop(shop_id)
|
|
|
|
for shop_item in shop.get("inventory", []):
|
|
if shop_item.get("item_id") == item_id:
|
|
return shop_item.get("price", 0)
|
|
|
|
raise ItemNotInShopError(f"Item '{item_id}' not available in shop '{shop_id}'")
|
|
|
|
def purchase_item(
|
|
self,
|
|
character: Character,
|
|
shop_id: str,
|
|
item_id: str,
|
|
quantity: int = 1,
|
|
session_id: Optional[str] = None
|
|
) -> dict:
|
|
"""
|
|
Purchase an item from the shop.
|
|
|
|
Args:
|
|
character: Character making the purchase
|
|
shop_id: Shop identifier
|
|
item_id: Item to purchase (template ID)
|
|
quantity: Number of items to buy
|
|
session_id: Optional session ID for transaction tracking
|
|
|
|
Returns:
|
|
Dictionary containing:
|
|
- purchase: Transaction details (item_id, quantity, total_cost)
|
|
- character: Updated character context (gold)
|
|
- items_added: List of item IDs added to inventory
|
|
|
|
Raises:
|
|
ShopNotFoundError: If shop doesn't exist
|
|
ItemNotInShopError: If item is not in the shop
|
|
InsufficientGoldError: If character can't afford the purchase
|
|
"""
|
|
# Get price
|
|
price_per_item = self.get_item_price(shop_id, item_id)
|
|
total_cost = price_per_item * quantity
|
|
|
|
# Validate gold
|
|
if not character.can_afford(total_cost):
|
|
raise InsufficientGoldError(
|
|
f"Not enough gold. Need {total_cost}, have {character.gold}"
|
|
)
|
|
|
|
# Deduct gold
|
|
character.remove_gold(total_cost)
|
|
|
|
# Add items to inventory
|
|
items_added = []
|
|
for _ in range(quantity):
|
|
item = self._resolve_item(item_id)
|
|
if item:
|
|
character.add_item(item)
|
|
items_added.append(item.item_id)
|
|
|
|
# Create transaction record for audit logging
|
|
transaction = Transaction.create_purchase(
|
|
transaction_id=str(uuid.uuid4()),
|
|
character_id=character.character_id,
|
|
shop_id=shop_id,
|
|
item_id=item_id,
|
|
quantity=quantity,
|
|
total_cost=total_cost,
|
|
session_id=session_id
|
|
)
|
|
|
|
# Save transaction to database
|
|
self._save_transaction(transaction)
|
|
|
|
logger.info(
|
|
"Purchase completed",
|
|
character_id=character.character_id,
|
|
shop_id=shop_id,
|
|
item_id=item_id,
|
|
quantity=quantity,
|
|
total_cost=total_cost,
|
|
gold_remaining=character.gold,
|
|
transaction_id=transaction.transaction_id
|
|
)
|
|
|
|
return {
|
|
"purchase": {
|
|
"item_id": item_id,
|
|
"quantity": quantity,
|
|
"total_cost": total_cost,
|
|
},
|
|
"character": {
|
|
"character_id": character.character_id,
|
|
"gold": character.gold,
|
|
},
|
|
"items_added": items_added,
|
|
"transaction": transaction.to_dict(),
|
|
}
|
|
|
|
def get_sell_price(self, shop_id: str, item: Item) -> int:
|
|
"""
|
|
Calculate the sell-back price for an item.
|
|
|
|
Uses the shop's sell_rate (default 50%) of the item's value.
|
|
|
|
Args:
|
|
shop_id: Shop identifier
|
|
item: Item to sell
|
|
|
|
Returns:
|
|
Sell price in gold
|
|
"""
|
|
shop = self.get_shop(shop_id)
|
|
sell_rate = shop.get("sell_rate", 0.5)
|
|
|
|
# Use item's value as base
|
|
return int(item.value * sell_rate)
|
|
|
|
def sell_item(
|
|
self,
|
|
character: Character,
|
|
shop_id: str,
|
|
item_instance_id: str,
|
|
quantity: int = 1,
|
|
session_id: Optional[str] = None
|
|
) -> dict:
|
|
"""
|
|
Sell an item back to the shop.
|
|
|
|
Args:
|
|
character: Character selling the item
|
|
shop_id: Shop identifier
|
|
item_instance_id: Unique item instance ID from character inventory
|
|
quantity: Number of items to sell (for stackable items, future use)
|
|
session_id: Optional session ID for transaction tracking
|
|
|
|
Returns:
|
|
Dictionary containing:
|
|
- sale: Transaction details (item_id, item_name, quantity, total_earned)
|
|
- character: Updated character context (gold)
|
|
|
|
Raises:
|
|
ShopNotFoundError: If shop doesn't exist
|
|
ItemNotOwnedError: If item is not in character inventory
|
|
"""
|
|
# Validate shop exists
|
|
self.get_shop(shop_id)
|
|
|
|
# Find item in character inventory
|
|
item = None
|
|
for inv_item in character.inventory:
|
|
if inv_item.item_id == item_instance_id:
|
|
item = inv_item
|
|
break
|
|
|
|
if not item:
|
|
raise ItemNotOwnedError(
|
|
f"Item '{item_instance_id}' not found in inventory"
|
|
)
|
|
|
|
# Calculate sell price
|
|
sell_price = self.get_sell_price(shop_id, item)
|
|
total_earned = sell_price * quantity
|
|
|
|
# Remove item from inventory and add gold
|
|
removed_item = character.remove_item(item_instance_id)
|
|
if removed_item:
|
|
character.add_gold(total_earned)
|
|
|
|
# Create transaction record for audit logging
|
|
transaction = Transaction.create_sale(
|
|
transaction_id=str(uuid.uuid4()),
|
|
character_id=character.character_id,
|
|
shop_id=shop_id,
|
|
item_id=item_instance_id,
|
|
item_name=item.get_display_name(),
|
|
quantity=quantity,
|
|
total_earned=total_earned,
|
|
session_id=session_id
|
|
)
|
|
|
|
# Save transaction to database
|
|
self._save_transaction(transaction)
|
|
|
|
logger.info(
|
|
"Sale completed",
|
|
character_id=character.character_id,
|
|
shop_id=shop_id,
|
|
item_id=item_instance_id,
|
|
item_name=item.name,
|
|
quantity=quantity,
|
|
total_earned=total_earned,
|
|
gold_remaining=character.gold,
|
|
transaction_id=transaction.transaction_id
|
|
)
|
|
|
|
return {
|
|
"sale": {
|
|
"item_id": item_instance_id,
|
|
"item_name": item.get_display_name(),
|
|
"quantity": quantity,
|
|
"total_earned": total_earned,
|
|
},
|
|
"character": {
|
|
"character_id": character.character_id,
|
|
"gold": character.gold,
|
|
},
|
|
"transaction": transaction.to_dict(),
|
|
}
|
|
|
|
raise ItemNotOwnedError(f"Failed to remove item '{item_instance_id}'")
|
|
|
|
def delete_transactions_by_character(self, character_id: str) -> int:
|
|
"""
|
|
Delete all transactions for a character.
|
|
|
|
Used during character deletion to clean up transaction records.
|
|
|
|
Args:
|
|
character_id: Character ID to delete transactions for
|
|
|
|
Returns:
|
|
Number of transactions deleted
|
|
"""
|
|
try:
|
|
logger.info("Deleting transactions for character", character_id=character_id)
|
|
|
|
# Query all transactions for this character
|
|
from appwrite.query import Query
|
|
documents = self.database_service.list_rows(
|
|
table_id="transactions",
|
|
queries=[Query.equal('character_id', character_id)]
|
|
)
|
|
|
|
if not documents:
|
|
logger.debug("No transactions found for character", character_id=character_id)
|
|
return 0
|
|
|
|
deleted_count = 0
|
|
for document in documents:
|
|
try:
|
|
self.database_service.delete_row(
|
|
table_id="transactions",
|
|
row_id=document['$id']
|
|
)
|
|
deleted_count += 1
|
|
except Exception as e:
|
|
logger.error("Failed to delete transaction",
|
|
transaction_id=document.get('transaction_id'),
|
|
error=str(e))
|
|
continue
|
|
|
|
logger.info("Transactions deleted for character",
|
|
character_id=character_id,
|
|
deleted_count=deleted_count)
|
|
return deleted_count
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to delete transactions for character",
|
|
character_id=character_id,
|
|
error=str(e))
|
|
return 0
|
|
|
|
def delete_transactions_by_session(self, session_id: str) -> int:
|
|
"""
|
|
Delete all transactions for a session.
|
|
|
|
Used during session deletion to clean up session-specific transaction records.
|
|
|
|
Args:
|
|
session_id: Session ID to delete transactions for
|
|
|
|
Returns:
|
|
Number of transactions deleted
|
|
"""
|
|
try:
|
|
logger.info("Deleting transactions for session", session_id=session_id)
|
|
|
|
# Query all transactions for this session
|
|
from appwrite.query import Query
|
|
documents = self.database_service.list_rows(
|
|
table_id="transactions",
|
|
queries=[Query.equal('session_id', session_id)]
|
|
)
|
|
|
|
if not documents:
|
|
logger.debug("No transactions found for session", session_id=session_id)
|
|
return 0
|
|
|
|
deleted_count = 0
|
|
for document in documents:
|
|
try:
|
|
self.database_service.delete_row(
|
|
table_id="transactions",
|
|
row_id=document['$id']
|
|
)
|
|
deleted_count += 1
|
|
except Exception as e:
|
|
logger.error("Failed to delete transaction",
|
|
transaction_id=document.get('transaction_id'),
|
|
error=str(e))
|
|
continue
|
|
|
|
logger.info("Transactions deleted for session",
|
|
session_id=session_id,
|
|
deleted_count=deleted_count)
|
|
return deleted_count
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to delete transactions for session",
|
|
session_id=session_id,
|
|
error=str(e))
|
|
return 0
|
|
|
|
|
|
# Global instance for convenience
|
|
_service_instance: Optional[ShopService] = None
|
|
|
|
|
|
def get_shop_service() -> ShopService:
|
|
"""
|
|
Get the global ShopService instance.
|
|
|
|
Returns:
|
|
Singleton ShopService instance
|
|
"""
|
|
global _service_instance
|
|
if _service_instance is None:
|
|
_service_instance = ShopService()
|
|
return _service_instance
|