468 lines
15 KiB
Markdown
468 lines
15 KiB
Markdown
|
||
## Phase 4B: Skill Trees & Leveling (Week 4)
|
||
|
||
### 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
|
||
|
||
---
|