Merge branch 'feat/Phase4-NPC-Shop' into dev
This commit is contained in:
@@ -179,7 +179,11 @@ def register_blueprints(app: Flask) -> None:
|
|||||||
app.register_blueprint(inventory_bp)
|
app.register_blueprint(inventory_bp)
|
||||||
logger.info("Inventory API blueprint registered")
|
logger.info("Inventory API blueprint registered")
|
||||||
|
|
||||||
|
# Import and register Shop API blueprint
|
||||||
|
from app.api.shop import shop_bp
|
||||||
|
app.register_blueprint(shop_bp)
|
||||||
|
logger.info("Shop API blueprint registered")
|
||||||
|
|
||||||
# TODO: Register additional blueprints as they are created
|
# TODO: Register additional blueprints as they are created
|
||||||
# from app.api import marketplace, shop
|
# from app.api import marketplace
|
||||||
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')
|
||||||
# app.register_blueprint(shop.bp, url_prefix='/api/v1/shop')
|
|
||||||
|
|||||||
474
api/app/api/shop.py
Normal file
474
api/app/api/shop.py
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
"""
|
||||||
|
Shop API Blueprint
|
||||||
|
|
||||||
|
Endpoints for browsing NPC shop inventory and making purchases/sales.
|
||||||
|
All endpoints require authentication.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /api/v1/shop/<shop_id>/inventory - Get shop inventory with character context
|
||||||
|
- POST /api/v1/shop/<shop_id>/purchase - Purchase an item
|
||||||
|
- POST /api/v1/shop/<shop_id>/sell - Sell an item back to the shop
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
|
||||||
|
from app.services.shop_service import (
|
||||||
|
get_shop_service,
|
||||||
|
ShopNotFoundError,
|
||||||
|
ItemNotInShopError,
|
||||||
|
InsufficientGoldError,
|
||||||
|
ItemNotOwnedError,
|
||||||
|
)
|
||||||
|
from app.services.character_service import (
|
||||||
|
get_character_service,
|
||||||
|
CharacterNotFound,
|
||||||
|
)
|
||||||
|
from app.utils.response import (
|
||||||
|
success_response,
|
||||||
|
error_response,
|
||||||
|
not_found_response,
|
||||||
|
validation_error_response,
|
||||||
|
)
|
||||||
|
from app.utils.auth import require_auth, get_current_user
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
shop_bp = Blueprint('shop', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@shop_bp.route('/api/v1/shop/<shop_id>/inventory', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_shop_inventory(shop_id: str):
|
||||||
|
"""
|
||||||
|
Get shop inventory with character context.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
character_id: Character ID to use for gold/affordability checks
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shop_id: Shop identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Shop inventory with enriched item data
|
||||||
|
401: Not authenticated
|
||||||
|
404: Shop or character not found
|
||||||
|
422: Validation error (missing character_id)
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"shop": {
|
||||||
|
"shop_id": "general_store",
|
||||||
|
"shop_name": "General Store",
|
||||||
|
"shopkeeper_name": "Merchant Guildmaster",
|
||||||
|
"sell_rate": 0.5
|
||||||
|
},
|
||||||
|
"character": {
|
||||||
|
"character_id": "char_abc",
|
||||||
|
"gold": 500
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"item": {...},
|
||||||
|
"shop_price": 25,
|
||||||
|
"stock": -1,
|
||||||
|
"can_afford": true,
|
||||||
|
"item_id": "health_potion_small"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["consumable", "weapon", "armor"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get character_id from query params
|
||||||
|
character_id = request.args.get('character_id', '').strip()
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Validation failed",
|
||||||
|
details={"character_id": "character_id query parameter is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Getting shop inventory",
|
||||||
|
user_id=user.id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
character_id=character_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Get enriched shop inventory
|
||||||
|
shop_service = get_shop_service()
|
||||||
|
result = shop_service.get_shop_inventory_for_character(shop_id, character)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Shop inventory retrieved",
|
||||||
|
user_id=user.id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
item_count=len(result["inventory"])
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(result=result)
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning(
|
||||||
|
"Character not found for shop",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ShopNotFoundError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Shop not found",
|
||||||
|
shop_id=shop_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to get shop inventory",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
shop_id=shop_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return error_response(
|
||||||
|
code="SHOP_INVENTORY_ERROR",
|
||||||
|
message="Failed to retrieve shop inventory",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shop_bp.route('/api/v1/shop/<shop_id>/purchase', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def purchase_item(shop_id: str):
|
||||||
|
"""
|
||||||
|
Purchase an item from the shop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shop_id: Shop identifier
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"character_id": "char_abc",
|
||||||
|
"item_id": "health_potion_small",
|
||||||
|
"quantity": 1,
|
||||||
|
"session_id": "optional_session_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Purchase successful
|
||||||
|
400: Insufficient gold or invalid quantity
|
||||||
|
401: Not authenticated
|
||||||
|
404: Shop, character, or item not found
|
||||||
|
422: Validation error
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"purchase": {
|
||||||
|
"item_id": "health_potion_small",
|
||||||
|
"quantity": 2,
|
||||||
|
"total_cost": 50
|
||||||
|
},
|
||||||
|
"character": {
|
||||||
|
"character_id": "char_abc",
|
||||||
|
"gold": 450
|
||||||
|
},
|
||||||
|
"items_added": ["health_potion_small_abc123", "health_potion_small_def456"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get request data
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Request body is required",
|
||||||
|
details={"error": "Request body is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
character_id = data.get('character_id', '').strip()
|
||||||
|
item_id = data.get('item_id', '').strip()
|
||||||
|
quantity = data.get('quantity', 1)
|
||||||
|
session_id = data.get('session_id', '').strip() or None
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
validation_errors = {}
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
validation_errors['character_id'] = "character_id is required"
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
validation_errors['item_id'] = "item_id is required"
|
||||||
|
|
||||||
|
if not isinstance(quantity, int) or quantity < 1:
|
||||||
|
validation_errors['quantity'] = "quantity must be a positive integer"
|
||||||
|
|
||||||
|
if validation_errors:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Validation failed",
|
||||||
|
details=validation_errors
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Processing purchase",
|
||||||
|
user_id=user.id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Process purchase
|
||||||
|
shop_service = get_shop_service()
|
||||||
|
result = shop_service.purchase_item(
|
||||||
|
character=character,
|
||||||
|
shop_id=shop_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=quantity,
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save updated character
|
||||||
|
char_service.update_character(character)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Purchase completed",
|
||||||
|
user_id=user.id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=quantity,
|
||||||
|
total_cost=result["purchase"]["total_cost"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(result=result)
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning(
|
||||||
|
"Character not found for purchase",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ShopNotFoundError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Shop not found for purchase",
|
||||||
|
shop_id=shop_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ItemNotInShopError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Item not in shop",
|
||||||
|
shop_id=shop_id,
|
||||||
|
item_id=item_id if 'item_id' in locals() else 'unknown',
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except InsufficientGoldError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Insufficient gold for purchase",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return error_response(
|
||||||
|
code="INSUFFICIENT_GOLD",
|
||||||
|
message=str(e),
|
||||||
|
status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to process purchase",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
shop_id=shop_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return error_response(
|
||||||
|
code="PURCHASE_ERROR",
|
||||||
|
message="Failed to process purchase",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shop_bp.route('/api/v1/shop/<shop_id>/sell', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def sell_item(shop_id: str):
|
||||||
|
"""
|
||||||
|
Sell an item back to the shop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shop_id: Shop identifier
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
{
|
||||||
|
"character_id": "char_abc",
|
||||||
|
"item_instance_id": "health_potion_small_abc123",
|
||||||
|
"quantity": 1,
|
||||||
|
"session_id": "optional_session_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Sale successful
|
||||||
|
401: Not authenticated
|
||||||
|
404: Shop, character, or item not found
|
||||||
|
422: Validation error
|
||||||
|
500: Internal server error
|
||||||
|
|
||||||
|
Example Response:
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"sale": {
|
||||||
|
"item_id": "health_potion_small_abc123",
|
||||||
|
"item_name": "Small Health Potion",
|
||||||
|
"quantity": 1,
|
||||||
|
"total_earned": 12
|
||||||
|
},
|
||||||
|
"character": {
|
||||||
|
"character_id": "char_abc",
|
||||||
|
"gold": 512
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get request data
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Request body is required",
|
||||||
|
details={"error": "Request body is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
character_id = data.get('character_id', '').strip()
|
||||||
|
item_instance_id = data.get('item_instance_id', '').strip()
|
||||||
|
quantity = data.get('quantity', 1)
|
||||||
|
session_id = data.get('session_id', '').strip() or None
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
validation_errors = {}
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
validation_errors['character_id'] = "character_id is required"
|
||||||
|
|
||||||
|
if not item_instance_id:
|
||||||
|
validation_errors['item_instance_id'] = "item_instance_id is required"
|
||||||
|
|
||||||
|
if not isinstance(quantity, int) or quantity < 1:
|
||||||
|
validation_errors['quantity'] = "quantity must be a positive integer"
|
||||||
|
|
||||||
|
if validation_errors:
|
||||||
|
return validation_error_response(
|
||||||
|
message="Validation failed",
|
||||||
|
details=validation_errors
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Processing sale",
|
||||||
|
user_id=user.id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_instance_id=item_instance_id,
|
||||||
|
quantity=quantity
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get character (validates ownership)
|
||||||
|
char_service = get_character_service()
|
||||||
|
character = char_service.get_character(character_id, user.id)
|
||||||
|
|
||||||
|
# Process sale
|
||||||
|
shop_service = get_shop_service()
|
||||||
|
result = shop_service.sell_item(
|
||||||
|
character=character,
|
||||||
|
shop_id=shop_id,
|
||||||
|
item_instance_id=item_instance_id,
|
||||||
|
quantity=quantity,
|
||||||
|
session_id=session_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save updated character
|
||||||
|
char_service.update_character(character)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Sale completed",
|
||||||
|
user_id=user.id,
|
||||||
|
shop_id=shop_id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_instance_id=item_instance_id,
|
||||||
|
total_earned=result["sale"]["total_earned"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return success_response(result=result)
|
||||||
|
|
||||||
|
except CharacterNotFound as e:
|
||||||
|
logger.warning(
|
||||||
|
"Character not found for sale",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ShopNotFoundError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Shop not found for sale",
|
||||||
|
shop_id=shop_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except ItemNotOwnedError as e:
|
||||||
|
logger.warning(
|
||||||
|
"Item not owned for sale",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
character_id=character_id if 'character_id' in locals() else 'unknown',
|
||||||
|
item_instance_id=item_instance_id if 'item_instance_id' in locals() else 'unknown',
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return not_found_response(message=str(e))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Failed to process sale",
|
||||||
|
user_id=user.id if 'user' in locals() else 'unknown',
|
||||||
|
shop_id=shop_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
return error_response(
|
||||||
|
code="SALE_ERROR",
|
||||||
|
message="Failed to process sale",
|
||||||
|
status=500
|
||||||
|
)
|
||||||
246
api/app/data/base_items/shields.yaml
Normal file
246
api/app/data/base_items/shields.yaml
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# Base Shield Templates for Procedural Generation
|
||||||
|
#
|
||||||
|
# These templates define the foundation that affixes attach to.
|
||||||
|
# Example: "Wooden Shield" + "Reinforced" prefix = "Reinforced Wooden Shield"
|
||||||
|
#
|
||||||
|
# Shield categories:
|
||||||
|
# - Light Shields: Low defense, high mobility (bucklers)
|
||||||
|
# - Medium Shields: Balanced defense/weight (round shields, kite shields)
|
||||||
|
# - Heavy Shields: High defense, reduced mobility (tower shields)
|
||||||
|
# - Magical Shields: Low physical defense, high resistance (arcane aegis)
|
||||||
|
#
|
||||||
|
# Template Structure:
|
||||||
|
# template_id: Unique identifier
|
||||||
|
# name: Base item name
|
||||||
|
# item_type: "armor" (shields use armor type)
|
||||||
|
# slot: "off_hand" (shields occupy off-hand slot)
|
||||||
|
# description: Flavor text
|
||||||
|
# base_defense: Physical damage reduction
|
||||||
|
# base_resistance: Magical damage reduction
|
||||||
|
# base_value: Gold value
|
||||||
|
# required_level: Min level to use/drop
|
||||||
|
# drop_weight: Higher = more common (1.0 = standard)
|
||||||
|
# min_rarity: Minimum rarity for this template
|
||||||
|
# block_chance: Chance to fully block an attack (optional)
|
||||||
|
|
||||||
|
shields:
|
||||||
|
# ==================== LIGHT SHIELDS (HIGH MOBILITY) ====================
|
||||||
|
buckler:
|
||||||
|
template_id: "buckler"
|
||||||
|
name: "Buckler"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A small, round shield strapped to the forearm. Offers minimal protection but doesn't hinder movement."
|
||||||
|
base_defense: 3
|
||||||
|
base_resistance: 0
|
||||||
|
base_value: 20
|
||||||
|
required_level: 1
|
||||||
|
drop_weight: 1.5
|
||||||
|
block_chance: 0.05
|
||||||
|
|
||||||
|
parrying_buckler:
|
||||||
|
template_id: "parrying_buckler"
|
||||||
|
name: "Parrying Buckler"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A lightweight buckler with a reinforced edge, designed for deflecting blows."
|
||||||
|
base_defense: 4
|
||||||
|
base_resistance: 0
|
||||||
|
base_value: 45
|
||||||
|
required_level: 3
|
||||||
|
drop_weight: 1.0
|
||||||
|
block_chance: 0.08
|
||||||
|
|
||||||
|
# ==================== MEDIUM SHIELDS (BALANCED) ====================
|
||||||
|
wooden_shield:
|
||||||
|
template_id: "wooden_shield"
|
||||||
|
name: "Wooden Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A basic shield carved from sturdy oak. Reliable protection for new adventurers."
|
||||||
|
base_defense: 5
|
||||||
|
base_resistance: 1
|
||||||
|
base_value: 35
|
||||||
|
required_level: 1
|
||||||
|
drop_weight: 1.3
|
||||||
|
|
||||||
|
round_shield:
|
||||||
|
template_id: "round_shield"
|
||||||
|
name: "Round Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A circular shield with an iron boss. Popular among infantry and mercenaries."
|
||||||
|
base_defense: 7
|
||||||
|
base_resistance: 1
|
||||||
|
base_value: 50
|
||||||
|
required_level: 2
|
||||||
|
drop_weight: 1.2
|
||||||
|
|
||||||
|
iron_shield:
|
||||||
|
template_id: "iron_shield"
|
||||||
|
name: "Iron Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A solid iron shield. Heavy but dependable."
|
||||||
|
base_defense: 9
|
||||||
|
base_resistance: 1
|
||||||
|
base_value: 70
|
||||||
|
required_level: 3
|
||||||
|
drop_weight: 1.0
|
||||||
|
|
||||||
|
kite_shield:
|
||||||
|
template_id: "kite_shield"
|
||||||
|
name: "Kite Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A tall, tapered shield that protects the entire body. Favored by knights."
|
||||||
|
base_defense: 10
|
||||||
|
base_resistance: 2
|
||||||
|
base_value: 80
|
||||||
|
required_level: 3
|
||||||
|
drop_weight: 1.0
|
||||||
|
|
||||||
|
heater_shield:
|
||||||
|
template_id: "heater_shield"
|
||||||
|
name: "Heater Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A classic triangular shield with excellent balance of protection and mobility."
|
||||||
|
base_defense: 12
|
||||||
|
base_resistance: 2
|
||||||
|
base_value: 100
|
||||||
|
required_level: 4
|
||||||
|
drop_weight: 0.9
|
||||||
|
|
||||||
|
steel_shield:
|
||||||
|
template_id: "steel_shield"
|
||||||
|
name: "Steel Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A finely crafted steel shield. Durable and well-balanced."
|
||||||
|
base_defense: 14
|
||||||
|
base_resistance: 2
|
||||||
|
base_value: 125
|
||||||
|
required_level: 5
|
||||||
|
drop_weight: 0.8
|
||||||
|
min_rarity: "uncommon"
|
||||||
|
|
||||||
|
# ==================== HEAVY SHIELDS (MAXIMUM DEFENSE) ====================
|
||||||
|
tower_shield:
|
||||||
|
template_id: "tower_shield"
|
||||||
|
name: "Tower Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A massive shield that covers the entire body. Extremely heavy but offers unparalleled protection."
|
||||||
|
base_defense: 18
|
||||||
|
base_resistance: 3
|
||||||
|
base_value: 175
|
||||||
|
required_level: 6
|
||||||
|
drop_weight: 0.6
|
||||||
|
min_rarity: "uncommon"
|
||||||
|
block_chance: 0.12
|
||||||
|
|
||||||
|
fortified_tower_shield:
|
||||||
|
template_id: "fortified_tower_shield"
|
||||||
|
name: "Fortified Tower Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A reinforced tower shield with iron plating. A mobile wall on the battlefield."
|
||||||
|
base_defense: 22
|
||||||
|
base_resistance: 4
|
||||||
|
base_value: 250
|
||||||
|
required_level: 7
|
||||||
|
drop_weight: 0.4
|
||||||
|
min_rarity: "rare"
|
||||||
|
block_chance: 0.15
|
||||||
|
|
||||||
|
# ==================== MAGICAL SHIELDS (HIGH RESISTANCE) ====================
|
||||||
|
enchanted_buckler:
|
||||||
|
template_id: "enchanted_buckler"
|
||||||
|
name: "Enchanted Buckler"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A small shield inscribed with protective runes. Weak against physical attacks but excellent against magic."
|
||||||
|
base_defense: 2
|
||||||
|
base_resistance: 6
|
||||||
|
base_value: 80
|
||||||
|
required_level: 3
|
||||||
|
drop_weight: 0.8
|
||||||
|
|
||||||
|
warded_shield:
|
||||||
|
template_id: "warded_shield"
|
||||||
|
name: "Warded Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A shield covered in magical wards that deflect spells."
|
||||||
|
base_defense: 4
|
||||||
|
base_resistance: 8
|
||||||
|
base_value: 110
|
||||||
|
required_level: 4
|
||||||
|
drop_weight: 0.7
|
||||||
|
|
||||||
|
magical_aegis:
|
||||||
|
template_id: "magical_aegis"
|
||||||
|
name: "Magical Aegis"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "An arcane shield that shimmers with protective energy. Prized by battle mages."
|
||||||
|
base_defense: 8
|
||||||
|
base_resistance: 10
|
||||||
|
base_value: 150
|
||||||
|
required_level: 5
|
||||||
|
drop_weight: 0.6
|
||||||
|
min_rarity: "uncommon"
|
||||||
|
|
||||||
|
spellguard:
|
||||||
|
template_id: "spellguard"
|
||||||
|
name: "Spellguard"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A crystalline shield forged from condensed magical energy. Near-impervious to spells."
|
||||||
|
base_defense: 6
|
||||||
|
base_resistance: 14
|
||||||
|
base_value: 200
|
||||||
|
required_level: 6
|
||||||
|
drop_weight: 0.5
|
||||||
|
min_rarity: "rare"
|
||||||
|
|
||||||
|
# ==================== SPECIALIZED SHIELDS ====================
|
||||||
|
spiked_shield:
|
||||||
|
template_id: "spiked_shield"
|
||||||
|
name: "Spiked Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A shield with iron spikes. Can be used offensively in close combat."
|
||||||
|
base_defense: 8
|
||||||
|
base_resistance: 1
|
||||||
|
base_value: 90
|
||||||
|
required_level: 4
|
||||||
|
drop_weight: 0.8
|
||||||
|
base_damage: 5
|
||||||
|
|
||||||
|
lantern_shield:
|
||||||
|
template_id: "lantern_shield"
|
||||||
|
name: "Lantern Shield"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A peculiar shield with an attached lantern. Useful for dungeon exploration."
|
||||||
|
base_defense: 6
|
||||||
|
base_resistance: 1
|
||||||
|
base_value: 75
|
||||||
|
required_level: 3
|
||||||
|
drop_weight: 0.7
|
||||||
|
provides_light: true
|
||||||
|
|
||||||
|
pavise:
|
||||||
|
template_id: "pavise"
|
||||||
|
name: "Pavise"
|
||||||
|
item_type: "armor"
|
||||||
|
slot: "off_hand"
|
||||||
|
description: "A large rectangular shield that can be planted in the ground for cover. Favored by crossbowmen."
|
||||||
|
base_defense: 15
|
||||||
|
base_resistance: 2
|
||||||
|
base_value: 130
|
||||||
|
required_level: 5
|
||||||
|
drop_weight: 0.6
|
||||||
|
deployable: true
|
||||||
141
api/app/data/shop/general_store.yaml
Normal file
141
api/app/data/shop/general_store.yaml
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
shop_id: "general_store"
|
||||||
|
shop_name: "General Store"
|
||||||
|
shop_description: "A well-stocked general store with essential supplies for adventurers."
|
||||||
|
shopkeeper_name: "Merchant Guildmaster"
|
||||||
|
sell_rate: 0.5 # 50% of buy price when selling items back
|
||||||
|
|
||||||
|
inventory:
|
||||||
|
# === CONSUMABLES ===
|
||||||
|
# Health Potions
|
||||||
|
- item_id: "health_potion_tiny"
|
||||||
|
stock: -1
|
||||||
|
price: 10
|
||||||
|
|
||||||
|
- item_id: "health_potion_small"
|
||||||
|
stock: -1
|
||||||
|
price: 25
|
||||||
|
|
||||||
|
- item_id: "health_potion_medium"
|
||||||
|
stock: -1
|
||||||
|
price: 75
|
||||||
|
|
||||||
|
# Mana Potions
|
||||||
|
- item_id: "mana_potion_tiny"
|
||||||
|
stock: -1
|
||||||
|
price: 15
|
||||||
|
|
||||||
|
- item_id: "mana_potion_small"
|
||||||
|
stock: -1
|
||||||
|
price: 35
|
||||||
|
|
||||||
|
- item_id: "mana_potion_medium"
|
||||||
|
stock: -1
|
||||||
|
price: 100
|
||||||
|
|
||||||
|
# Hybrid Potions
|
||||||
|
- item_id: "rejuvenation_potion_small"
|
||||||
|
stock: -1
|
||||||
|
price: 50
|
||||||
|
|
||||||
|
# Cures
|
||||||
|
- item_id: "antidote"
|
||||||
|
stock: -1
|
||||||
|
price: 20
|
||||||
|
|
||||||
|
- item_id: "smelling_salts"
|
||||||
|
stock: -1
|
||||||
|
price: 15
|
||||||
|
|
||||||
|
# Throwables
|
||||||
|
- item_id: "smoke_bomb"
|
||||||
|
stock: -1
|
||||||
|
price: 30
|
||||||
|
|
||||||
|
- item_id: "oil_flask"
|
||||||
|
stock: -1
|
||||||
|
price: 25
|
||||||
|
|
||||||
|
# Food
|
||||||
|
- item_id: "bread_loaf"
|
||||||
|
stock: -1
|
||||||
|
price: 5
|
||||||
|
|
||||||
|
- item_id: "ration"
|
||||||
|
stock: -1
|
||||||
|
price: 10
|
||||||
|
|
||||||
|
- item_id: "cooked_meat"
|
||||||
|
stock: -1
|
||||||
|
price: 15
|
||||||
|
|
||||||
|
# === WEAPONS (base templates, sold as common) ===
|
||||||
|
- item_id: "dagger"
|
||||||
|
stock: -1
|
||||||
|
price: 25
|
||||||
|
|
||||||
|
- item_id: "short_sword"
|
||||||
|
stock: -1
|
||||||
|
price: 50
|
||||||
|
|
||||||
|
- item_id: "club"
|
||||||
|
stock: -1
|
||||||
|
price: 15
|
||||||
|
|
||||||
|
- item_id: "shortbow"
|
||||||
|
stock: -1
|
||||||
|
price: 45
|
||||||
|
|
||||||
|
- item_id: "wand"
|
||||||
|
stock: -1
|
||||||
|
price: 40
|
||||||
|
|
||||||
|
- item_id: "quarterstaff"
|
||||||
|
stock: -1
|
||||||
|
price: 20
|
||||||
|
|
||||||
|
# === ARMOR ===
|
||||||
|
- item_id: "cloth_robe"
|
||||||
|
stock: -1
|
||||||
|
price: 30
|
||||||
|
|
||||||
|
- item_id: "leather_vest"
|
||||||
|
stock: -1
|
||||||
|
price: 60
|
||||||
|
|
||||||
|
- item_id: "chain_shirt"
|
||||||
|
stock: -1
|
||||||
|
price: 120
|
||||||
|
|
||||||
|
- item_id: "scale_mail"
|
||||||
|
stock: -1
|
||||||
|
price: 200
|
||||||
|
|
||||||
|
# === SHIELDS ===
|
||||||
|
- item_id: "buckler"
|
||||||
|
stock: -1
|
||||||
|
price: 25
|
||||||
|
|
||||||
|
- item_id: "wooden_shield"
|
||||||
|
stock: -1
|
||||||
|
price: 40
|
||||||
|
|
||||||
|
- item_id: "tower_shield"
|
||||||
|
stock: -1
|
||||||
|
price: 150
|
||||||
|
|
||||||
|
# === ACCESSORIES ===
|
||||||
|
- item_id: "copper_ring"
|
||||||
|
stock: -1
|
||||||
|
price: 20
|
||||||
|
|
||||||
|
- item_id: "silver_ring"
|
||||||
|
stock: -1
|
||||||
|
price: 50
|
||||||
|
|
||||||
|
- item_id: "wooden_pendant"
|
||||||
|
stock: -1
|
||||||
|
price: 25
|
||||||
|
|
||||||
|
- item_id: "leather_belt"
|
||||||
|
stock: -1
|
||||||
|
price: 15
|
||||||
487
api/app/data/static_items/accessories.yaml
Normal file
487
api/app/data/static_items/accessories.yaml
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
# Accessory items available for purchase from NPC shops
|
||||||
|
# These are fixed-stat items (not procedurally generated)
|
||||||
|
#
|
||||||
|
# Accessory Slots:
|
||||||
|
# - ring: Finger slot (typically 2 slots available)
|
||||||
|
# - amulet: Neck slot (1 slot)
|
||||||
|
# - belt: Waist slot (1 slot)
|
||||||
|
#
|
||||||
|
# All accessories use item_type: armor with their respective slot
|
||||||
|
|
||||||
|
items:
|
||||||
|
# ==========================================================================
|
||||||
|
# RINGS - Basic
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
copper_ring:
|
||||||
|
name: "Copper Ring"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: common
|
||||||
|
description: "A simple copper band. Provides a minor boost to the wearer's capabilities."
|
||||||
|
value: 25
|
||||||
|
required_level: 1
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
luck: 1
|
||||||
|
|
||||||
|
silver_ring:
|
||||||
|
name: "Silver Ring"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: common
|
||||||
|
description: "A polished silver ring. A step up from copper, with better enchantment potential."
|
||||||
|
value: 50
|
||||||
|
required_level: 2
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
luck: 2
|
||||||
|
|
||||||
|
gold_ring:
|
||||||
|
name: "Gold Ring"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A gleaming gold ring. The metal's purity enhances its magical properties."
|
||||||
|
value: 100
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
luck: 3
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# RINGS - Stat-Specific
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
ring_of_strength:
|
||||||
|
name: "Ring of Strength"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A heavy iron ring etched with symbols of power. Increases physical might."
|
||||||
|
value: 120
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
strength: 3
|
||||||
|
|
||||||
|
ring_of_agility:
|
||||||
|
name: "Ring of Agility"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A lightweight ring of woven silver. Enhances speed and reflexes."
|
||||||
|
value: 120
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
dexterity: 3
|
||||||
|
|
||||||
|
ring_of_intellect:
|
||||||
|
name: "Ring of Intellect"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A sapphire-studded ring that sharpens the mind."
|
||||||
|
value: 120
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
intelligence: 3
|
||||||
|
|
||||||
|
ring_of_wisdom:
|
||||||
|
name: "Ring of Wisdom"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: uncommon
|
||||||
|
description: "An ancient ring carved from petrified wood. Grants insight and perception."
|
||||||
|
value: 120
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
wisdom: 3
|
||||||
|
|
||||||
|
ring_of_fortitude:
|
||||||
|
name: "Ring of Fortitude"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A thick band of blackened steel. Toughens the body against harm."
|
||||||
|
value: 120
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
constitution: 3
|
||||||
|
|
||||||
|
ring_of_charisma:
|
||||||
|
name: "Ring of Charisma"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A elegant ring with a hypnotic gem. Enhances presence and charm."
|
||||||
|
value: 120
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
charisma: 3
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# RINGS - Combat
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
ring_of_protection:
|
||||||
|
name: "Ring of Protection"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: rare
|
||||||
|
description: "A ring inscribed with protective runes. Creates a subtle barrier around the wearer."
|
||||||
|
value: 200
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
base_defense: 5
|
||||||
|
|
||||||
|
ring_of_the_magi:
|
||||||
|
name: "Ring of the Magi"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: rare
|
||||||
|
description: "A ring pulsing with arcane energy. Amplifies spellcasting power."
|
||||||
|
value: 200
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
base_spell_power: 5
|
||||||
|
|
||||||
|
ring_of_evasion:
|
||||||
|
name: "Ring of Evasion"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: rare
|
||||||
|
description: "A shadowy ring that seems to flicker in and out of sight."
|
||||||
|
value: 175
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
dodge_bonus: 0.05
|
||||||
|
|
||||||
|
ring_of_striking:
|
||||||
|
name: "Ring of Striking"
|
||||||
|
item_type: armor
|
||||||
|
slot: ring
|
||||||
|
rarity: rare
|
||||||
|
description: "A ring with a ruby set in iron. Enhances the force of physical attacks."
|
||||||
|
value: 185
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
damage_bonus: 3
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# AMULETS - Basic
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
wooden_pendant:
|
||||||
|
name: "Wooden Pendant"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: common
|
||||||
|
description: "A carved wooden pendant on a leather cord. A humble but effective charm."
|
||||||
|
value: 30
|
||||||
|
required_level: 1
|
||||||
|
is_tradeable: true
|
||||||
|
max_hp_bonus: 5
|
||||||
|
|
||||||
|
bone_amulet:
|
||||||
|
name: "Bone Amulet"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: common
|
||||||
|
description: "An amulet carved from the bone of a magical beast. Provides minor magical resistance."
|
||||||
|
value: 40
|
||||||
|
required_level: 1
|
||||||
|
is_tradeable: true
|
||||||
|
base_resistance: 1
|
||||||
|
|
||||||
|
silver_locket:
|
||||||
|
name: "Silver Locket"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A small silver locket that can hold a keepsake. Strengthens the wearer's resolve."
|
||||||
|
value: 75
|
||||||
|
required_level: 2
|
||||||
|
is_tradeable: true
|
||||||
|
max_hp_bonus: 10
|
||||||
|
|
||||||
|
travelers_charm:
|
||||||
|
name: "Traveler's Charm"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: common
|
||||||
|
description: "A lucky charm carried by wanderers. Said to bring fortune on the road."
|
||||||
|
value: 50
|
||||||
|
required_level: 2
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
strength: 1
|
||||||
|
dexterity: 1
|
||||||
|
constitution: 1
|
||||||
|
intelligence: 1
|
||||||
|
wisdom: 1
|
||||||
|
charisma: 1
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# AMULETS - Specialized
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
amulet_of_health:
|
||||||
|
name: "Amulet of Health"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A ruby-studded amulet that pulses with life energy."
|
||||||
|
value: 125
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
max_hp_bonus: 15
|
||||||
|
|
||||||
|
amulet_of_mana:
|
||||||
|
name: "Amulet of Mana"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A sapphire pendant that glows with arcane power."
|
||||||
|
value: 125
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
max_mp_bonus: 15
|
||||||
|
|
||||||
|
amulet_of_warding:
|
||||||
|
name: "Amulet of Warding"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: rare
|
||||||
|
description: "An intricate amulet covered in protective sigils. Guards against magical harm."
|
||||||
|
value: 175
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
base_resistance: 3
|
||||||
|
|
||||||
|
amulet_of_regeneration:
|
||||||
|
name: "Amulet of Regeneration"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: rare
|
||||||
|
description: "A green gem amulet infused with troll essence. Slowly heals wounds over time."
|
||||||
|
value: 200
|
||||||
|
required_level: 5
|
||||||
|
is_tradeable: true
|
||||||
|
hp_regen: 2
|
||||||
|
|
||||||
|
amulet_of_focus:
|
||||||
|
name: "Amulet of Focus"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: rare
|
||||||
|
description: "A crystal pendant that helps the wearer concentrate on spellcasting."
|
||||||
|
value: 175
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
base_spell_power: 4
|
||||||
|
|
||||||
|
amulet_of_vitality:
|
||||||
|
name: "Amulet of Vitality"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: rare
|
||||||
|
description: "A vibrant amber amulet that radiates warmth. Enhances the wearer's constitution."
|
||||||
|
value: 180
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
constitution: 4
|
||||||
|
max_hp_bonus: 10
|
||||||
|
|
||||||
|
warriors_medallion:
|
||||||
|
name: "Warrior's Medallion"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A bronze medallion awarded to proven warriors. Inspires courage in battle."
|
||||||
|
value: 100
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
strength: 2
|
||||||
|
constitution: 2
|
||||||
|
|
||||||
|
sorcerers_pendant:
|
||||||
|
name: "Sorcerer's Pendant"
|
||||||
|
item_type: armor
|
||||||
|
slot: amulet
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A mystical pendant favored by apprentice mages."
|
||||||
|
value: 100
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
intelligence: 2
|
||||||
|
wisdom: 2
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# BELTS - Basic
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
leather_belt:
|
||||||
|
name: "Leather Belt"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: common
|
||||||
|
description: "A sturdy leather belt with pouches for carrying supplies."
|
||||||
|
value: 15
|
||||||
|
required_level: 1
|
||||||
|
is_tradeable: true
|
||||||
|
carry_capacity_bonus: 5
|
||||||
|
|
||||||
|
adventurers_belt:
|
||||||
|
name: "Adventurer's Belt"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: common
|
||||||
|
description: "A well-worn belt with loops and pouches. Standard gear for dungeon delvers."
|
||||||
|
value: 35
|
||||||
|
required_level: 1
|
||||||
|
is_tradeable: true
|
||||||
|
potion_slots_bonus: 2
|
||||||
|
|
||||||
|
reinforced_belt:
|
||||||
|
name: "Reinforced Belt"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: common
|
||||||
|
description: "A thick belt reinforced with metal studs. Provides minor protection."
|
||||||
|
value: 45
|
||||||
|
required_level: 2
|
||||||
|
is_tradeable: true
|
||||||
|
base_defense: 1
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# BELTS - Class-Specific
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
warriors_girdle:
|
||||||
|
name: "Warrior's Girdle"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A wide leather girdle worn by seasoned fighters. Supports heavy armor and enhances martial prowess."
|
||||||
|
value: 100
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
strength: 2
|
||||||
|
constitution: 2
|
||||||
|
|
||||||
|
rogues_sash:
|
||||||
|
name: "Rogue's Sash"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A dark silk sash with hidden pockets. Favored by thieves and assassins."
|
||||||
|
value: 100
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
dexterity: 2
|
||||||
|
crit_chance_bonus: 0.05
|
||||||
|
|
||||||
|
mages_cord:
|
||||||
|
name: "Mage's Cord"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A woven cord inscribed with arcane symbols. Channels magical energy more efficiently."
|
||||||
|
value: 100
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
intelligence: 2
|
||||||
|
base_spell_power: 5
|
||||||
|
|
||||||
|
clerics_cincture:
|
||||||
|
name: "Cleric's Cincture"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A blessed rope belt worn by the faithful. Enhances divine magic."
|
||||||
|
value: 100
|
||||||
|
required_level: 3
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
wisdom: 2
|
||||||
|
base_resistance: 2
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# BELTS - Combat
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
belt_of_giant_strength:
|
||||||
|
name: "Belt of Giant Strength"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: rare
|
||||||
|
description: "A massive belt made from giant's hide. Grants tremendous physical power."
|
||||||
|
value: 200
|
||||||
|
required_level: 5
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
strength: 5
|
||||||
|
|
||||||
|
belt_of_endurance:
|
||||||
|
name: "Belt of Endurance"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: rare
|
||||||
|
description: "A sturdy belt that seems to lighten burdens and boost stamina."
|
||||||
|
value: 175
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
constitution: 2
|
||||||
|
max_hp_bonus: 20
|
||||||
|
|
||||||
|
belt_of_the_serpent:
|
||||||
|
name: "Belt of the Serpent"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: rare
|
||||||
|
description: "A belt made from serpent scales. Grants uncanny flexibility and reflexes."
|
||||||
|
value: 185
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
stat_bonuses:
|
||||||
|
dexterity: 4
|
||||||
|
dodge_bonus: 0.03
|
||||||
|
|
||||||
|
war_belt:
|
||||||
|
name: "War Belt"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: rare
|
||||||
|
description: "A heavy battle belt with weapon holsters. Designed for extended combat."
|
||||||
|
value: 160
|
||||||
|
required_level: 4
|
||||||
|
is_tradeable: true
|
||||||
|
base_defense: 3
|
||||||
|
stat_bonuses:
|
||||||
|
strength: 2
|
||||||
|
constitution: 1
|
||||||
|
|
||||||
|
utility_belt:
|
||||||
|
name: "Utility Belt"
|
||||||
|
item_type: armor
|
||||||
|
slot: belt
|
||||||
|
rarity: uncommon
|
||||||
|
description: "A belt with numerous pouches and clips. Perfect for carrying tools and supplies."
|
||||||
|
value: 75
|
||||||
|
required_level: 2
|
||||||
|
is_tradeable: true
|
||||||
|
carry_capacity_bonus: 10
|
||||||
|
potion_slots_bonus: 1
|
||||||
File diff suppressed because it is too large
Load Diff
181
api/app/models/transaction.py
Normal file
181
api/app/models/transaction.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""
|
||||||
|
Transaction Model
|
||||||
|
|
||||||
|
Tracks all gold transactions for audit and analytics purposes.
|
||||||
|
Includes shop purchases, sales, quest rewards, trades, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionType(Enum):
|
||||||
|
"""Types of gold transactions."""
|
||||||
|
SHOP_PURCHASE = "shop_purchase"
|
||||||
|
SHOP_SALE = "shop_sale"
|
||||||
|
QUEST_REWARD = "quest_reward"
|
||||||
|
ENEMY_LOOT = "enemy_loot"
|
||||||
|
PLAYER_TRADE = "player_trade"
|
||||||
|
NPC_GIFT = "npc_gift"
|
||||||
|
SYSTEM_ADJUSTMENT = "system_adjustment"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Transaction:
|
||||||
|
"""
|
||||||
|
Represents a gold transaction for audit logging.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
transaction_id: Unique identifier for this transaction
|
||||||
|
transaction_type: Type of transaction (purchase, sale, reward, etc.)
|
||||||
|
character_id: Character involved in the transaction
|
||||||
|
session_id: Game session where transaction occurred (for cleanup on session delete)
|
||||||
|
amount: Gold amount (negative for expenses, positive for income)
|
||||||
|
description: Human-readable description of the transaction
|
||||||
|
timestamp: When the transaction occurred
|
||||||
|
metadata: Additional context-specific data
|
||||||
|
"""
|
||||||
|
|
||||||
|
transaction_id: str
|
||||||
|
transaction_type: TransactionType
|
||||||
|
character_id: str
|
||||||
|
session_id: Optional[str] = None # Session context for cleanup
|
||||||
|
amount: int = 0 # Negative for expenses, positive for income
|
||||||
|
description: str = ""
|
||||||
|
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Serialize transaction to dictionary for storage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing all transaction data
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"transaction_id": self.transaction_id,
|
||||||
|
"transaction_type": self.transaction_type.value,
|
||||||
|
"character_id": self.character_id,
|
||||||
|
"session_id": self.session_id,
|
||||||
|
"amount": self.amount,
|
||||||
|
"description": self.description,
|
||||||
|
"timestamp": self.timestamp.isoformat(),
|
||||||
|
"metadata": self.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
|
||||||
|
"""
|
||||||
|
Deserialize transaction from dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary containing transaction data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transaction instance
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
transaction_id=data["transaction_id"],
|
||||||
|
transaction_type=TransactionType(data["transaction_type"]),
|
||||||
|
character_id=data["character_id"],
|
||||||
|
session_id=data.get("session_id"),
|
||||||
|
amount=data["amount"],
|
||||||
|
description=data["description"],
|
||||||
|
timestamp=datetime.fromisoformat(data["timestamp"]),
|
||||||
|
metadata=data.get("metadata", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_purchase(
|
||||||
|
cls,
|
||||||
|
transaction_id: str,
|
||||||
|
character_id: str,
|
||||||
|
shop_id: str,
|
||||||
|
item_id: str,
|
||||||
|
quantity: int,
|
||||||
|
total_cost: int,
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
) -> 'Transaction':
|
||||||
|
"""
|
||||||
|
Factory method for creating a shop purchase transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transaction_id: Unique transaction ID
|
||||||
|
character_id: Character making the purchase
|
||||||
|
shop_id: Shop where purchase was made
|
||||||
|
item_id: Item purchased
|
||||||
|
quantity: Number of items purchased
|
||||||
|
total_cost: Total gold spent
|
||||||
|
session_id: Optional session ID for cleanup tracking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transaction instance for the purchase
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
transaction_type=TransactionType.SHOP_PURCHASE,
|
||||||
|
character_id=character_id,
|
||||||
|
session_id=session_id,
|
||||||
|
amount=-total_cost, # Negative because spending gold
|
||||||
|
description=f"Purchased {quantity}x {item_id} from {shop_id}",
|
||||||
|
metadata={
|
||||||
|
"shop_id": shop_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"quantity": quantity,
|
||||||
|
"unit_price": total_cost // quantity if quantity > 0 else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_sale(
|
||||||
|
cls,
|
||||||
|
transaction_id: str,
|
||||||
|
character_id: str,
|
||||||
|
shop_id: str,
|
||||||
|
item_id: str,
|
||||||
|
item_name: str,
|
||||||
|
quantity: int,
|
||||||
|
total_earned: int,
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
) -> 'Transaction':
|
||||||
|
"""
|
||||||
|
Factory method for creating a shop sale transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transaction_id: Unique transaction ID
|
||||||
|
character_id: Character making the sale
|
||||||
|
shop_id: Shop where sale was made
|
||||||
|
item_id: Item sold
|
||||||
|
item_name: Display name of the item
|
||||||
|
quantity: Number of items sold
|
||||||
|
total_earned: Total gold earned
|
||||||
|
session_id: Optional session ID for cleanup tracking
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transaction instance for the sale
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
transaction_type=TransactionType.SHOP_SALE,
|
||||||
|
character_id=character_id,
|
||||||
|
session_id=session_id,
|
||||||
|
amount=total_earned, # Positive because receiving gold
|
||||||
|
description=f"Sold {quantity}x {item_name} to {shop_id}",
|
||||||
|
metadata={
|
||||||
|
"shop_id": shop_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"item_name": item_name,
|
||||||
|
"quantity": quantity,
|
||||||
|
"unit_price": total_earned // quantity if quantity > 0 else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of the transaction."""
|
||||||
|
sign = "+" if self.amount >= 0 else ""
|
||||||
|
return (
|
||||||
|
f"Transaction({self.transaction_type.value}, "
|
||||||
|
f"{sign}{self.amount}g, {self.description})"
|
||||||
|
)
|
||||||
@@ -354,8 +354,8 @@ class CharacterService:
|
|||||||
"""
|
"""
|
||||||
Permanently delete a character from the database.
|
Permanently delete a character from the database.
|
||||||
|
|
||||||
Also cleans up any game sessions associated with the character
|
Also cleans up any game sessions, shop transactions, and other
|
||||||
to prevent orphaned sessions.
|
associated data to prevent orphaned records.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
character_id: Character ID
|
character_id: Character ID
|
||||||
@@ -375,6 +375,15 @@ class CharacterService:
|
|||||||
if not character:
|
if not character:
|
||||||
raise CharacterNotFound(f"Character not found: {character_id}")
|
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
|
# Clean up associated sessions before deleting the character
|
||||||
# Local import to avoid circular dependency (session_service imports character_service)
|
# Local import to avoid circular dependency (session_service imports character_service)
|
||||||
from app.services.session_service import get_session_service
|
from app.services.session_service import get_session_service
|
||||||
|
|||||||
@@ -124,6 +124,15 @@ class DatabaseInitService:
|
|||||||
logger.error("Failed to initialize combat_rounds table", error=str(e))
|
logger.error("Failed to initialize combat_rounds table", error=str(e))
|
||||||
results['combat_rounds'] = False
|
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)
|
success_count = sum(1 for v in results.values() if v)
|
||||||
total_count = len(results)
|
total_count = len(results)
|
||||||
|
|
||||||
@@ -1084,6 +1093,174 @@ class DatabaseInitService:
|
|||||||
code=e.code)
|
code=e.code)
|
||||||
raise
|
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(
|
def _create_column(
|
||||||
self,
|
self,
|
||||||
table_id: str,
|
table_id: str,
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ class SessionService:
|
|||||||
from the database. Use this when the user wants to free up their session
|
from the database. Use this when the user wants to free up their session
|
||||||
slot and doesn't need to preserve the game history.
|
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:
|
Args:
|
||||||
session_id: Session ID to delete
|
session_id: Session ID to delete
|
||||||
@@ -439,7 +439,15 @@ class SessionService:
|
|||||||
# Verify ownership first (raises SessionNotFound if invalid)
|
# Verify ownership first (raises SessionNotFound if invalid)
|
||||||
self.get_session(session_id, user_id)
|
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()
|
chat_service = get_chat_message_service()
|
||||||
deleted_messages = chat_service.delete_messages_by_session(session_id)
|
deleted_messages = chat_service.delete_messages_by_session(session_id)
|
||||||
logger.info("Deleted associated chat messages",
|
logger.info("Deleted associated chat messages",
|
||||||
|
|||||||
642
api/app/services/shop_service.py
Normal file
642
api/app/services/shop_service.py
Normal 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
## Phase 4C: NPC Shop (Days 15-18)
|
## Phase 4C: NPC Shop (Days 15-18)
|
||||||
|
|
||||||
### Task 5.1: Define Shop Inventory (4 hours)
|
### Task 5.1: Define Shop Inventory ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Create YAML for shop items
|
**Objective:** Create YAML for shop items
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ inventory:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 5.2: Shop API Endpoints (4 hours)
|
### Task 5.2: Shop API Endpoints ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Create shop endpoints
|
**Objective:** Create shop endpoints
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ class ShopService:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 5.3: Shop UI (1 day / 8 hours)
|
### Task 5.3: Shop UI ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Shop browse and purchase interface
|
**Objective:** Shop browse and purchase interface
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@ def purchase():
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 5.4: Transaction Logging (2 hours)
|
### Task 5.4: Transaction Logging ✅ COMPLETE
|
||||||
|
|
||||||
**Objective:** Log all shop purchases
|
**Objective:** Log all shop purchases
|
||||||
|
|
||||||
@@ -511,3 +511,40 @@ def purchase_item(...):
|
|||||||
- Can query transaction history
|
- Can query transaction history
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Task 5.5: Shop UI Integration ✅ COMPLETE
|
||||||
|
|
||||||
|
**Objective:** Create shop modal accessible from play session character panel
|
||||||
|
|
||||||
|
**Files to Create/Modify:**
|
||||||
|
- `/public_web/templates/game/partials/shop_modal.html` (CREATE)
|
||||||
|
- `/public_web/app/views/game_views.py` (ADD routes)
|
||||||
|
- `/public_web/templates/game/partials/character_panel.html` (ADD button)
|
||||||
|
- `/public_web/static/css/shop.css` (CREATE)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
1. **Shop Modal Template** - Follow existing modal patterns (inventory, equipment)
|
||||||
|
- Header with shop name and gold display
|
||||||
|
- Tab filters for item categories
|
||||||
|
- Item grid with purchase buttons
|
||||||
|
- HTMX-powered purchase flow
|
||||||
|
|
||||||
|
2. **View Routes** - Add to game_views.py:
|
||||||
|
- `GET /play/session/<id>/shop-modal` - Display shop
|
||||||
|
- `POST /play/session/<id>/shop/purchase` - Buy item (HTMX)
|
||||||
|
- `POST /play/session/<id>/shop/sell` - Sell item (HTMX)
|
||||||
|
|
||||||
|
3. **Character Panel Button** - Add Shop button in quick-actions
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Shop button visible in character panel
|
||||||
|
- Modal displays general_store inventory
|
||||||
|
- Items show name, stats, price with rarity styling
|
||||||
|
- Tab filters work (All, Weapons, Armor, Consumables)
|
||||||
|
- Purchase disabled when insufficient gold
|
||||||
|
- Gold updates after purchase
|
||||||
|
- Success/error messages displayed
|
||||||
|
- Modal closes via button, overlay click, or Escape
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ def _build_character_from_api(char_data: dict) -> dict:
|
|||||||
'equipped': char_data.get('equipped', {}),
|
'equipped': char_data.get('equipped', {}),
|
||||||
'inventory': char_data.get('inventory', []),
|
'inventory': char_data.get('inventory', []),
|
||||||
'gold': char_data.get('gold', 0),
|
'gold': char_data.get('gold', 0),
|
||||||
'experience': char_data.get('experience', 0)
|
'experience': char_data.get('experience', 0),
|
||||||
|
'unlocked_skills': char_data.get('unlocked_skills', [])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1373,3 +1374,215 @@ def talk_to_npc(session_id: str, npc_id: str):
|
|||||||
except APIError as e:
|
except APIError as e:
|
||||||
logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e))
|
logger.error("failed_to_talk_to_npc", session_id=session_id, npc_id=npc_id, error=str(e))
|
||||||
return f'<div class="error">Failed to talk to NPC: {e}</div>', 500
|
return f'<div class="error">Failed to talk to NPC: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Shop Routes =====
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/shop-modal')
|
||||||
|
@require_auth
|
||||||
|
def shop_modal(session_id: str):
|
||||||
|
"""
|
||||||
|
Get shop modal for browsing and purchasing items.
|
||||||
|
|
||||||
|
Supports filtering by item type via ?filter= parameter.
|
||||||
|
Uses the general_store shop.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
filter_type = request.args.get('filter', 'all')
|
||||||
|
message = request.args.get('message', '')
|
||||||
|
error = request.args.get('error', '')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
gold = 0
|
||||||
|
inventory = []
|
||||||
|
shop = {}
|
||||||
|
|
||||||
|
if character_id:
|
||||||
|
try:
|
||||||
|
# Get shop inventory with character context (for affordability)
|
||||||
|
shop_response = client.get(
|
||||||
|
f'/api/v1/shop/general_store/inventory',
|
||||||
|
params={'character_id': character_id}
|
||||||
|
)
|
||||||
|
shop_data = shop_response.get('result', {})
|
||||||
|
shop = shop_data.get('shop', {})
|
||||||
|
inventory = shop_data.get('inventory', [])
|
||||||
|
|
||||||
|
# Get character gold
|
||||||
|
char_data = shop_data.get('character', {})
|
||||||
|
gold = char_data.get('gold', 0)
|
||||||
|
|
||||||
|
except (APINotFoundError, APIError) as e:
|
||||||
|
logger.warning("failed_to_load_shop", character_id=character_id, error=str(e))
|
||||||
|
error = "Failed to load shop inventory"
|
||||||
|
|
||||||
|
# Filter inventory by type if specified
|
||||||
|
if filter_type != 'all':
|
||||||
|
inventory = [
|
||||||
|
entry for entry in inventory
|
||||||
|
if entry.get('item', {}).get('item_type') == filter_type
|
||||||
|
]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/shop_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
shop=shop,
|
||||||
|
inventory=inventory,
|
||||||
|
gold=gold,
|
||||||
|
filter=filter_type,
|
||||||
|
message=message,
|
||||||
|
error=error
|
||||||
|
)
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_shop_modal", session_id=session_id, error=str(e))
|
||||||
|
return f'''
|
||||||
|
<div class="modal-overlay" onclick="closeModal()">
|
||||||
|
<div class="modal-content shop-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Shop</h2>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="shop-empty">Failed to load shop: {e}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/shop/purchase', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def shop_purchase(session_id: str):
|
||||||
|
"""
|
||||||
|
Purchase an item from the shop.
|
||||||
|
|
||||||
|
HTMX endpoint - returns updated shop modal.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
item_id = request.form.get('item_id')
|
||||||
|
quantity = int(request.form.get('quantity', 1))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
return shop_modal_with_error(session_id, "No character found for this session")
|
||||||
|
|
||||||
|
if not item_id:
|
||||||
|
return shop_modal_with_error(session_id, "No item specified")
|
||||||
|
|
||||||
|
# Attempt purchase
|
||||||
|
purchase_data = {
|
||||||
|
'character_id': character_id,
|
||||||
|
'item_id': item_id,
|
||||||
|
'quantity': quantity,
|
||||||
|
'session_id': session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post('/api/v1/shop/general_store/purchase', json=purchase_data)
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
# Get item name for message
|
||||||
|
purchase_info = result.get('purchase', {})
|
||||||
|
item_name = purchase_info.get('item_id', item_id)
|
||||||
|
total_cost = purchase_info.get('total_cost', 0)
|
||||||
|
|
||||||
|
message = f"Purchased {item_name} for {total_cost} gold!"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"shop_purchase_success",
|
||||||
|
session_id=session_id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_id=item_id,
|
||||||
|
quantity=quantity,
|
||||||
|
total_cost=total_cost
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-render shop modal with success message
|
||||||
|
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(
|
||||||
|
"shop_purchase_failed",
|
||||||
|
session_id=session_id,
|
||||||
|
item_id=item_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
|
||||||
|
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/shop/sell', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def shop_sell(session_id: str):
|
||||||
|
"""
|
||||||
|
Sell an item to the shop.
|
||||||
|
|
||||||
|
HTMX endpoint - returns updated shop modal.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
item_instance_id = request.form.get('item_instance_id')
|
||||||
|
quantity = int(request.form.get('quantity', 1))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
if not character_id:
|
||||||
|
return redirect(url_for('game.shop_modal', session_id=session_id, error="No character found"))
|
||||||
|
|
||||||
|
if not item_instance_id:
|
||||||
|
return redirect(url_for('game.shop_modal', session_id=session_id, error="No item specified"))
|
||||||
|
|
||||||
|
# Attempt sale
|
||||||
|
sale_data = {
|
||||||
|
'character_id': character_id,
|
||||||
|
'item_instance_id': item_instance_id,
|
||||||
|
'quantity': quantity,
|
||||||
|
'session_id': session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post('/api/v1/shop/general_store/sell', json=sale_data)
|
||||||
|
result = response.get('result', {})
|
||||||
|
|
||||||
|
sale_info = result.get('sale', {})
|
||||||
|
item_name = sale_info.get('item_name', 'Item')
|
||||||
|
total_earned = sale_info.get('total_earned', 0)
|
||||||
|
|
||||||
|
message = f"Sold {item_name} for {total_earned} gold!"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"shop_sell_success",
|
||||||
|
session_id=session_id,
|
||||||
|
character_id=character_id,
|
||||||
|
item_instance_id=item_instance_id,
|
||||||
|
total_earned=total_earned
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(url_for('game.shop_modal', session_id=session_id, message=message))
|
||||||
|
|
||||||
|
except APIError as e:
|
||||||
|
logger.error(
|
||||||
|
"shop_sell_failed",
|
||||||
|
session_id=session_id,
|
||||||
|
item_instance_id=item_instance_id,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
error_msg = str(e.message) if hasattr(e, 'message') else str(e)
|
||||||
|
return redirect(url_for('game.shop_modal', session_id=session_id, error=error_msg))
|
||||||
|
|
||||||
|
|
||||||
|
def shop_modal_with_error(session_id: str, error: str):
|
||||||
|
"""Helper to render shop modal with an error message."""
|
||||||
|
return redirect(url_for('game.shop_modal', session_id=session_id, error=error))
|
||||||
|
|||||||
404
public_web/static/css/shop.css
Normal file
404
public_web/static/css/shop.css
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* Code of Conquest - Shop UI Stylesheet
|
||||||
|
* Shop modal for browsing and purchasing items
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== SHOP MODAL ===== */
|
||||||
|
.shop-modal {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 95%;
|
||||||
|
max-height: 85vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOP HEADER ===== */
|
||||||
|
.shop-modal .modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-header-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-header-info .modal-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-keeper {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-gold-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
border: 1px solid var(--accent-gold, #f3a61a);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-gold-display .gold-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-gold-display .gold-amount {
|
||||||
|
font-size: var(--text-lg, 1.125rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOP MESSAGES ===== */
|
||||||
|
.shop-message {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-message--success {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border-bottom: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-message--error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOP TABS ===== */
|
||||||
|
.shop-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
border-bottom: 1px solid var(--play-border, #3a3a45);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-tabs .tab {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary, #a0a0a8);
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-tabs .tab:hover {
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-tabs .tab.active {
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
border-bottom-color: var(--accent-gold, #f3a61a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOP BODY ===== */
|
||||||
|
.shop-body {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOP GRID ===== */
|
||||||
|
.shop-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOP ITEM CARD ===== */
|
||||||
|
.shop-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-input, #1e1e24);
|
||||||
|
border: 2px solid var(--border-primary, #3a3a45);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rarity borders */
|
||||||
|
.shop-item.rarity-common { border-color: var(--rarity-common, #9ca3af); }
|
||||||
|
.shop-item.rarity-uncommon { border-color: var(--rarity-uncommon, #22c55e); }
|
||||||
|
.shop-item.rarity-rare { border-color: var(--rarity-rare, #3b82f6); }
|
||||||
|
.shop-item.rarity-epic { border-color: var(--rarity-epic, #a855f7); }
|
||||||
|
.shop-item.rarity-legendary {
|
||||||
|
border-color: var(--rarity-legendary, #f59e0b);
|
||||||
|
box-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item Header */
|
||||||
|
.shop-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== RARITY TAG ===== */
|
||||||
|
.shop-item-rarity {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: var(--text-xs, 0.75rem);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rarity-tag--common {
|
||||||
|
background: rgba(156, 163, 175, 0.2);
|
||||||
|
color: var(--rarity-common, #9ca3af);
|
||||||
|
border: 1px solid var(--rarity-common, #9ca3af);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rarity-tag--uncommon {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--rarity-uncommon, #22c55e);
|
||||||
|
border: 1px solid var(--rarity-uncommon, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rarity-tag--rare {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--rarity-rare, #3b82f6);
|
||||||
|
border: 1px solid var(--rarity-rare, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rarity-tag--epic {
|
||||||
|
background: rgba(168, 85, 247, 0.15);
|
||||||
|
color: var(--rarity-epic, #a855f7);
|
||||||
|
border: 1px solid var(--rarity-epic, #a855f7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rarity-tag--legendary {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--rarity-legendary, #f59e0b);
|
||||||
|
border: 1px solid var(--rarity-legendary, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-name {
|
||||||
|
font-family: var(--font-heading, 'Cinzel', serif);
|
||||||
|
font-size: var(--text-base, 1rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-type {
|
||||||
|
font-size: var(--text-xs, 0.75rem);
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item Description */
|
||||||
|
.shop-item-desc {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
color: var(--text-secondary, #a0a0a8);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item Stats */
|
||||||
|
.shop-item-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-stats .stat {
|
||||||
|
font-size: var(--text-xs, 0.75rem);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary, #e5e5e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item Footer - Price and Buy */
|
||||||
|
.shop-item-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--play-border, #3a3a45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: var(--text-base, 1rem);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-price .gold-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-price.unaffordable {
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PURCHASE BUTTON ===== */
|
||||||
|
.btn-purchase {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
min-width: 90px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-purchase--available {
|
||||||
|
background: var(--accent-green, #22c55e);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-purchase--available:hover {
|
||||||
|
background: #16a34a;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-purchase--disabled {
|
||||||
|
background: var(--bg-tertiary, #16161a);
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== EMPTY STATE ===== */
|
||||||
|
.shop-empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--text-muted, #707078);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SHOP FOOTER ===== */
|
||||||
|
.shop-modal .modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-footer-gold {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--accent-gold, #f3a61a);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-footer-gold .gold-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE RESPONSIVENESS ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.shop-modal {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-modal .modal-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-header-info {
|
||||||
|
order: 1;
|
||||||
|
flex: 1 0 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-gold-display {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-modal .modal-close {
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-tabs {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-tabs .tab {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-name {
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra small screens */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.shop-item-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item-price {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-purchase {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ACCESSIBILITY ===== */
|
||||||
|
.shop-tabs .tab:focus-visible,
|
||||||
|
.btn-purchase:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-gold, #f3a61a);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.shop-item,
|
||||||
|
.btn-purchase {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-item:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,6 +103,15 @@ Displays character stats, resource bars, and action buttons
|
|||||||
⚔️ Equipment & Gear
|
⚔️ Equipment & Gear
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{# Shop - Opens shop modal #}
|
||||||
|
<button class="action-btn action-btn--special"
|
||||||
|
hx-get="{{ url_for('game.shop_modal', session_id=session_id) }}"
|
||||||
|
hx-target="#modal-container"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<span class="action-icon">💰</span>
|
||||||
|
Shop
|
||||||
|
</button>
|
||||||
|
|
||||||
{# Skill Trees - Direct link to skills page #}
|
{# Skill Trees - Direct link to skills page #}
|
||||||
<a class="action-btn action-btn--special"
|
<a class="action-btn action-btn--special"
|
||||||
href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}">
|
href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}">
|
||||||
|
|||||||
180
public_web/templates/game/partials/shop_modal.html
Normal file
180
public_web/templates/game/partials/shop_modal.html
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
{#
|
||||||
|
Shop Modal
|
||||||
|
Browse and purchase items from the general store
|
||||||
|
#}
|
||||||
|
<div class="modal-overlay" onclick="if(event.target===this) closeModal()"
|
||||||
|
role="dialog" aria-modal="true" aria-labelledby="shop-title">
|
||||||
|
<div class="modal-content shop-modal">
|
||||||
|
{# Header #}
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="shop-header-info">
|
||||||
|
<h2 class="modal-title" id="shop-title">
|
||||||
|
{{ shop.shop_name|default('General Store') }}
|
||||||
|
</h2>
|
||||||
|
<span class="shop-keeper">{{ shop.shopkeeper_name|default('Merchant') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="shop-gold-display">
|
||||||
|
<span class="gold-icon">💰</span>
|
||||||
|
<span class="gold-amount">{{ gold }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeModal()" aria-label="Close shop">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Success/Error Message #}
|
||||||
|
{% if message %}
|
||||||
|
<div class="shop-message shop-message--success">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error %}
|
||||||
|
<div class="shop-message shop-message--error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Tab Filter Bar #}
|
||||||
|
<div class="shop-tabs" role="tablist">
|
||||||
|
<button class="tab {% if filter == 'all' %}active{% endif %}"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="{{ 'true' if filter == 'all' else 'false' }}"
|
||||||
|
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='all') }}"
|
||||||
|
hx-target=".shop-modal"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button class="tab {% if filter == 'weapon' %}active{% endif %}"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="{{ 'true' if filter == 'weapon' else 'false' }}"
|
||||||
|
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='weapon') }}"
|
||||||
|
hx-target=".shop-modal"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Weapons
|
||||||
|
</button>
|
||||||
|
<button class="tab {% if filter == 'armor' %}active{% endif %}"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="{{ 'true' if filter == 'armor' else 'false' }}"
|
||||||
|
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='armor') }}"
|
||||||
|
hx-target=".shop-modal"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Armor
|
||||||
|
</button>
|
||||||
|
<button class="tab {% if filter == 'consumable' %}active{% endif %}"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="{{ 'true' if filter == 'consumable' else 'false' }}"
|
||||||
|
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='consumable') }}"
|
||||||
|
hx-target=".shop-modal"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Consumables
|
||||||
|
</button>
|
||||||
|
<button class="tab {% if filter == 'accessory' %}active{% endif %}"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="{{ 'true' if filter == 'accessory' else 'false' }}"
|
||||||
|
hx-get="{{ url_for('game.shop_modal', session_id=session_id, filter='accessory') }}"
|
||||||
|
hx-target=".shop-modal"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Accessories
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Body - Item Grid #}
|
||||||
|
<div class="modal-body shop-body">
|
||||||
|
<div class="shop-grid">
|
||||||
|
{% for entry in inventory %}
|
||||||
|
{% set item = entry.item %}
|
||||||
|
{% set price = entry.shop_price %}
|
||||||
|
{% set can_afford = entry.can_afford|default(gold >= price) %}
|
||||||
|
<div class="shop-item rarity-{{ item.rarity|default('common') }}">
|
||||||
|
{# Item Header #}
|
||||||
|
<div class="shop-item-header">
|
||||||
|
<span class="shop-item-name">{{ item.name }}</span>
|
||||||
|
<span class="shop-item-type">{{ item.item_type|default('item')|replace('_', ' ')|title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Rarity Tag #}
|
||||||
|
<span class="shop-item-rarity rarity-tag--{{ item.rarity|default('common') }}">
|
||||||
|
{{ item.rarity|default('common')|title }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{# Item Description #}
|
||||||
|
<p class="shop-item-desc">{{ item.description|default('A useful item.')|truncate(80) }}</p>
|
||||||
|
|
||||||
|
{# Item Stats #}
|
||||||
|
<div class="shop-item-stats">
|
||||||
|
{% if item.item_type == 'weapon' %}
|
||||||
|
{% if item.damage %}
|
||||||
|
<span class="stat">Damage: {{ item.damage }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.damage_type %}
|
||||||
|
<span class="stat">{{ item.damage_type|title }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif item.item_type == 'armor' or item.item_type == 'shield' %}
|
||||||
|
{% if item.defense %}
|
||||||
|
<span class="stat">Defense: +{{ item.defense }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.slot %}
|
||||||
|
<span class="stat">{{ item.slot|replace('_', ' ')|title }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif item.item_type == 'consumable' %}
|
||||||
|
{% if item.hp_restore %}
|
||||||
|
<span class="stat">HP +{{ item.hp_restore }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.mp_restore %}
|
||||||
|
<span class="stat">MP +{{ item.mp_restore }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.effect %}
|
||||||
|
<span class="stat">{{ item.effect }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% elif item.item_type == 'accessory' %}
|
||||||
|
{% if item.stat_bonuses %}
|
||||||
|
{% for stat, bonus in item.stat_bonuses.items() %}
|
||||||
|
<span class="stat">{{ stat|title }}: +{{ bonus }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Price and Buy Button #}
|
||||||
|
<div class="shop-item-footer">
|
||||||
|
<span class="shop-item-price {% if not can_afford %}unaffordable{% endif %}">
|
||||||
|
<span class="gold-icon">💰</span> {{ price }}
|
||||||
|
</span>
|
||||||
|
<button class="btn-purchase {% if can_afford %}btn-purchase--available{% else %}btn-purchase--disabled{% endif %}"
|
||||||
|
{% if can_afford %}
|
||||||
|
hx-post="{{ url_for('game.shop_purchase', session_id=session_id) }}"
|
||||||
|
hx-vals='{"item_id": "{{ item.item_id }}", "quantity": 1}'
|
||||||
|
hx-target=".shop-modal"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
{% else %}
|
||||||
|
disabled
|
||||||
|
{% endif %}
|
||||||
|
aria-label="{% if can_afford %}Purchase {{ item.name }} for {{ price }} gold{% else %}Not enough gold{% endif %}">
|
||||||
|
{% if can_afford %}
|
||||||
|
Buy
|
||||||
|
{% else %}
|
||||||
|
Can't Afford
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="shop-empty">
|
||||||
|
{% if filter == 'all' %}
|
||||||
|
No items available in this shop.
|
||||||
|
{% else %}
|
||||||
|
No {{ filter|replace('_', ' ') }}s available.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Footer #}
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="shop-footer-gold">
|
||||||
|
<span class="gold-icon">💰</span>
|
||||||
|
<span class="gold-amount">{{ gold }} gold</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/inventory.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/shop.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
Reference in New Issue
Block a user