diff --git a/api/app/__init__.py b/api/app/__init__.py index 4d46a26..76a5f93 100644 --- a/api/app/__init__.py +++ b/api/app/__init__.py @@ -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') diff --git a/api/app/api/shop.py b/api/app/api/shop.py new file mode 100644 index 0000000..6dea5f7 --- /dev/null +++ b/api/app/api/shop.py @@ -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//inventory - Get shop inventory with character context +- POST /api/v1/shop//purchase - Purchase an item +- POST /api/v1/shop//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//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//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//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 + ) diff --git a/api/app/data/base_items/shields.yaml b/api/app/data/base_items/shields.yaml new file mode 100644 index 0000000..b2f9983 --- /dev/null +++ b/api/app/data/base_items/shields.yaml @@ -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 diff --git a/api/app/data/shop/general_store.yaml b/api/app/data/shop/general_store.yaml new file mode 100644 index 0000000..53aeb75 --- /dev/null +++ b/api/app/data/shop/general_store.yaml @@ -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 diff --git a/api/app/data/static_items/accessories.yaml b/api/app/data/static_items/accessories.yaml new file mode 100644 index 0000000..62bd9f3 --- /dev/null +++ b/api/app/data/static_items/accessories.yaml @@ -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 diff --git a/api/app/data/static_items/consumables.yaml b/api/app/data/static_items/consumables.yaml index ed07206..fa0f752 100644 --- a/api/app/data/static_items/consumables.yaml +++ b/api/app/data/static_items/consumables.yaml @@ -1,11 +1,34 @@ # Consumable items that drop from enemies or are purchased from vendors # These items have effects_on_use that trigger when consumed +# +# Effect Types: +# - hot: Heal Over Time (health restoration) +# - dot: Damage Over Time (poison, burn, etc.) +# - buff: Temporary stat increase +# - debuff: Temporary stat decrease +# - stun: Skip turn +# - shield: Absorb damage before HP loss items: # ========================================================================== - # Health Potions + # HEALTH POTIONS # ========================================================================== + health_potion_tiny: + name: "Tiny Health Potion" + item_type: consumable + rarity: common + description: "A tiny vial of red liquid. Barely enough to close a scratch." + value: 10 + is_tradeable: true + effects_on_use: + - effect_id: heal_tiny + name: "Minor Mending" + effect_type: hot + power: 15 + duration: 1 + stacks: 1 + health_potion_small: name: "Small Health Potion" item_type: consumable @@ -51,10 +74,56 @@ items: duration: 1 stacks: 1 + health_potion_greater: + name: "Greater Health Potion" + item_type: consumable + rarity: epic + description: "A masterfully crafted elixir infused with rare healing herbs. Restores a massive amount of health." + value: 350 + is_tradeable: true + effects_on_use: + - effect_id: heal_greater + name: "Greater Healing" + effect_type: hot + power: 300 + duration: 1 + stacks: 1 + + health_potion_supreme: + name: "Supreme Health Potion" + item_type: consumable + rarity: legendary + description: "A legendary elixir brewed by master alchemists using ingredients from the rarest corners of the world. Fully restores the drinker's vitality." + value: 750 + is_tradeable: true + effects_on_use: + - effect_id: heal_supreme + name: "Supreme Healing" + effect_type: hot + power: 500 + duration: 1 + stacks: 1 + # ========================================================================== - # Mana Potions + # MANA POTIONS # ========================================================================== + mana_potion_tiny: + name: "Tiny Mana Potion" + item_type: consumable + rarity: common + description: "A tiny vial of shimmering blue liquid. Enough to fuel a minor cantrip." + value: 10 + is_tradeable: true + effects_on_use: + - effect_id: mana_tiny + name: "Minor Mana" + effect_type: hot + stat_target: mana + power: 15 + duration: 1 + stacks: 1 + mana_potion_small: name: "Small Mana Potion" item_type: consumable @@ -62,7 +131,14 @@ items: description: "A small vial of blue liquid that restores mana." value: 25 is_tradeable: true - # Note: MP restoration would need custom effect type or game logic + effects_on_use: + - effect_id: mana_small + name: "Minor Mana Restoration" + effect_type: hot + stat_target: mana + power: 30 + duration: 1 + stacks: 1 mana_potion_medium: name: "Mana Potion" @@ -71,9 +147,135 @@ items: description: "A standard mana potion favored by spellcasters." value: 75 is_tradeable: true + effects_on_use: + - effect_id: mana_medium + name: "Mana Restoration" + effect_type: hot + stat_target: mana + power: 75 + duration: 1 + stacks: 1 + + mana_potion_large: + name: "Large Mana Potion" + item_type: consumable + rarity: rare + description: "A concentrated draught of arcane essence that restores significant mana." + value: 150 + is_tradeable: true + effects_on_use: + - effect_id: mana_large + name: "Major Mana Restoration" + effect_type: hot + stat_target: mana + power: 150 + duration: 1 + stacks: 1 + + mana_potion_greater: + name: "Greater Mana Potion" + item_type: consumable + rarity: epic + description: "A masterfully distilled elixir of pure magical energy. Restores a massive amount of mana." + value: 350 + is_tradeable: true + effects_on_use: + - effect_id: mana_greater + name: "Greater Mana Restoration" + effect_type: hot + stat_target: mana + power: 300 + duration: 1 + stacks: 1 + + mana_potion_supreme: + name: "Supreme Mana Potion" + item_type: consumable + rarity: legendary + description: "A legendary elixir containing concentrated ley line energy. Fully restores the drinker's magical reserves." + value: 750 + is_tradeable: true + effects_on_use: + - effect_id: mana_supreme + name: "Supreme Mana Restoration" + effect_type: hot + stat_target: mana + power: 500 + duration: 1 + stacks: 1 # ========================================================================== - # Status Effect Cures + # HYBRID POTIONS (Rejuvenation - HP and MP) + # ========================================================================== + + rejuvenation_potion_small: + name: "Small Rejuvenation Potion" + item_type: consumable + rarity: uncommon + description: "A swirling mixture of red and blue that restores both health and mana." + value: 60 + is_tradeable: true + effects_on_use: + - effect_id: rejuv_hp_small + name: "Minor Rejuvenation" + effect_type: hot + power: 25 + duration: 1 + stacks: 1 + - effect_id: rejuv_mp_small + name: "Minor Mana Rejuvenation" + effect_type: hot + stat_target: mana + power: 25 + duration: 1 + stacks: 1 + + rejuvenation_potion_medium: + name: "Rejuvenation Potion" + item_type: consumable + rarity: rare + description: "A balanced elixir that restores both body and mind in equal measure." + value: 125 + is_tradeable: true + effects_on_use: + - effect_id: rejuv_hp_medium + name: "Rejuvenation" + effect_type: hot + power: 50 + duration: 1 + stacks: 1 + - effect_id: rejuv_mp_medium + name: "Mana Rejuvenation" + effect_type: hot + stat_target: mana + power: 50 + duration: 1 + stacks: 1 + + rejuvenation_potion_large: + name: "Greater Rejuvenation Potion" + item_type: consumable + rarity: epic + description: "A perfectly harmonized elixir that significantly restores both health and mana." + value: 275 + is_tradeable: true + effects_on_use: + - effect_id: rejuv_hp_large + name: "Greater Rejuvenation" + effect_type: hot + power: 100 + duration: 1 + stacks: 1 + - effect_id: rejuv_mp_large + name: "Greater Mana Rejuvenation" + effect_type: hot + stat_target: mana + power: 100 + duration: 1 + stacks: 1 + + # ========================================================================== + # STATUS EFFECT CURES # ========================================================================== antidote: @@ -83,6 +285,14 @@ items: description: "A bitter herbal remedy that cures poison effects." value: 30 is_tradeable: true + effects_on_use: + - effect_id: cure_poison + name: "Cure Poison" + effect_type: buff + removes_effect: poison + power: 0 + duration: 1 + stacks: 1 smelling_salts: name: "Smelling Salts" @@ -91,9 +301,81 @@ items: description: "Pungent salts that can revive unconscious allies or cure stun." value: 40 is_tradeable: true + effects_on_use: + - effect_id: cure_stun + name: "Clear Mind" + effect_type: buff + removes_effect: stun + power: 0 + duration: 1 + stacks: 1 + + burn_salve: + name: "Burn Salve" + item_type: consumable + rarity: common + description: "A cooling ointment made from aloe and mint that soothes burning wounds." + value: 35 + is_tradeable: true + effects_on_use: + - effect_id: cure_burn + name: "Soothe Burns" + effect_type: buff + removes_effect: burn + power: 0 + duration: 1 + stacks: 1 + + frost_balm: + name: "Frost Balm" + item_type: consumable + rarity: common + description: "A warming salve that restores circulation and removes freezing effects." + value: 35 + is_tradeable: true + effects_on_use: + - effect_id: cure_freeze + name: "Thaw" + effect_type: buff + removes_effect: freeze + power: 0 + duration: 1 + stacks: 1 + + holy_water: + name: "Holy Water" + item_type: consumable + rarity: uncommon + description: "Water blessed by a priest of the light. Removes curses and undead afflictions." + value: 50 + is_tradeable: true + effects_on_use: + - effect_id: cure_curse + name: "Purify" + effect_type: buff + removes_effect: curse + power: 0 + duration: 1 + stacks: 1 + + panacea: + name: "Panacea" + item_type: consumable + rarity: rare + description: "A legendary cure-all that removes every negative status effect at once." + value: 150 + is_tradeable: true + effects_on_use: + - effect_id: cure_all + name: "Universal Cure" + effect_type: buff + removes_effect: all + power: 0 + duration: 1 + stacks: 1 # ========================================================================== - # Combat Buffs + # STAT ELIXIRS (Single Stat) # ========================================================================== elixir_of_strength: @@ -107,6 +389,8 @@ items: - effect_id: str_buff name: "Strength Boost" effect_type: buff + stat_bonus: + strength: 5 power: 5 duration: 5 stacks: 1 @@ -122,14 +406,675 @@ items: - effect_id: dex_buff name: "Agility Boost" effect_type: buff + stat_bonus: + dexterity: 5 + power: 5 + duration: 5 + stacks: 1 + + elixir_of_intellect: + name: "Elixir of Intellect" + item_type: consumable + rarity: rare + description: "A cerulean elixir that sharpens the mind and enhances magical aptitude." + value: 100 + is_tradeable: true + effects_on_use: + - effect_id: int_buff + name: "Intellect Boost" + effect_type: buff + stat_bonus: + intelligence: 5 + power: 5 + duration: 5 + stacks: 1 + + elixir_of_wisdom: + name: "Elixir of Wisdom" + item_type: consumable + rarity: rare + description: "A golden elixir that grants clarity of perception and insight." + value: 100 + is_tradeable: true + effects_on_use: + - effect_id: wis_buff + name: "Wisdom Boost" + effect_type: buff + stat_bonus: + wisdom: 5 + power: 5 + duration: 5 + stacks: 1 + + elixir_of_fortitude: + name: "Elixir of Fortitude" + item_type: consumable + rarity: rare + description: "A thick, earthy elixir that toughens the body and increases endurance." + value: 100 + is_tradeable: true + effects_on_use: + - effect_id: con_buff + name: "Fortitude Boost" + effect_type: buff + stat_bonus: + constitution: 5 + power: 5 + duration: 5 + stacks: 1 + + elixir_of_charisma: + name: "Elixir of Charisma" + item_type: consumable + rarity: rare + description: "A sweet, fragrant elixir that enhances presence and charm." + value: 100 + is_tradeable: true + effects_on_use: + - effect_id: cha_buff + name: "Charisma Boost" + effect_type: buff + stat_bonus: + charisma: 5 power: 5 duration: 5 stacks: 1 # ========================================================================== - # Food Items (simple healing, no combat use) + # STAT ELIXIRS (Multi-Stat Draughts) # ========================================================================== + warriors_draught: + name: "Warrior's Draught" + item_type: consumable + rarity: epic + description: "A blood-red draught favored by gladiators. Enhances both strength and constitution." + value: 175 + is_tradeable: true + effects_on_use: + - effect_id: warrior_buff + name: "Warrior's Might" + effect_type: buff + stat_bonus: + strength: 3 + constitution: 3 + power: 6 + duration: 5 + stacks: 1 + + mages_draught: + name: "Mage's Draught" + item_type: consumable + rarity: epic + description: "A swirling violet draught brewed by archmages. Enhances both intellect and wisdom." + value: 175 + is_tradeable: true + effects_on_use: + - effect_id: mage_buff + name: "Arcane Insight" + effect_type: buff + stat_bonus: + intelligence: 3 + wisdom: 3 + power: 6 + duration: 5 + stacks: 1 + + rogues_draught: + name: "Rogue's Draught" + item_type: consumable + rarity: epic + description: "A smoky, quick-acting elixir favored by thieves. Enhances both agility and charm." + value: 175 + is_tradeable: true + effects_on_use: + - effect_id: rogue_buff + name: "Shadow's Grace" + effect_type: buff + stat_bonus: + dexterity: 3 + charisma: 3 + power: 6 + duration: 5 + stacks: 1 + + # ========================================================================== + # COMBAT ELIXIRS + # ========================================================================== + + potion_of_haste: + name: "Potion of Haste" + item_type: consumable + rarity: rare + description: "A quicksilver elixir that accelerates your actions in combat." + value: 125 + is_tradeable: true + effects_on_use: + - effect_id: haste_buff + name: "Haste" + effect_type: buff + speed_modifier: 1.25 + power: 25 + duration: 3 + stacks: 1 + + potion_of_stoneskin: + name: "Potion of Stoneskin" + item_type: consumable + rarity: rare + description: "A gritite elixir that hardens your skin like stone." + value: 120 + is_tradeable: true + effects_on_use: + - effect_id: stoneskin_buff + name: "Stoneskin" + effect_type: buff + defense_bonus: 10 + power: 10 + duration: 5 + stacks: 1 + + potion_of_clarity: + name: "Potion of Clarity" + item_type: consumable + rarity: rare + description: "A crystal-clear elixir that focuses magical energy." + value: 120 + is_tradeable: true + effects_on_use: + - effect_id: clarity_buff + name: "Magical Clarity" + effect_type: buff + spell_power_bonus: 10 + power: 10 + duration: 5 + stacks: 1 + + potion_of_evasion: + name: "Potion of Evasion" + item_type: consumable + rarity: rare + description: "A smoky elixir that makes you harder to hit." + value: 130 + is_tradeable: true + effects_on_use: + - effect_id: evasion_buff + name: "Evasion" + effect_type: buff + dodge_bonus: 0.15 + power: 15 + duration: 5 + stacks: 1 + + potion_of_regeneration: + name: "Potion of Regeneration" + item_type: consumable + rarity: epic + description: "A verdant elixir infused with troll blood. Heals wounds over time." + value: 200 + is_tradeable: true + effects_on_use: + - effect_id: regen_buff + name: "Regeneration" + effect_type: hot + power: 15 + duration: 10 + stacks: 1 + + potion_of_berserk: + name: "Potion of Berserk" + item_type: consumable + rarity: rare + description: "A crimson elixir that sends the drinker into a battle frenzy. Increases damage but lowers defenses." + value: 110 + is_tradeable: true + effects_on_use: + - effect_id: berserk_buff + name: "Berserk Fury" + effect_type: buff + damage_modifier: 1.20 + defense_modifier: 0.90 + power: 20 + duration: 5 + stacks: 1 + + potion_of_invisibility: + name: "Potion of Invisibility" + item_type: consumable + rarity: epic + description: "A colorless, odorless elixir that grants temporary invisibility." + value: 250 + is_tradeable: true + effects_on_use: + - effect_id: invisibility_buff + name: "Invisibility" + effect_type: buff + stealth: true + power: 0 + duration: 3 + stacks: 1 + + # ========================================================================== + # THROWABLES & BOMBS + # ========================================================================== + + smoke_bomb: + name: "Smoke Bomb" + item_type: consumable + rarity: common + description: "A small clay ball filled with smoke powder. Creates a cloud that obscures vision and aids escape." + value: 35 + is_tradeable: true + effects_on_use: + - effect_id: smoke_effect + name: "Smoke Cloud" + effect_type: debuff + target: enemies + blind: true + allows_escape: true + power: 0 + duration: 2 + stacks: 1 + + oil_flask: + name: "Oil Flask" + item_type: consumable + rarity: common + description: "A flask of flammable oil. Creates a slippery, flammable area." + value: 25 + is_tradeable: true + effects_on_use: + - effect_id: oil_effect + name: "Oil Slick" + effect_type: debuff + target: area + flammable: true + power: 0 + duration: 3 + stacks: 1 + + alchemist_fire: + name: "Alchemist's Fire" + item_type: consumable + rarity: uncommon + description: "A volatile flask of alchemical fire that explodes on impact." + value: 75 + is_tradeable: true + effects_on_use: + - effect_id: alch_fire + name: "Alchemist's Fire" + effect_type: dot + damage_type: fire + target: enemies + power: 40 + duration: 1 + stacks: 1 + + frost_bomb: + name: "Frost Bomb" + item_type: consumable + rarity: uncommon + description: "A crystalline sphere of frozen essence that shatters in a burst of cold." + value: 80 + is_tradeable: true + effects_on_use: + - effect_id: frost_burst + name: "Frost Burst" + effect_type: dot + damage_type: ice + target: enemies + power: 30 + duration: 1 + stacks: 1 + - effect_id: frost_slow + name: "Chilling" + effect_type: debuff + target: enemies + speed_modifier: 0.75 + power: 0 + duration: 2 + stacks: 1 + + thunder_stone: + name: "Thunder Stone" + item_type: consumable + rarity: uncommon + description: "A charged crystal that releases a thunderous explosion when thrown." + value: 85 + is_tradeable: true + effects_on_use: + - effect_id: thunder_burst + name: "Thunder Burst" + effect_type: dot + damage_type: lightning + target: enemies + power: 25 + duration: 1 + stacks: 1 + - effect_id: thunder_stun + name: "Stunning" + effect_type: stun + target: enemies + stun_chance: 0.30 + power: 0 + duration: 1 + stacks: 1 + + acid_vial: + name: "Acid Vial" + item_type: consumable + rarity: uncommon + description: "A glass vial of corrosive acid that melts armor and flesh alike." + value: 70 + is_tradeable: true + effects_on_use: + - effect_id: acid_splash + name: "Acid Splash" + effect_type: dot + damage_type: poison + target: enemies + power: 35 + duration: 1 + stacks: 1 + - effect_id: acid_corrode + name: "Corrosion" + effect_type: debuff + target: enemies + defense_modifier: 0.85 + power: 0 + duration: 3 + stacks: 1 + + holy_bomb: + name: "Holy Bomb" + item_type: consumable + rarity: rare + description: "A blessed sphere of condensed divine light. Devastating against undead." + value: 150 + is_tradeable: true + effects_on_use: + - effect_id: holy_burst + name: "Holy Burst" + effect_type: dot + damage_type: holy + target: enemies + power: 60 + bonus_vs_undead: 2.0 + duration: 1 + stacks: 1 + + flash_powder: + name: "Flash Powder" + item_type: consumable + rarity: common + description: "A packet of powder that creates a blinding flash when ignited." + value: 40 + is_tradeable: true + effects_on_use: + - effect_id: flash_blind + name: "Blinding Flash" + effect_type: debuff + target: enemies + blind: true + power: 0 + duration: 2 + stacks: 1 + + poison_vial: + name: "Poison Vial" + item_type: consumable + rarity: uncommon + description: "A vial of potent poison that can be thrown or applied to weapons." + value: 60 + is_tradeable: true + effects_on_use: + - effect_id: poison_dot + name: "Poison" + effect_type: dot + damage_type: poison + target: enemies + power: 10 + duration: 5 + stacks: 1 + + caltrops: + name: "Caltrops" + item_type: consumable + rarity: common + description: "A bag of metal spikes designed to slow pursuing enemies." + value: 30 + is_tradeable: true + effects_on_use: + - effect_id: caltrop_slow + name: "Impeding" + effect_type: debuff + target: enemies + speed_modifier: 0.50 + power: 0 + duration: 3 + stacks: 1 + + # ========================================================================== + # SCROLLS + # ========================================================================== + + scroll_of_fireball: + name: "Scroll of Fireball" + item_type: consumable + rarity: rare + description: "An ancient scroll containing a powerful fire spell. Releases a massive fireball on use." + value: 175 + is_tradeable: true + effects_on_use: + - effect_id: scroll_fireball + name: "Fireball" + effect_type: dot + damage_type: fire + target: all_enemies + power: 80 + duration: 1 + stacks: 1 + + scroll_of_lightning: + name: "Scroll of Lightning" + item_type: consumable + rarity: rare + description: "A crackling scroll that unleashes a devastating lightning bolt." + value: 175 + is_tradeable: true + effects_on_use: + - effect_id: scroll_lightning + name: "Lightning Bolt" + effect_type: dot + damage_type: lightning + target: single_enemy + power: 100 + duration: 1 + stacks: 1 + + scroll_of_healing: + name: "Scroll of Healing" + item_type: consumable + rarity: uncommon + description: "A blessed scroll that invokes divine healing when read aloud." + value: 100 + is_tradeable: true + effects_on_use: + - effect_id: scroll_heal + name: "Divine Healing" + effect_type: hot + target: self + power: 50 + duration: 1 + stacks: 1 + + scroll_of_protection: + name: "Scroll of Protection" + item_type: consumable + rarity: uncommon + description: "A warded scroll that creates a protective barrier around the reader." + value: 90 + is_tradeable: true + effects_on_use: + - effect_id: scroll_protect + name: "Magical Protection" + effect_type: shield + target: self + power: 50 + duration: 3 + stacks: 1 + + scroll_of_identify: + name: "Scroll of Identify" + item_type: consumable + rarity: common + description: "A divination scroll that reveals the true properties of an item." + value: 50 + is_tradeable: true + effects_on_use: + - effect_id: scroll_identify + name: "Identify" + effect_type: buff + identify_item: true + power: 0 + duration: 1 + stacks: 1 + + scroll_of_teleport: + name: "Scroll of Teleport" + item_type: consumable + rarity: rare + description: "A complex arcane scroll that instantly transports the reader to safety." + value: 200 + is_tradeable: true + effects_on_use: + - effect_id: scroll_teleport + name: "Teleport" + effect_type: buff + teleport: true + allows_escape: true + power: 0 + duration: 1 + stacks: 1 + + scroll_of_resurrection: + name: "Scroll of Resurrection" + item_type: consumable + rarity: epic + description: "An extremely rare scroll containing the power to bring back the fallen." + value: 500 + is_tradeable: true + effects_on_use: + - effect_id: scroll_resurrect + name: "Resurrection" + effect_type: hot + target: ally + resurrect: true + power: 100 + duration: 1 + stacks: 1 + + scroll_of_dispel: + name: "Scroll of Dispel" + item_type: consumable + rarity: uncommon + description: "A scroll that unravels magical effects on enemies." + value: 85 + is_tradeable: true + effects_on_use: + - effect_id: scroll_dispel + name: "Dispel Magic" + effect_type: debuff + target: enemies + remove_buffs: true + power: 0 + duration: 1 + stacks: 1 + + scroll_of_summon: + name: "Scroll of Summon" + item_type: consumable + rarity: rare + description: "A conjuration scroll that calls forth a temporary ally to fight by your side." + value: 225 + is_tradeable: true + effects_on_use: + - effect_id: scroll_summon + name: "Summon Ally" + effect_type: buff + summon: true + summon_type: elemental + power: 0 + duration: 5 + stacks: 1 + + scroll_of_enchant: + name: "Scroll of Enchant" + item_type: consumable + rarity: epic + description: "A powerful scroll that temporarily imbues your weapon with magical energy." + value: 300 + is_tradeable: true + effects_on_use: + - effect_id: scroll_enchant + name: "Weapon Enchant" + effect_type: buff + target: self + damage_modifier: 1.50 + damage_type: arcane + power: 50 + duration: 5 + stacks: 1 + + # ========================================================================== + # FOOD ITEMS + # ========================================================================== + + bread_loaf: + name: "Bread Loaf" + item_type: consumable + rarity: common + description: "A simple loaf of crusty bread. Basic sustenance for travelers." + value: 3 + is_tradeable: true + effects_on_use: + - effect_id: bread_heal + name: "Nourishment" + effect_type: hot + power: 8 + duration: 1 + stacks: 1 + + cheese_wheel: + name: "Cheese Wheel" + item_type: consumable + rarity: common + description: "A wheel of aged cheese. Filling and nutritious." + value: 8 + is_tradeable: true + effects_on_use: + - effect_id: cheese_heal + name: "Nourishment" + effect_type: hot + power: 12 + duration: 1 + stacks: 1 + + fruit_basket: + name: "Fruit Basket" + item_type: consumable + rarity: common + description: "A basket of fresh fruits. Refreshing and healthy." + value: 10 + is_tradeable: true + effects_on_use: + - effect_id: fruit_heal + name: "Refreshment" + effect_type: hot + power: 15 + duration: 1 + stacks: 1 + ration: name: "Trail Ration" item_type: consumable @@ -159,3 +1104,93 @@ items: power: 20 duration: 1 stacks: 1 + + travelers_stew: + name: "Traveler's Stew" + item_type: consumable + rarity: uncommon + description: "A hearty stew made with vegetables and meat. Warms the body and soul." + value: 30 + is_tradeable: true + effects_on_use: + - effect_id: stew_heal + name: "Warming Meal" + effect_type: hot + power: 35 + duration: 1 + stacks: 1 + + roasted_boar: + name: "Roasted Boar" + item_type: consumable + rarity: uncommon + description: "A feast of roasted wild boar. Extremely filling and restorative." + value: 45 + is_tradeable: true + effects_on_use: + - effect_id: boar_heal + name: "Feast" + effect_type: hot + power: 50 + duration: 1 + stacks: 1 + + elven_waybread: + name: "Elven Waybread" + item_type: consumable + rarity: rare + description: "A magical bread baked by elven bakers. A single bite can sustain a traveler for days." + value: 80 + is_tradeable: true + effects_on_use: + - effect_id: waybread_heal + name: "Elven Sustenance" + effect_type: hot + power: 75 + duration: 1 + stacks: 1 + + dwarven_ale: + name: "Dwarven Ale" + item_type: consumable + rarity: common + description: "A strong ale brewed in the mountain halls. Builds courage and warms the belly." + value: 8 + is_tradeable: true + effects_on_use: + - effect_id: ale_heal + name: "Liquid Courage" + effect_type: hot + power: 5 + duration: 1 + stacks: 1 + - effect_id: ale_courage + name: "Courage" + effect_type: buff + fear_immunity: true + power: 0 + duration: 3 + stacks: 1 + + wine_bottle: + name: "Bottle of Wine" + item_type: consumable + rarity: common + description: "A fine bottle of wine. Loosens the tongue and warms the heart." + value: 12 + is_tradeable: true + effects_on_use: + - effect_id: wine_heal + name: "Warm Glow" + effect_type: hot + power: 3 + duration: 1 + stacks: 1 + - effect_id: wine_charm + name: "Social Grace" + effect_type: buff + stat_bonus: + charisma: 2 + power: 2 + duration: 5 + stacks: 1 diff --git a/api/app/models/transaction.py b/api/app/models/transaction.py new file mode 100644 index 0000000..d9aa40f --- /dev/null +++ b/api/app/models/transaction.py @@ -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})" + ) diff --git a/api/app/services/character_service.py b/api/app/services/character_service.py index 7ef6db9..714ba1a 100644 --- a/api/app/services/character_service.py +++ b/api/app/services/character_service.py @@ -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 diff --git a/api/app/services/database_init.py b/api/app/services/database_init.py index 7a618e6..6199a18 100644 --- a/api/app/services/database_init.py +++ b/api/app/services/database_init.py @@ -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, diff --git a/api/app/services/session_service.py b/api/app/services/session_service.py index 3383ff5..61fc79f 100644 --- a/api/app/services/session_service.py +++ b/api/app/services/session_service.py @@ -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", diff --git a/api/app/services/shop_service.py b/api/app/services/shop_service.py new file mode 100644 index 0000000..7e12b1c --- /dev/null +++ b/api/app/services/shop_service.py @@ -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 diff --git a/docs/Phase4c.md b/docs/Phase4c.md index 0f6485d..cfe7004 100644 --- a/docs/Phase4c.md +++ b/docs/Phase4c.md @@ -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//shop-modal` - Display shop + - `POST /play/session//shop/purchase` - Buy item (HTMX) + - `POST /play/session//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 + +--- diff --git a/public_web/app/views/game_views.py b/public_web/app/views/game_views.py index c457972..63dc2b6 100644 --- a/public_web/app/views/game_views.py +++ b/public_web/app/views/game_views.py @@ -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'
Failed to talk to NPC: {e}
', 500 + + +# ===== Shop Routes ===== + +@game_bp.route('/session//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''' + + ''' + + +@game_bp.route('/session//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//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)) diff --git a/public_web/static/css/shop.css b/public_web/static/css/shop.css new file mode 100644 index 0000000..3a97679 --- /dev/null +++ b/public_web/static/css/shop.css @@ -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; + } +} diff --git a/public_web/templates/game/partials/character_panel.html b/public_web/templates/game/partials/character_panel.html index d0fae69..b5257aa 100644 --- a/public_web/templates/game/partials/character_panel.html +++ b/public_web/templates/game/partials/character_panel.html @@ -103,6 +103,15 @@ Displays character stats, resource bars, and action buttons ⚔️ Equipment & Gear + {# Shop - Opens shop modal #} + + {# Skill Trees - Direct link to skills page #} diff --git a/public_web/templates/game/partials/shop_modal.html b/public_web/templates/game/partials/shop_modal.html new file mode 100644 index 0000000..b5dc229 --- /dev/null +++ b/public_web/templates/game/partials/shop_modal.html @@ -0,0 +1,180 @@ +{# +Shop Modal +Browse and purchase items from the general store +#} + diff --git a/public_web/templates/game/play.html b/public_web/templates/game/play.html index 3191394..756fd14 100644 --- a/public_web/templates/game/play.html +++ b/public_web/templates/game/play.html @@ -5,6 +5,7 @@ {% block extra_head %} + {% endblock %} {% block content %}