updating docs
This commit is contained in:
@@ -81,8 +81,8 @@ This phase implements the core combat and progression systems for Code of Conque
|
|||||||
| Sub-Phase | Duration | Focus |
|
| Sub-Phase | Duration | Focus |
|
||||||
|-----------|----------|-------|
|
|-----------|----------|-------|
|
||||||
| **Phase 4A** | 2-3 weeks | Combat Foundation |
|
| **Phase 4A** | 2-3 weeks | Combat Foundation |
|
||||||
| **Phase 4B** | 1-2 weeks | Skill Trees & Leveling |
|
| **Phase 4B** | 1-2 weeks | Skill Trees & Leveling | See [`/PHASE4b.md`](/PHASE4b.md)
|
||||||
| **Phase 4C** | 3-4 days | NPC Shop |
|
| **Phase 4C** | 3-4 days | NPC Shop | [`/PHASE4c.md`](/PHASE4c.md)
|
||||||
|
|
||||||
**Total Estimated Time:** 4-5 weeks (~140-175 hours)
|
**Total Estimated Time:** 4-5 weeks (~140-175 hours)
|
||||||
|
|
||||||
@@ -746,985 +746,13 @@ app.register_blueprint(combat_bp, url_prefix='/combat')
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Phase 4B: Skill Trees & Leveling (Week 4)
|
## Phase 4B: Skill Trees & Leveling (Week 4)
|
||||||
|
See [`/PHASE4b.md`](/PHASE4b.md)
|
||||||
### Task 4.1: Verify Skill Tree Data (2 hours)
|
|
||||||
|
|
||||||
**Objective:** Review skill system
|
|
||||||
|
|
||||||
**Files to Review:**
|
|
||||||
- `/api/app/models/skills.py` - SkillNode, SkillTree, PlayerClass
|
|
||||||
- `/api/app/data/skills/` - Skill YAML files for all 8 classes
|
|
||||||
|
|
||||||
**Verification Checklist:**
|
|
||||||
- [ ] Skill trees loaded from YAML
|
|
||||||
- [ ] Each class has 2 skill trees
|
|
||||||
- [ ] Each tree has 5 tiers
|
|
||||||
- [ ] Prerequisites work correctly
|
|
||||||
- [ ] Stat bonuses apply correctly
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
- All 8 classes have complete skill trees
|
|
||||||
- Unlock logic works
|
|
||||||
- Respec logic implemented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4.2: Create Skill Tree Template (2 days / 16 hours)
|
|
||||||
|
|
||||||
**Objective:** Visual skill tree UI
|
|
||||||
|
|
||||||
**File:** `/public_web/templates/character/skills.html`
|
|
||||||
|
|
||||||
**Layout:**
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ CHARACTER SKILL TREES │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ Skill Points Available: 5 [Respec] ($$$)│
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────────────┐ ┌────────────────────────┐ │
|
|
||||||
│ │ TREE 1: Combat │ │ TREE 2: Utility │ │
|
|
||||||
│ ├────────────────────────┤ ├────────────────────────┤ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ Tier 5: [⬢] [⬢] │ │ Tier 5: [⬢] [⬢] │ │
|
|
||||||
│ │ │ │ │ │ │ │ │ │
|
|
||||||
│ │ Tier 4: [⬢] [⬢] │ │ Tier 4: [⬢] [⬢] │ │
|
|
||||||
│ │ │ │ │ │ │ │ │ │
|
|
||||||
│ │ Tier 3: [⬢] [⬢] │ │ Tier 3: [⬢] [⬢] │ │
|
|
||||||
│ │ │ │ │ │ │ │ │ │
|
|
||||||
│ │ Tier 2: [✓] [⬢] │ │ Tier 2: [⬢] [✓] │ │
|
|
||||||
│ │ │ │ │ │ │ │ │ │
|
|
||||||
│ │ Tier 1: [✓] [✓] │ │ Tier 1: [✓] [✓] │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ └────────────────────────┘ └────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Legend: [✓] Unlocked [⬡] Available [⬢] Locked │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Implementation:**
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Skill Trees - {{ character.name }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="skills-container">
|
|
||||||
<div class="skills-header">
|
|
||||||
<h1>{{ character.name }}'s Skill Trees</h1>
|
|
||||||
<div class="skills-info">
|
|
||||||
<span class="skill-points">Skill Points: <strong>{{ character.skill_points }}</strong></span>
|
|
||||||
<button class="btn btn-warning btn-respec"
|
|
||||||
hx-post="/characters/{{ character.character_id }}/skills/respec"
|
|
||||||
hx-confirm="Respec costs {{ respec_cost }} gold. Continue?"
|
|
||||||
hx-target=".skills-container"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
Respec ({{ respec_cost }} gold)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="skill-trees-grid">
|
|
||||||
{% for tree in character.skill_trees %}
|
|
||||||
<div class="skill-tree">
|
|
||||||
<h2 class="tree-name">{{ tree.name }}</h2>
|
|
||||||
<p class="tree-description">{{ tree.description }}</p>
|
|
||||||
|
|
||||||
<div class="tree-diagram">
|
|
||||||
{% for tier in range(5, 0, -1) %}
|
|
||||||
<div class="skill-tier" data-tier="{{ tier }}">
|
|
||||||
<span class="tier-label">Tier {{ tier }}</span>
|
|
||||||
<div class="skill-nodes">
|
|
||||||
{% for node in tree.get_nodes_by_tier(tier) %}
|
|
||||||
<div class="skill-node {{ get_node_status(node, character) }}"
|
|
||||||
data-skill-id="{{ node.skill_id }}"
|
|
||||||
hx-get="/skills/{{ node.skill_id }}/tooltip"
|
|
||||||
hx-target="#skill-tooltip"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-trigger="mouseenter">
|
|
||||||
|
|
||||||
<div class="node-icon">
|
|
||||||
{% if node.skill_id in character.unlocked_skills %}
|
|
||||||
✓
|
|
||||||
{% elif character.can_unlock(node.skill_id) %}
|
|
||||||
⬡
|
|
||||||
{% else %}
|
|
||||||
⬢
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="node-name">{{ node.name }}</span>
|
|
||||||
|
|
||||||
{% if character.can_unlock(node.skill_id) and character.skill_points > 0 %}
|
|
||||||
<button class="btn-unlock"
|
|
||||||
hx-post="/characters/{{ character.character_id }}/skills/unlock"
|
|
||||||
hx-vals='{"skill_id": "{{ node.skill_id }}"}'
|
|
||||||
hx-target=".skills-container"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
Unlock
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Draw prerequisite lines #}
|
|
||||||
{% if node.prerequisite_skill_id %}
|
|
||||||
<div class="prerequisite-line"></div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Skill Tooltip (populated via HTMX) #}
|
|
||||||
<div id="skill-tooltip" class="skill-tooltip"></div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Also create `/public_web/templates/character/partials/skill_tooltip.html`:**
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="tooltip-content">
|
|
||||||
<h3 class="skill-name">{{ skill.name }}</h3>
|
|
||||||
<p class="skill-description">{{ skill.description }}</p>
|
|
||||||
|
|
||||||
<div class="skill-bonuses">
|
|
||||||
<strong>Bonuses:</strong>
|
|
||||||
<ul>
|
|
||||||
{% for stat, bonus in skill.stat_bonuses.items() %}
|
|
||||||
<li>+{{ bonus }} {{ stat|title }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if skill.prerequisite_skill_id %}
|
|
||||||
<p class="prerequisite">
|
|
||||||
<strong>Requires:</strong> {{ get_skill_name(skill.prerequisite_skill_id) }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
- Dual skill tree layout works
|
|
||||||
- 5 tiers × 2 nodes per tree displayed
|
|
||||||
- Locked/available/unlocked states visual
|
|
||||||
- Prerequisite lines drawn
|
|
||||||
- Hover shows tooltip
|
|
||||||
- Mobile responsive
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4.3: Skill Unlock HTMX (4 hours)
|
|
||||||
|
|
||||||
**Objective:** Click to unlock skills
|
|
||||||
|
|
||||||
**File:** `/public_web/app/views/skills.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
Skill Views
|
|
||||||
|
|
||||||
Routes for skill tree UI.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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__)
|
|
||||||
|
|
||||||
skills_bp = Blueprint('skills', __name__)
|
|
||||||
|
|
||||||
|
|
||||||
@skills_bp.route('/<skill_id>/tooltip', methods=['GET'])
|
|
||||||
@require_auth
|
|
||||||
def skill_tooltip(skill_id: str):
|
|
||||||
"""Get skill tooltip (HTMX partial)."""
|
|
||||||
# Load skill data
|
|
||||||
# Return rendered tooltip
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@skills_bp.route('/characters/<character_id>/skills', methods=['GET'])
|
|
||||||
@require_auth
|
|
||||||
def character_skills(character_id: str):
|
|
||||||
"""Display character skill trees."""
|
|
||||||
api_client = APIClient()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get character
|
|
||||||
response = api_client.get(f'/characters/{character_id}')
|
|
||||||
character = response['result']
|
|
||||||
|
|
||||||
# Calculate respec cost
|
|
||||||
respec_cost = character['level'] * 100
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'character/skills.html',
|
|
||||||
character=character,
|
|
||||||
respec_cost=respec_cost
|
|
||||||
)
|
|
||||||
|
|
||||||
except APIError as e:
|
|
||||||
logger.error(f"Failed to load skills: {e}")
|
|
||||||
return render_template('partials/error.html', error=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@skills_bp.route('/characters/<character_id>/skills/unlock', methods=['POST'])
|
|
||||||
@require_auth
|
|
||||||
def unlock_skill(character_id: str):
|
|
||||||
"""Unlock skill (HTMX endpoint)."""
|
|
||||||
api_client = APIClient()
|
|
||||||
skill_id = request.form.get('skill_id')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Unlock skill via API
|
|
||||||
response = api_client.post(
|
|
||||||
f'/characters/{character_id}/skills/unlock',
|
|
||||||
json={'skill_id': skill_id}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Re-render skill trees
|
|
||||||
character = response['result']['character']
|
|
||||||
respec_cost = character['level'] * 100
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'character/skills.html',
|
|
||||||
character=character,
|
|
||||||
respec_cost=respec_cost
|
|
||||||
)
|
|
||||||
|
|
||||||
except APIError as e:
|
|
||||||
logger.error(f"Failed to unlock skill: {e}")
|
|
||||||
return render_template('partials/error.html', error=str(e))
|
|
||||||
```
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
- Click available node unlocks skill
|
|
||||||
- Skill points decrease
|
|
||||||
- Stat bonuses apply immediately
|
|
||||||
- Prerequisites enforced
|
|
||||||
- UI updates without page reload
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4.4: Respec Functionality (4 hours)
|
|
||||||
|
|
||||||
**Objective:** Respec button with confirmation
|
|
||||||
|
|
||||||
**Implementation:** (in `skills_bp`)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@skills_bp.route('/characters/<character_id>/skills/respec', methods=['POST'])
|
|
||||||
@require_auth
|
|
||||||
def respec_skills(character_id: str):
|
|
||||||
"""Respec all skills."""
|
|
||||||
api_client = APIClient()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = api_client.post(f'/characters/{character_id}/skills/respec')
|
|
||||||
character = response['result']['character']
|
|
||||||
respec_cost = character['level'] * 100
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
'character/skills.html',
|
|
||||||
character=character,
|
|
||||||
respec_cost=respec_cost,
|
|
||||||
message="Skills reset! All skill points refunded."
|
|
||||||
)
|
|
||||||
|
|
||||||
except APIError as e:
|
|
||||||
logger.error(f"Failed to respec: {e}")
|
|
||||||
return render_template('partials/error.html', error=str(e))
|
|
||||||
```
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
- Respec button costs gold
|
|
||||||
- Confirmation modal shown
|
|
||||||
- All skills reset
|
|
||||||
- Skill points refunded
|
|
||||||
- Gold deducted
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4.5: XP & Leveling System (1 day / 8 hours)
|
|
||||||
|
|
||||||
**Objective:** Award XP after combat, level up grants skill points
|
|
||||||
|
|
||||||
**File:** `/api/app/services/leveling_service.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
Leveling Service
|
|
||||||
|
|
||||||
Manages XP gain and level ups.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from app.models.character import Character
|
|
||||||
from app.utils.logging import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
|
||||||
|
|
||||||
|
|
||||||
class LevelingService:
|
|
||||||
"""Service for XP and leveling."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def xp_required_for_level(level: int) -> int:
|
|
||||||
"""
|
|
||||||
Calculate XP required for a given level.
|
|
||||||
|
|
||||||
Formula: 100 * (level ^ 2)
|
|
||||||
"""
|
|
||||||
return 100 * (level ** 2)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def award_xp(character: Character, xp_amount: int) -> dict:
|
|
||||||
"""
|
|
||||||
Award XP to character and check for level up.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
character: Character instance
|
|
||||||
xp_amount: XP to award
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with leveled_up, new_level, skill_points_gained
|
|
||||||
"""
|
|
||||||
character.experience += xp_amount
|
|
||||||
|
|
||||||
leveled_up = False
|
|
||||||
levels_gained = 0
|
|
||||||
|
|
||||||
# Check for level ups (can level multiple times)
|
|
||||||
while character.experience >= LevelingService.xp_required_for_level(character.level + 1):
|
|
||||||
character.level += 1
|
|
||||||
character.skill_points += 1
|
|
||||||
levels_gained += 1
|
|
||||||
leveled_up = True
|
|
||||||
|
|
||||||
logger.info(f"Character {character.character_id} leveled up to {character.level}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'leveled_up': leveled_up,
|
|
||||||
'new_level': character.level if leveled_up else None,
|
|
||||||
'skill_points_gained': levels_gained,
|
|
||||||
'xp_gained': xp_amount
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Update Combat Results Endpoint:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In /api/app/api/combat.py
|
|
||||||
|
|
||||||
@combat_bp.route('/<combat_id>/results', methods=['GET'])
|
|
||||||
@require_auth
|
|
||||||
def get_combat_results(combat_id: str):
|
|
||||||
"""Get combat results with XP/loot."""
|
|
||||||
combat_service = CombatService(get_appwrite_service())
|
|
||||||
encounter = combat_service.get_encounter(combat_id)
|
|
||||||
|
|
||||||
if encounter.status != CombatStatus.VICTORY:
|
|
||||||
return error_response("Combat not won", 400)
|
|
||||||
|
|
||||||
# Calculate XP (based on enemy difficulty)
|
|
||||||
xp_gained = sum(enemy.level * 50 for enemy in encounter.combatants if not enemy.is_player)
|
|
||||||
|
|
||||||
# Award XP to character
|
|
||||||
char_service = get_character_service()
|
|
||||||
character = char_service.get_character(encounter.character_id, g.user_id)
|
|
||||||
|
|
||||||
from app.services.leveling_service import LevelingService
|
|
||||||
level_result = LevelingService.award_xp(character, xp_gained)
|
|
||||||
|
|
||||||
# Award gold
|
|
||||||
gold_gained = sum(enemy.level * 25 for enemy in encounter.combatants if not enemy.is_player)
|
|
||||||
character.gold += gold_gained
|
|
||||||
|
|
||||||
# Generate loot (TODO: implement loot tables)
|
|
||||||
loot = []
|
|
||||||
|
|
||||||
# Save character
|
|
||||||
char_service.update_character(character)
|
|
||||||
|
|
||||||
return success_response({
|
|
||||||
'victory': True,
|
|
||||||
'xp_gained': xp_gained,
|
|
||||||
'gold_gained': gold_gained,
|
|
||||||
'loot': loot,
|
|
||||||
'level_up': level_result
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Create Level Up Modal Template:**
|
|
||||||
|
|
||||||
**File:** `/public_web/templates/game/partials/level_up_modal.html`
|
|
||||||
|
|
||||||
```html
|
|
||||||
<div class="modal-overlay">
|
|
||||||
<div class="modal-content level-up-modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h2>🎉 LEVEL UP! 🎉</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
<p class="level-up-text">
|
|
||||||
Congratulations! You've reached <strong>Level {{ new_level }}</strong>!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="level-up-rewards">
|
|
||||||
<p>You gained:</p>
|
|
||||||
<ul>
|
|
||||||
<li>+1 Skill Point</li>
|
|
||||||
<li>+{{ stat_increases.vitality }} Vitality</li>
|
|
||||||
<li>+{{ stat_increases.spirit }} Spirit</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-primary" onclick="closeModal()">Awesome!</button>
|
|
||||||
<a href="/characters/{{ character_id }}/skills" class="btn btn-secondary">
|
|
||||||
View Skill Trees
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
- XP awarded after combat victory
|
|
||||||
- Level up triggers at XP threshold
|
|
||||||
- Skill points granted on level up
|
|
||||||
- Level up modal shown
|
|
||||||
- Character stats increase
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4C: NPC Shop (Days 15-18)
|
## Phase 4C: NPC Shop (Days 15-18)
|
||||||
|
See [`/PHASE4c.md`](/PHASE4c.md)
|
||||||
|
|
||||||
### Task 5.1: Define Shop Inventory (4 hours)
|
|
||||||
|
|
||||||
**Objective:** Create YAML for shop items
|
|
||||||
|
|
||||||
**File:** `/api/app/data/shop/general_store.yaml`
|
|
||||||
|
|
||||||
```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 (4 hours)
|
|
||||||
|
|
||||||
**Objective:** Create shop endpoints
|
|
||||||
|
|
||||||
**File:** `/api/app/api/shop.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
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`:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
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 (1 day / 8 hours)
|
|
||||||
|
|
||||||
**Objective:** Shop browse and purchase interface
|
|
||||||
|
|
||||||
**File:** `/public_web/templates/shop/index.html`
|
|
||||||
|
|
||||||
```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`:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
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 (2 hours)
|
|
||||||
|
|
||||||
**Objective:** Log all shop purchases
|
|
||||||
|
|
||||||
**File:** `/api/app/models/transaction.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""
|
|
||||||
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:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria - Phase 4 Complete
|
## Success Criteria - Phase 4 Complete
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user