""" 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