NPC shop implimented

This commit is contained in:
2025-11-29 01:16:46 -06:00
parent 32af625d14
commit 8bd494a52f
17 changed files with 4265 additions and 17 deletions

View File

@@ -354,8 +354,8 @@ class CharacterService:
"""
Permanently delete a character from the database.
Also cleans up any game sessions associated with the character
to prevent orphaned sessions.
Also cleans up any game sessions, shop transactions, and other
associated data to prevent orphaned records.
Args:
character_id: Character ID
@@ -375,6 +375,15 @@ class CharacterService:
if not character:
raise CharacterNotFound(f"Character not found: {character_id}")
# Clean up shop transactions for this character
from app.services.shop_service import get_shop_service
shop_service = get_shop_service()
deleted_transactions = shop_service.delete_transactions_by_character(character_id)
if deleted_transactions > 0:
logger.info("Cleaned up transactions for deleted character",
character_id=character_id,
transactions_deleted=deleted_transactions)
# Clean up associated sessions before deleting the character
# Local import to avoid circular dependency (session_service imports character_service)
from app.services.session_service import get_session_service

View File

@@ -124,6 +124,15 @@ class DatabaseInitService:
logger.error("Failed to initialize combat_rounds table", error=str(e))
results['combat_rounds'] = False
# Initialize transactions table
try:
self.init_transactions_table()
results['transactions'] = True
logger.info("Transactions table initialized successfully")
except Exception as e:
logger.error("Failed to initialize transactions table", error=str(e))
results['transactions'] = False
success_count = sum(1 for v in results.values() if v)
total_count = len(results)
@@ -1084,6 +1093,174 @@ class DatabaseInitService:
code=e.code)
raise
def init_transactions_table(self) -> bool:
"""
Initialize the transactions table for tracking gold transactions.
Table schema:
- transaction_id (string, required): Unique transaction identifier (UUID)
- transaction_type (string, required): Type (shop_purchase, shop_sale, quest_reward, etc.)
- character_id (string, required): Character involved in transaction
- session_id (string, optional): Game session where transaction occurred
- amount (integer, required): Gold amount (negative=expense, positive=income)
- description (string, required): Human-readable description
- timestamp (string, required): ISO timestamp when transaction occurred
- metadata (string, optional): JSON metadata for additional context
Indexes:
- idx_character_id: Character-based lookups
- idx_session_id: Session-based lookups (for cleanup on session delete)
- idx_character_id_timestamp: Character transaction history
- idx_transaction_type: Filter by type
- idx_timestamp: Date range queries
Returns:
True if successful
Raises:
AppwriteException: If table creation fails
"""
table_id = 'transactions'
logger.info("Initializing transactions table", table_id=table_id)
try:
# Check if table already exists
try:
self.tables_db.get_table(
database_id=self.database_id,
table_id=table_id
)
logger.info("Transactions table already exists", table_id=table_id)
return True
except AppwriteException as e:
if e.code != 404:
raise
logger.info("Transactions table does not exist, creating...")
# Create table
logger.info("Creating transactions table")
table = self.tables_db.create_table(
database_id=self.database_id,
table_id=table_id,
name='Transactions'
)
logger.info("Transactions table created", table_id=table['$id'])
# Create columns
self._create_column(
table_id=table_id,
column_id='transaction_id',
column_type='string',
size=36, # UUID length
required=True
)
self._create_column(
table_id=table_id,
column_id='transaction_type',
column_type='string',
size=50, # TransactionType enum values
required=True
)
self._create_column(
table_id=table_id,
column_id='character_id',
column_type='string',
size=100,
required=True
)
self._create_column(
table_id=table_id,
column_id='session_id',
column_type='string',
size=100,
required=False # Optional - some transactions may not have a session
)
self._create_column(
table_id=table_id,
column_id='amount',
column_type='integer',
required=True
)
self._create_column(
table_id=table_id,
column_id='description',
column_type='string',
size=500,
required=True
)
self._create_column(
table_id=table_id,
column_id='timestamp',
column_type='string',
size=50, # ISO timestamp format
required=True
)
self._create_column(
table_id=table_id,
column_id='metadata',
column_type='string',
size=2000, # JSON metadata
required=False
)
# Wait for columns to fully propagate
logger.info("Waiting for columns to propagate before creating indexes...")
time.sleep(2)
# Create indexes for efficient querying
self._create_index(
table_id=table_id,
index_id='idx_character_id',
index_type='key',
attributes=['character_id']
)
self._create_index(
table_id=table_id,
index_id='idx_session_id',
index_type='key',
attributes=['session_id']
)
self._create_index(
table_id=table_id,
index_id='idx_character_id_timestamp',
index_type='key',
attributes=['character_id', 'timestamp']
)
self._create_index(
table_id=table_id,
index_id='idx_transaction_type',
index_type='key',
attributes=['transaction_type']
)
self._create_index(
table_id=table_id,
index_id='idx_timestamp',
index_type='key',
attributes=['timestamp']
)
logger.info("Transactions table initialized successfully", table_id=table_id)
return True
except AppwriteException as e:
logger.error("Failed to initialize transactions table",
table_id=table_id,
error=str(e),
code=e.code)
raise
def _create_column(
self,
table_id: str,

View File

@@ -421,7 +421,7 @@ class SessionService:
from the database. Use this when the user wants to free up their session
slot and doesn't need to preserve the game history.
Also deletes all chat messages associated with this session.
Also deletes all chat messages and shop transactions associated with this session.
Args:
session_id: Session ID to delete
@@ -439,7 +439,15 @@ class SessionService:
# Verify ownership first (raises SessionNotFound if invalid)
self.get_session(session_id, user_id)
# Delete associated chat messages first
# Delete associated shop transactions first
from app.services.shop_service import get_shop_service
shop_service = get_shop_service()
deleted_transactions = shop_service.delete_transactions_by_session(session_id)
logger.info("Deleted associated transactions",
session_id=session_id,
transaction_count=deleted_transactions)
# Delete associated chat messages
chat_service = get_chat_message_service()
deleted_messages = chat_service.delete_messages_by_session(session_id)
logger.info("Deleted associated chat messages",

View File

@@ -0,0 +1,642 @@
"""
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