Compare commits

..

2 Commits

Author SHA1 Message Date
e7e329e6ed Merge branch 'feat/Phase4-NPC-Shop' into dev 2025-11-29 01:17:01 -06:00
8bd494a52f NPC shop implimented 2025-11-29 01:16:46 -06:00
17 changed files with 4265 additions and 17 deletions

View File

@@ -179,7 +179,11 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(inventory_bp)
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
# from app.api import marketplace, shop
# from app.api import 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
View 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
)

View 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

View 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

View 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

View 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})"
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
## 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
@@ -58,7 +58,7 @@ inventory:
---
### Task 5.2: Shop API Endpoints (4 hours)
### Task 5.2: Shop API Endpoints ✅ COMPLETE
**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
@@ -415,7 +415,7 @@ def purchase():
---
### Task 5.4: Transaction Logging (2 hours)
### Task 5.4: Transaction Logging ✅ COMPLETE
**Objective:** Log all shop purchases
@@ -511,3 +511,40 @@ def purchase_item(...):
- 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
---

View File

@@ -119,7 +119,8 @@ def _build_character_from_api(char_data: dict) -> dict:
'equipped': char_data.get('equipped', {}),
'inventory': char_data.get('inventory', []),
'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:
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
# ===== 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()">&times;</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))

View 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;
}
}

View File

@@ -103,6 +103,15 @@ Displays character stats, resource bars, and action buttons
⚔️ Equipment & Gear
</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">&#128176;</span>
Shop
</button>
{# Skill Trees - Direct link to skills page #}
<a class="action-btn action-btn--special"
href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}">

View 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">&#128176;</span>
<span class="gold-amount">{{ gold }}</span>
</div>
<button class="modal-close" onclick="closeModal()" aria-label="Close shop">&times;</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">&#128176;</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">&#128176;</span>
<span class="gold-amount">{{ gold }} gold</span>
</div>
<button class="btn btn--secondary" onclick="closeModal()">Close</button>
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@
{% block extra_head %}
<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/shop.css') }}">
{% endblock %}
{% block content %}