Files
Code_of_Conquest/docs/Phase4c.md
2025-11-29 01:16:46 -06:00

14 KiB

Phase 4C: NPC Shop (Days 15-18)

Task 5.1: Define Shop Inventory COMPLETE

Objective: Create YAML for shop items

File: /api/app/data/shop/general_store.yaml

shop_id: "general_store"
shop_name: "General Store"
shop_description: "A well-stocked general store with essential supplies."
shopkeeper_name: "Merchant Guildmaster"

inventory:
  # Weapons
  - item_id: "iron_sword"
    stock: -1  # Unlimited stock (-1)
    price: 50

  - item_id: "oak_bow"
    stock: -1
    price: 45

  # Armor
  - item_id: "leather_helmet"
    stock: -1
    price: 30

  - item_id: "leather_chest"
    stock: -1
    price: 60

  # Consumables
  - item_id: "health_potion_small"
    stock: -1
    price: 10

  - item_id: "health_potion_medium"
    stock: -1
    price: 30

  - item_id: "mana_potion_small"
    stock: -1
    price: 15

  - item_id: "antidote"
    stock: -1
    price: 20

Acceptance Criteria:

  • Shop inventory defined in YAML
  • Mix of weapons, armor, consumables
  • Reasonable pricing
  • Unlimited stock for basics

Task 5.2: Shop API Endpoints COMPLETE

Objective: Create shop endpoints

File: /api/app/api/shop.py

"""
Shop API Blueprint

Endpoints:
- GET /api/v1/shop/inventory - Browse shop items
- POST /api/v1/shop/purchase - Purchase item
"""

from flask import Blueprint, request, g

from app.services.shop_service import ShopService
from app.services.character_service import get_character_service
from app.services.appwrite_service import get_appwrite_service
from app.utils.response import success_response, error_response
from app.utils.auth import require_auth
from app.utils.logging import get_logger

logger = get_logger(__file__)

shop_bp = Blueprint('shop', __name__)


@shop_bp.route('/inventory', methods=['GET'])
@require_auth
def get_shop_inventory():
    """Get shop inventory."""
    shop_service = ShopService()
    inventory = shop_service.get_shop_inventory("general_store")

    return success_response({
        'shop_name': "General Store",
        'inventory': [
            {
                'item': item.to_dict(),
                'price': price,
                'in_stock': True
            }
            for item, price in inventory
        ]
    })


@shop_bp.route('/purchase', methods=['POST'])
@require_auth
def purchase_item():
    """
    Purchase item from shop.

    Request JSON:
    {
        "character_id": "char_abc",
        "item_id": "iron_sword",
        "quantity": 1
    }
    """
    data = request.get_json()

    character_id = data.get('character_id')
    item_id = data.get('item_id')
    quantity = data.get('quantity', 1)

    # Get character
    char_service = get_character_service()
    character = char_service.get_character(character_id, g.user_id)

    # Purchase item
    shop_service = ShopService()

    try:
        result = shop_service.purchase_item(
            character,
            "general_store",
            item_id,
            quantity
        )

        # Save character
        char_service.update_character(character)

        return success_response(result)

    except Exception as e:
        return error_response(str(e), 400)

Also create /api/app/services/shop_service.py:

"""
Shop Service

Manages NPC shop inventory and purchases.
"""

import yaml
from typing import List, Tuple

from app.models.items import Item
from app.models.character import Character
from app.services.item_loader import ItemLoader
from app.utils.logging import get_logger

logger = get_logger(__file__)


class ShopService:
    """Service for NPC shops."""

    def __init__(self):
        self.item_loader = ItemLoader()
        self.shops = self._load_shops()

    def _load_shops(self) -> dict:
        """Load all shop data from YAML."""
        shops = {}

        with open('app/data/shop/general_store.yaml', 'r') as f:
            shop_data = yaml.safe_load(f)
            shops[shop_data['shop_id']] = shop_data

        return shops

    def get_shop_inventory(self, shop_id: str) -> List[Tuple[Item, int]]:
        """
        Get shop inventory.

        Returns:
            List of (Item, price) tuples
        """
        shop = self.shops.get(shop_id)
        if not shop:
            return []

        inventory = []
        for item_data in shop['inventory']:
            item = self.item_loader.get_item(item_data['item_id'])
            price = item_data['price']
            inventory.append((item, price))

        return inventory

    def purchase_item(
        self,
        character: Character,
        shop_id: str,
        item_id: str,
        quantity: int = 1
    ) -> dict:
        """
        Purchase item from shop.

        Args:
            character: Character instance
            shop_id: Shop ID
            item_id: Item to purchase
            quantity: Quantity to buy

        Returns:
            Purchase result dict

        Raises:
            ValueError: If insufficient gold or item not found
        """
        shop = self.shops.get(shop_id)
        if not shop:
            raise ValueError("Shop not found")

        # Find item in shop inventory
        item_data = next(
            (i for i in shop['inventory'] if i['item_id'] == item_id),
            None
        )

        if not item_data:
            raise ValueError("Item not available in shop")

        price = item_data['price'] * quantity

        # Check if character has enough gold
        if character.gold < price:
            raise ValueError(f"Not enough gold. Need {price}, have {character.gold}")

        # Deduct gold
        character.gold -= price

        # Add items to inventory
        for _ in range(quantity):
            if item_id not in character.inventory_item_ids:
                character.inventory_item_ids.append(item_id)
            else:
                # Item already exists, increment stack (if stackable)
                # For now, just add multiple entries
                character.inventory_item_ids.append(item_id)

        logger.info(f"Character {character.character_id} purchased {quantity}x {item_id} for {price} gold")

        return {
            'item_purchased': item_id,
            'quantity': quantity,
            'total_cost': price,
            'gold_remaining': character.gold
        }

Acceptance Criteria:

  • Shop inventory endpoint works
  • Purchase endpoint validates gold
  • Items added to inventory
  • Gold deducted
  • Transactions logged

Task 5.3: Shop UI COMPLETE

Objective: Shop browse and purchase interface

File: /public_web/templates/shop/index.html

{% extends "base.html" %}

{% block title %}Shop - Code of Conquest{% endblock %}

{% block content %}
<div class="shop-container">
    <div class="shop-header">
        <h1>🏪 {{ shop_name }}</h1>
        <p class="shopkeeper">Shopkeeper: {{ shopkeeper_name }}</p>
        <p class="player-gold">Your Gold: <strong>{{ character.gold }}</strong></p>
    </div>

    <div class="shop-inventory">
        {% for item_entry in inventory %}
        <div class="shop-item-card {{ item_entry.item.rarity }}">
            <div class="item-header">
                <h3>{{ item_entry.item.name }}</h3>
                <span class="item-price">{{ item_entry.price }} gold</span>
            </div>

            <p class="item-description">{{ item_entry.item.description }}</p>

            <div class="item-stats">
                {% if item_entry.item.item_type == 'weapon' %}
                <span>⚔️ Damage: {{ item_entry.item.damage }}</span>
                {% elif item_entry.item.item_type == 'armor' %}
                <span>🛡️ Defense: {{ item_entry.item.defense }}</span>
                {% elif item_entry.item.item_type == 'consumable' %}
                <span>❤️ Restores: {{ item_entry.item.hp_restore }} HP</span>
                {% endif %}
            </div>

            <button class="btn btn-primary btn-purchase"
                    {% if character.gold < item_entry.price %}disabled{% endif %}
                    hx-post="/shop/purchase"
                    hx-vals='{"character_id": "{{ character.character_id }}", "item_id": "{{ item_entry.item.item_id }}"}'
                    hx-target=".shop-container"
                    hx-swap="outerHTML">
                {% if character.gold >= item_entry.price %}
                Purchase
                {% else %}
                Not Enough Gold
                {% endif %}
            </button>
        </div>
        {% endfor %}
    </div>
</div>
{% endblock %}

Create view in /public_web/app/views/shop.py:

"""
Shop Views
"""

from flask import Blueprint, render_template, request, g

from app.services.api_client import APIClient, APIError
from app.utils.auth import require_auth
from app.utils.logging import get_logger

logger = get_logger(__file__)

shop_bp = Blueprint('shop', __name__)


@shop_bp.route('/')
@require_auth
def shop_index():
    """Display shop."""
    api_client = APIClient()

    try:
        # Get shop inventory
        shop_response = api_client.get('/shop/inventory')
        inventory = shop_response['result']['inventory']

        # Get character (for gold display)
        char_response = api_client.get(f'/characters/{g.character_id}')
        character = char_response['result']

        return render_template(
            'shop/index.html',
            shop_name="General Store",
            shopkeeper_name="Merchant Guildmaster",
            inventory=inventory,
            character=character
        )

    except APIError as e:
        logger.error(f"Failed to load shop: {e}")
        return render_template('partials/error.html', error=str(e))


@shop_bp.route('/purchase', methods=['POST'])
@require_auth
def purchase():
    """Purchase item (HTMX endpoint)."""
    api_client = APIClient()

    purchase_data = {
        'character_id': request.form.get('character_id'),
        'item_id': request.form.get('item_id'),
        'quantity': 1
    }

    try:
        response = api_client.post('/shop/purchase', json=purchase_data)

        # Reload shop
        return shop_index()

    except APIError as e:
        logger.error(f"Purchase failed: {e}")
        return render_template('partials/error.html', error=str(e))

Acceptance Criteria:

  • Shop displays all items
  • Item cards show stats and price
  • Purchase button disabled if not enough gold
  • Purchase adds item to inventory
  • Gold updates dynamically
  • UI refreshes after purchase

Task 5.4: Transaction Logging COMPLETE

Objective: Log all shop purchases

File: /api/app/models/transaction.py

"""
Transaction Model

Tracks all gold transactions (shop, trades, etc.)
"""

from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Any


@dataclass
class Transaction:
    """Represents a gold transaction."""

    transaction_id: str
    transaction_type: str  # "shop_purchase", "trade", "quest_reward", etc.
    character_id: str
    amount: int  # 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 to dict."""
        return {
            "transaction_id": self.transaction_id,
            "transaction_type": self.transaction_type,
            "character_id": self.character_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 from dict."""
        return cls(
            transaction_id=data["transaction_id"],
            transaction_type=data["transaction_type"],
            character_id=data["character_id"],
            amount=data["amount"],
            description=data["description"],
            timestamp=datetime.fromisoformat(data["timestamp"]),
            metadata=data.get("metadata", {})
        )

Update ShopService.purchase_item() to log transaction:

# In shop_service.py

def purchase_item(...):
    # ... existing code ...

    # Log transaction
    from app.models.transaction import Transaction
    import uuid

    transaction = Transaction(
        transaction_id=str(uuid.uuid4()),
        transaction_type="shop_purchase",
        character_id=character.character_id,
        amount=-price,
        description=f"Purchased {quantity}x {item_id} from {shop_id}",
        metadata={
            "shop_id": shop_id,
            "item_id": item_id,
            "quantity": quantity,
            "unit_price": item_data['price']
        }
    )

    # Save to database
    from app.services.appwrite_service import get_appwrite_service
    appwrite = get_appwrite_service()
    appwrite.create_document("transactions", transaction.transaction_id, transaction.to_dict())

    # ... rest of code ...

Acceptance Criteria:

  • All purchases logged to database
  • Transaction records complete
  • Can query transaction history

Task 5.5: Shop UI Integration COMPLETE

Objective: Create shop modal accessible from play session character panel

Files to Create/Modify:

  • /public_web/templates/game/partials/shop_modal.html (CREATE)
  • /public_web/app/views/game_views.py (ADD routes)
  • /public_web/templates/game/partials/character_panel.html (ADD button)
  • /public_web/static/css/shop.css (CREATE)

Implementation:

  1. Shop Modal Template - Follow existing modal patterns (inventory, equipment)

    • Header with shop name and gold display
    • Tab filters for item categories
    • Item grid with purchase buttons
    • HTMX-powered purchase flow
  2. View Routes - Add to game_views.py:

    • GET /play/session/<id>/shop-modal - Display shop
    • POST /play/session/<id>/shop/purchase - Buy item (HTMX)
    • POST /play/session/<id>/shop/sell - Sell item (HTMX)
  3. Character Panel Button - Add Shop button in quick-actions

Acceptance Criteria:

  • Shop button visible in character panel
  • Modal displays general_store inventory
  • Items show name, stats, price with rarity styling
  • Tab filters work (All, Weapons, Armor, Consumables)
  • Purchase disabled when insufficient gold
  • Gold updates after purchase
  • Success/error messages displayed
  • Modal closes via button, overlay click, or Escape