Equipment-Combat Integration: - Update Stats damage formula from STR//2 to int(STR*0.75) for better scaling - Add spell_power system for magical weapons (staves, wands) - Add spell_power_bonus field to Stats model with spell_power property - Add spell_power field to Item model with is_magical_weapon() method - Update Character.get_effective_stats() to populate spell_power_bonus Combatant Model Updates: - Add weapon property fields (crit_chance, crit_multiplier, damage_type) - Add elemental weapon support (elemental_damage_type, physical_ratio, elemental_ratio) - Update serialization to handle new weapon properties DamageCalculator Refactoring: - Remove weapon_damage parameter from calculate_physical_damage() - Use attacker_stats.damage directly (includes weapon bonus) - Use attacker_stats.spell_power for magical damage calculations Combat Service Updates: - Extract weapon properties in _create_combatant_from_character() - Use stats.damage_bonus for enemy combatants from templates - Remove hardcoded _get_weapon_damage() method - Handle elemental weapons with split damage in _execute_attack() Item Generation Updates: - Add base_spell_power to BaseItemTemplate dataclass - Add ARCANE damage type to DamageType enum - Add magical weapon templates (wizard_staff, arcane_staff, wand, crystal_wand) Test Updates: - Update test_stats.py for new damage formula (0.75 scaling) - Update test_character.py for equipment bonus calculations - Update test_damage_calculator.py for new API signatures - Update test_combat_service.py mock fixture for equipped attribute Tests: 174 passing
3377 lines
102 KiB
Markdown
3377 lines
102 KiB
Markdown
# Phase 4: Combat & Progression Systems - Implementation Plan
|
||
|
||
**Status:** In Progress - Week 2 Complete, Week 3 Next
|
||
**Timeline:** 4-5 weeks
|
||
**Last Updated:** November 26, 2025
|
||
**Document Version:** 1.3
|
||
|
||
---
|
||
|
||
## Completion Summary
|
||
|
||
### Week 1: Combat Backend - COMPLETE
|
||
|
||
| Task | Description | Status | Tests |
|
||
|------|-------------|--------|-------|
|
||
| 1.1 | Verify Combat Data Models | ✅ Complete | - |
|
||
| 1.2 | Implement Combat Service | ✅ Complete | 25 tests |
|
||
| 1.3 | Implement Damage Calculator | ✅ Complete | 39 tests |
|
||
| 1.4 | Implement Effect Processor | ✅ Complete | - |
|
||
| 1.5 | Implement Combat Actions | ✅ Complete | - |
|
||
| 1.6 | Combat API Endpoints | ✅ Complete | 19 tests |
|
||
| 1.7 | Manual API Testing | ⏭️ Skipped | - |
|
||
|
||
**Files Created:**
|
||
- `/api/app/models/enemy.py` - EnemyTemplate, LootEntry dataclasses
|
||
- `/api/app/services/enemy_loader.py` - YAML-based enemy loading
|
||
- `/api/app/services/combat_service.py` - Combat orchestration service
|
||
- `/api/app/services/damage_calculator.py` - Damage formula calculations
|
||
- `/api/app/api/combat.py` - REST API endpoints
|
||
- `/api/app/data/enemies/*.yaml` - 6 sample enemy definitions
|
||
- `/api/tests/test_damage_calculator.py` - 39 tests
|
||
- `/api/tests/test_enemy_loader.py` - 25 tests
|
||
- `/api/tests/test_combat_service.py` - 25 tests
|
||
- `/api/tests/test_combat_api.py` - 19 tests
|
||
|
||
**Total Tests:** 108 passing
|
||
|
||
### Week 2: Inventory & Equipment - COMPLETE
|
||
|
||
| Task | Description | Status | Tests |
|
||
|------|-------------|--------|-------|
|
||
| 2.1 | Item Data Models (Affixes) | ✅ Complete | 24 tests |
|
||
| 2.2 | Item Data Files (YAML) | ✅ Complete | - |
|
||
| 2.2.1 | Item Generator Service | ✅ Complete | 35 tests |
|
||
| 2.3 | Inventory Service | ✅ Complete | 24 tests |
|
||
| 2.4 | Inventory API Endpoints | ✅ Complete | 25 tests |
|
||
| 2.5 | Character Stats Calculation | ✅ Complete | 17 tests |
|
||
| 2.6 | Equipment-Combat Integration | ✅ Complete | 140 tests |
|
||
|
||
**Files Created/Modified:**
|
||
- `/api/app/models/items.py` - Item with affix support, spell_power field
|
||
- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses
|
||
- `/api/app/models/stats.py` - spell_power_bonus, updated damage formula
|
||
- `/api/app/models/combat.py` - Combatant weapon properties
|
||
- `/api/app/services/item_generator.py` - Procedural item generation
|
||
- `/api/app/services/inventory_service.py` - Equipment management
|
||
- `/api/app/services/damage_calculator.py` - Refactored to use stats properties
|
||
- `/api/app/services/combat_service.py` - Equipment integration
|
||
- `/api/app/api/inventory.py` - REST API endpoints
|
||
|
||
**Total Tests (Week 2):** 265+ passing
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
This phase implements the core combat and progression systems for Code of Conquest, enabling turn-based tactical combat, inventory management, equipment, skill trees, and the NPC shop. This is a prerequisite for the story progression and quest systems.
|
||
|
||
**Key Deliverables:**
|
||
- Turn-based combat system (API + UI)
|
||
- Inventory & equipment management
|
||
- Skill tree visualization and unlocking
|
||
- XP and leveling system
|
||
- NPC shop
|
||
|
||
---
|
||
|
||
## Phase Structure
|
||
|
||
| Sub-Phase | Duration | Focus |
|
||
|-----------|----------|-------|
|
||
| **Phase 4A** | 2-3 weeks | Combat Foundation |
|
||
| **Phase 4B** | 1-2 weeks | Skill Trees & Leveling |
|
||
| **Phase 4C** | 3-4 days | NPC Shop |
|
||
|
||
**Total Estimated Time:** 4-5 weeks (~140-175 hours)
|
||
|
||
---
|
||
|
||
## Phase 4A: Combat Foundation (Weeks 1-3)
|
||
|
||
### Week 1: Combat Backend & Data Models ✅ COMPLETE
|
||
|
||
#### Task 1.1: Verify Combat Data Models (2 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Ensure all combat-related dataclasses are complete and correct
|
||
|
||
**Files to Review:**
|
||
- `/api/app/models/combat.py` - Combatant, CombatEncounter
|
||
- `/api/app/models/effects.py` - Effect, all effect types
|
||
- `/api/app/models/abilities.py` - Ability, AbilityLoader
|
||
- `/api/app/models/stats.py` - Stats with computed properties
|
||
|
||
**Verification Checklist:**
|
||
- [x] `Combatant` dataclass has all required fields
|
||
- `combatant_id`, `name`, `stats`, `current_hp`, `current_mp`
|
||
- `active_effects`, `cooldowns`, `is_player`
|
||
- [x] `CombatEncounter` dataclass complete
|
||
- `encounter_id`, `combatants`, `turn_order`, `current_turn_index`
|
||
- `combat_log`, `round_number`, `status`
|
||
- [x] Effect types implemented: BUFF, DEBUFF, DOT, HOT, STUN, SHIELD
|
||
- [x] Effect stacking logic correct (max_stacks, duration refresh)
|
||
- [x] Ability loading from YAML works
|
||
- [x] All dataclasses have `to_dict()` and `from_dict()` methods
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- All combat models serialize/deserialize correctly
|
||
- Unit tests pass for combat models
|
||
- No missing fields or methods
|
||
|
||
---
|
||
|
||
#### Task 1.2: Implement Combat Service (1 day / 8 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Create service layer for combat management
|
||
|
||
**File:** `/api/app/services/combat_service.py`
|
||
|
||
**Implementation:**
|
||
|
||
```python
|
||
"""
|
||
Combat Service
|
||
|
||
Manages combat encounters, turn order, and combat state.
|
||
"""
|
||
|
||
from typing import Optional, List, Dict, Any
|
||
from dataclasses import asdict
|
||
import uuid
|
||
|
||
from app.models.combat import Combatant, CombatEncounter, CombatStatus
|
||
from app.models.character import Character
|
||
from app.models.effects import Effect
|
||
from app.models.abilities import Ability, AbilityLoader
|
||
from app.services.appwrite_service import AppwriteService
|
||
from app.utils.logging import get_logger
|
||
|
||
logger = get_logger(__file__)
|
||
|
||
|
||
class CombatNotFound(Exception):
|
||
"""Raised when combat encounter is not found."""
|
||
pass
|
||
|
||
|
||
class InvalidCombatAction(Exception):
|
||
"""Raised when combat action is invalid."""
|
||
pass
|
||
|
||
|
||
class CombatService:
|
||
"""Service for managing combat encounters."""
|
||
|
||
def __init__(self, appwrite_service: AppwriteService):
|
||
self.appwrite = appwrite_service
|
||
self.ability_loader = AbilityLoader()
|
||
self.collection_id = "combat_encounters"
|
||
|
||
def initiate_combat(
|
||
self,
|
||
session_id: str,
|
||
character: Character,
|
||
enemies: List[Dict[str, Any]]
|
||
) -> CombatEncounter:
|
||
"""
|
||
Initiate a new combat encounter.
|
||
|
||
Args:
|
||
session_id: Game session ID
|
||
character: Player character
|
||
enemies: List of enemy data dicts
|
||
|
||
Returns:
|
||
CombatEncounter instance
|
||
"""
|
||
combat_id = str(uuid.uuid4())
|
||
|
||
# Create player combatant
|
||
player_combatant = Combatant.from_character(character)
|
||
|
||
# Create enemy combatants
|
||
enemy_combatants = []
|
||
for i, enemy_data in enumerate(enemies):
|
||
enemy_combatant = Combatant.from_enemy_data(
|
||
enemy_id=f"{combat_id}_enemy_{i}",
|
||
enemy_data=enemy_data
|
||
)
|
||
enemy_combatants.append(enemy_combatant)
|
||
|
||
# Combine all combatants
|
||
all_combatants = [player_combatant] + enemy_combatants
|
||
|
||
# Roll initiative and create turn order
|
||
turn_order = self._roll_initiative(all_combatants)
|
||
|
||
# Create combat encounter
|
||
encounter = CombatEncounter(
|
||
combat_id=combat_id,
|
||
session_id=session_id,
|
||
combatants=all_combatants,
|
||
turn_order=turn_order,
|
||
current_turn_index=0,
|
||
combat_log=[],
|
||
round_number=1,
|
||
status=CombatStatus.IN_PROGRESS
|
||
)
|
||
|
||
# Save to database
|
||
self._save_encounter(encounter)
|
||
|
||
logger.info(f"Combat initiated: {combat_id}", extra={
|
||
"combat_id": combat_id,
|
||
"session_id": session_id,
|
||
"num_enemies": len(enemies)
|
||
})
|
||
|
||
return encounter
|
||
|
||
def _roll_initiative(self, combatants: List[Combatant]) -> List[str]:
|
||
"""
|
||
Roll initiative for all combatants and return turn order.
|
||
|
||
Args:
|
||
combatants: List of combatants
|
||
|
||
Returns:
|
||
List of combatant IDs in turn order (highest initiative first)
|
||
"""
|
||
import random
|
||
|
||
initiative_rolls = []
|
||
for combatant in combatants:
|
||
roll = random.randint(1, 20) + combatant.stats.speed
|
||
initiative_rolls.append((combatant.combatant_id, roll))
|
||
|
||
# Sort by initiative (highest first)
|
||
initiative_rolls.sort(key=lambda x: x[1], reverse=True)
|
||
|
||
return [combatant_id for combatant_id, _ in initiative_rolls]
|
||
|
||
def get_encounter(self, combat_id: str) -> CombatEncounter:
|
||
"""
|
||
Load combat encounter from database.
|
||
|
||
Args:
|
||
combat_id: Combat encounter ID
|
||
|
||
Returns:
|
||
CombatEncounter instance
|
||
|
||
Raises:
|
||
CombatNotFound: If combat not found
|
||
"""
|
||
try:
|
||
doc = self.appwrite.get_document(self.collection_id, combat_id)
|
||
return CombatEncounter.from_dict(doc)
|
||
except Exception as e:
|
||
raise CombatNotFound(f"Combat {combat_id} not found") from e
|
||
|
||
def process_action(
|
||
self,
|
||
combat_id: str,
|
||
action_type: str,
|
||
ability_id: Optional[str] = None,
|
||
target_id: Optional[str] = None,
|
||
item_id: Optional[str] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Process a combat action.
|
||
|
||
Args:
|
||
combat_id: Combat encounter ID
|
||
action_type: "attack", "spell", "item", "defend"
|
||
ability_id: Ability ID (for attack/spell)
|
||
target_id: Target combatant ID
|
||
item_id: Item ID (for item use)
|
||
|
||
Returns:
|
||
Action result dict with damage, effects, etc.
|
||
|
||
Raises:
|
||
InvalidCombatAction: If action is invalid
|
||
"""
|
||
encounter = self.get_encounter(combat_id)
|
||
|
||
# Get current combatant
|
||
current_combatant_id = encounter.turn_order[encounter.current_turn_index]
|
||
current_combatant = encounter.get_combatant(current_combatant_id)
|
||
|
||
# Process effect ticks at start of turn
|
||
self._process_turn_start(encounter, current_combatant)
|
||
|
||
# Check if stunned
|
||
if current_combatant.is_stunned():
|
||
result = {
|
||
"action": "stunned",
|
||
"message": f"{current_combatant.name} is stunned and cannot act!"
|
||
}
|
||
encounter.combat_log.append(result["message"])
|
||
self._advance_turn(encounter)
|
||
self._save_encounter(encounter)
|
||
return result
|
||
|
||
# Execute action based on type
|
||
if action_type == "attack":
|
||
result = self._execute_attack(encounter, current_combatant, ability_id, target_id)
|
||
elif action_type == "spell":
|
||
result = self._execute_spell(encounter, current_combatant, ability_id, target_id)
|
||
elif action_type == "item":
|
||
result = self._execute_item(encounter, current_combatant, item_id, target_id)
|
||
elif action_type == "defend":
|
||
result = self._execute_defend(encounter, current_combatant)
|
||
else:
|
||
raise InvalidCombatAction(f"Invalid action type: {action_type}")
|
||
|
||
# Log action
|
||
encounter.combat_log.append(result["message"])
|
||
|
||
# Check for deaths
|
||
self._check_deaths(encounter)
|
||
|
||
# Check for combat end
|
||
if self._check_combat_end(encounter):
|
||
encounter.status = CombatStatus.VICTORY if self._player_won(encounter) else CombatStatus.DEFEAT
|
||
|
||
# Advance turn
|
||
self._advance_turn(encounter)
|
||
|
||
# Save encounter
|
||
self._save_encounter(encounter)
|
||
|
||
return result
|
||
|
||
def _process_turn_start(self, encounter: CombatEncounter, combatant: Combatant) -> None:
|
||
"""Process effects at start of combatant's turn."""
|
||
for effect in combatant.active_effects:
|
||
effect.tick(combatant)
|
||
|
||
# Remove expired effects
|
||
combatant.active_effects = [e for e in combatant.active_effects if not e.is_expired()]
|
||
|
||
# Reduce cooldowns
|
||
combatant.reduce_cooldowns()
|
||
|
||
def _advance_turn(self, encounter: CombatEncounter) -> None:
|
||
"""Advance to next turn."""
|
||
encounter.current_turn_index += 1
|
||
|
||
# If back to first combatant, increment round
|
||
if encounter.current_turn_index >= len(encounter.turn_order):
|
||
encounter.current_turn_index = 0
|
||
encounter.round_number += 1
|
||
|
||
def _check_deaths(self, encounter: CombatEncounter) -> None:
|
||
"""Check for dead combatants and remove from turn order."""
|
||
for combatant in encounter.combatants:
|
||
if combatant.current_hp <= 0 and combatant.combatant_id in encounter.turn_order:
|
||
encounter.turn_order.remove(combatant.combatant_id)
|
||
encounter.combat_log.append(f"{combatant.name} has been defeated!")
|
||
|
||
def _check_combat_end(self, encounter: CombatEncounter) -> bool:
|
||
"""Check if combat has ended."""
|
||
players_alive = any(c.is_player and c.current_hp > 0 for c in encounter.combatants)
|
||
enemies_alive = any(not c.is_player and c.current_hp > 0 for c in encounter.combatants)
|
||
|
||
return not (players_alive and enemies_alive)
|
||
|
||
def _player_won(self, encounter: CombatEncounter) -> bool:
|
||
"""Check if player won the combat."""
|
||
return any(c.is_player and c.current_hp > 0 for c in encounter.combatants)
|
||
|
||
def _save_encounter(self, encounter: CombatEncounter) -> None:
|
||
"""Save encounter to database."""
|
||
doc_data = encounter.to_dict()
|
||
try:
|
||
self.appwrite.update_document(self.collection_id, encounter.combat_id, doc_data)
|
||
except:
|
||
self.appwrite.create_document(self.collection_id, encounter.combat_id, doc_data)
|
||
|
||
# TODO: Implement _execute_attack, _execute_spell, _execute_item, _execute_defend
|
||
# These will be implemented in Task 1.3 (Damage Calculator)
|
||
```
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- Combat can be initiated with player + enemies
|
||
- Initiative rolls correctly
|
||
- Turn order maintained
|
||
- Combat state persists to GameSession
|
||
- Combat can be loaded from session
|
||
|
||
---
|
||
|
||
#### Task 1.3: Implement Damage Calculator (4 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Calculate damage for physical/magical attacks with critical hits
|
||
|
||
**File:** `/api/app/services/damage_calculator.py`
|
||
|
||
**Implementation:**
|
||
|
||
```python
|
||
"""
|
||
Damage Calculator
|
||
|
||
Calculates damage for attacks and spells, including critical hits.
|
||
"""
|
||
|
||
import random
|
||
from typing import Dict, Any
|
||
|
||
from app.models.stats import Stats
|
||
from app.models.abilities import Ability
|
||
from app.models.items import Item
|
||
from app.utils.logging import get_logger
|
||
|
||
logger = get_logger(__file__)
|
||
|
||
|
||
class DamageCalculator:
|
||
"""Calculate damage for combat actions."""
|
||
|
||
@staticmethod
|
||
def calculate_physical_damage(
|
||
attacker_stats: Stats,
|
||
defender_stats: Stats,
|
||
weapon: Item,
|
||
ability: Ability = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Calculate physical damage.
|
||
|
||
Formula: weapon.damage + (strength / 2) - target.defense
|
||
Min damage: 1
|
||
|
||
Args:
|
||
attacker_stats: Attacker's effective stats
|
||
defender_stats: Defender's effective stats
|
||
weapon: Equipped weapon
|
||
ability: Optional ability (for skills like Power Strike)
|
||
|
||
Returns:
|
||
Dict with damage, is_crit, message
|
||
"""
|
||
# Base damage from weapon
|
||
base_damage = weapon.damage if weapon else 1
|
||
|
||
# Add ability power if using skill
|
||
if ability:
|
||
base_damage += ability.calculate_power(attacker_stats)
|
||
|
||
# Add strength scaling
|
||
base_damage += attacker_stats.strength // 2
|
||
|
||
# Subtract defense
|
||
damage = base_damage - defender_stats.defense
|
||
|
||
# Min damage is 1
|
||
damage = max(1, damage)
|
||
|
||
# Check for critical hit
|
||
crit_chance = weapon.crit_chance if weapon else 0.05
|
||
is_crit = random.random() < crit_chance
|
||
|
||
if is_crit:
|
||
crit_mult = weapon.crit_multiplier if weapon else 2.0
|
||
damage = int(damage * crit_mult)
|
||
|
||
return {
|
||
"damage": damage,
|
||
"is_crit": is_crit,
|
||
"damage_type": "physical"
|
||
}
|
||
|
||
@staticmethod
|
||
def calculate_magical_damage(
|
||
attacker_stats: Stats,
|
||
defender_stats: Stats,
|
||
ability: Ability
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Calculate magical damage.
|
||
|
||
Formula: spell.damage + (magic_power / 2) - target.resistance
|
||
Min damage: 1
|
||
|
||
Args:
|
||
attacker_stats: Attacker's effective stats
|
||
defender_stats: Defender's effective stats
|
||
ability: Spell ability
|
||
|
||
Returns:
|
||
Dict with damage, is_crit, message
|
||
"""
|
||
# Base damage from spell
|
||
base_damage = ability.calculate_power(attacker_stats)
|
||
|
||
# Subtract magic resistance
|
||
damage = base_damage - defender_stats.resistance
|
||
|
||
# Min damage is 1
|
||
damage = max(1, damage)
|
||
|
||
# Spells don't crit by default (can be added per-spell)
|
||
is_crit = False
|
||
|
||
return {
|
||
"damage": damage,
|
||
"is_crit": is_crit,
|
||
"damage_type": "magical"
|
||
}
|
||
|
||
@staticmethod
|
||
def apply_damage(combatant, damage: int) -> int:
|
||
"""
|
||
Apply damage to combatant, considering shields.
|
||
|
||
Args:
|
||
combatant: Target combatant
|
||
damage: Damage amount
|
||
|
||
Returns:
|
||
Actual damage dealt to HP
|
||
"""
|
||
# Check for shield effects
|
||
shield_power = combatant.get_shield_power()
|
||
|
||
if shield_power > 0:
|
||
if damage <= shield_power:
|
||
# Shield absorbs all damage
|
||
combatant.reduce_shield(damage)
|
||
return 0
|
||
else:
|
||
# Shield absorbs partial damage
|
||
remaining_damage = damage - shield_power
|
||
combatant.reduce_shield(shield_power)
|
||
combatant.current_hp -= remaining_damage
|
||
return remaining_damage
|
||
else:
|
||
# No shield, apply damage directly
|
||
combatant.current_hp -= damage
|
||
return damage
|
||
```
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- Physical damage formula correct (39 unit tests)
|
||
- Magical damage formula correct
|
||
- Critical hits work (LUK-based chance, configurable multiplier)
|
||
- Shield absorption works (partial and full)
|
||
- Minimum damage is always 1
|
||
|
||
---
|
||
|
||
#### Task 1.4: Implement Effect Processor (4 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Process effects (DOT, HOT, buffs, debuffs, stun, shield) at turn start
|
||
|
||
**Implementation:** Extend `Effect` class in `/api/app/models/effects.py`
|
||
|
||
**Add Methods:**
|
||
|
||
```python
|
||
# In Effect class
|
||
|
||
def tick(self, combatant) -> None:
|
||
"""
|
||
Process this effect for one turn.
|
||
|
||
Args:
|
||
combatant: Combatant affected by this effect
|
||
"""
|
||
if self.effect_type == EffectType.DOT:
|
||
damage = self.power * self.stacks
|
||
combatant.current_hp -= damage
|
||
logger.info(f"{combatant.name} takes {damage} damage from {self.name}")
|
||
|
||
elif self.effect_type == EffectType.HOT:
|
||
healing = self.power * self.stacks
|
||
combatant.current_hp = min(combatant.current_hp + healing, combatant.stats.max_hp)
|
||
logger.info(f"{combatant.name} heals {healing} HP from {self.name}")
|
||
|
||
elif self.effect_type == EffectType.STUN:
|
||
# Stun doesn't tick damage, just prevents action
|
||
pass
|
||
|
||
elif self.effect_type == EffectType.SHIELD:
|
||
# Shield doesn't tick, it absorbs damage
|
||
pass
|
||
|
||
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
|
||
# Stat modifiers are applied via get_effective_stats()
|
||
pass
|
||
|
||
# Reduce duration
|
||
self.duration -= 1
|
||
```
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- DOT deals damage each turn
|
||
- HOT heals each turn (capped at max HP)
|
||
- Buffs/debuffs modify stats via `get_effective_stats()`
|
||
- Shields absorb damage before HP
|
||
- Stun prevents actions
|
||
- Effects expire when duration reaches 0
|
||
|
||
---
|
||
|
||
#### Task 1.5: Implement Combat Actions (1 day / 8 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Implement `_execute_attack`, `_execute_spell`, `_execute_item`, `_execute_defend` in CombatService
|
||
|
||
**Add to `/api/app/services/combat_service.py`:**
|
||
|
||
```python
|
||
def _execute_attack(
|
||
self,
|
||
encounter: CombatEncounter,
|
||
attacker: Combatant,
|
||
ability_id: str,
|
||
target_id: str
|
||
) -> Dict[str, Any]:
|
||
"""Execute physical attack."""
|
||
target = encounter.get_combatant(target_id)
|
||
ability = self.ability_loader.get_ability(ability_id) if ability_id else None
|
||
|
||
# Check mana cost
|
||
if ability and ability.mana_cost > attacker.current_mp:
|
||
raise InvalidCombatAction("Not enough mana")
|
||
|
||
# Check cooldown
|
||
if ability and not attacker.can_use_ability(ability.ability_id):
|
||
raise InvalidCombatAction("Ability is on cooldown")
|
||
|
||
# Calculate damage
|
||
from app.services.damage_calculator import DamageCalculator
|
||
weapon = attacker.equipped_weapon # Assume Combatant has this field
|
||
dmg_result = DamageCalculator.calculate_physical_damage(
|
||
attacker.stats,
|
||
target.stats,
|
||
weapon,
|
||
ability
|
||
)
|
||
|
||
# Apply damage
|
||
actual_damage = DamageCalculator.apply_damage(target, dmg_result["damage"])
|
||
|
||
# Apply effects from ability
|
||
if ability and ability.effects_applied:
|
||
for effect_data in ability.effects_applied:
|
||
effect = Effect.from_dict(effect_data)
|
||
target.apply_effect(effect)
|
||
|
||
# Consume mana
|
||
if ability:
|
||
attacker.current_mp -= ability.mana_cost
|
||
attacker.set_cooldown(ability.ability_id, ability.cooldown)
|
||
|
||
# Build message
|
||
crit_msg = " (CRITICAL HIT!)" if dmg_result["is_crit"] else ""
|
||
message = f"{attacker.name} attacks {target.name} for {actual_damage} damage{crit_msg}"
|
||
|
||
return {
|
||
"action": "attack",
|
||
"damage": actual_damage,
|
||
"is_crit": dmg_result["is_crit"],
|
||
"target": target.name,
|
||
"message": message
|
||
}
|
||
|
||
def _execute_spell(
|
||
self,
|
||
encounter: CombatEncounter,
|
||
caster: Combatant,
|
||
ability_id: str,
|
||
target_id: str
|
||
) -> Dict[str, Any]:
|
||
"""Execute spell."""
|
||
# Similar to _execute_attack but uses calculate_magical_damage
|
||
# Implementation left as exercise
|
||
pass
|
||
|
||
def _execute_item(
|
||
self,
|
||
encounter: CombatEncounter,
|
||
user: Combatant,
|
||
item_id: str,
|
||
target_id: str
|
||
) -> Dict[str, Any]:
|
||
"""Use item in combat."""
|
||
# Load item, apply effects (healing, buffs, etc.)
|
||
# Remove item from inventory
|
||
pass
|
||
|
||
def _execute_defend(
|
||
self,
|
||
encounter: CombatEncounter,
|
||
defender: Combatant
|
||
) -> Dict[str, Any]:
|
||
"""Enter defensive stance."""
|
||
# Apply temporary defense buff
|
||
from app.models.effects import Effect, EffectType
|
||
|
||
defense_buff = Effect(
|
||
effect_id="defend_buff",
|
||
name="Defending",
|
||
effect_type=EffectType.BUFF,
|
||
duration=1,
|
||
power=5,
|
||
stat_type="defense",
|
||
stacks=1,
|
||
max_stacks=1
|
||
)
|
||
|
||
defender.apply_effect(defense_buff)
|
||
|
||
return {
|
||
"action": "defend",
|
||
"message": f"{defender.name} takes a defensive stance (+5 defense)"
|
||
}
|
||
```
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- Attack action works (physical damage via DamageCalculator)
|
||
- Ability action works (magical damage, mana cost)
|
||
- Defend action applies temporary defense buff
|
||
- Flee action with DEX-based success chance
|
||
- All actions log messages to combat log
|
||
|
||
---
|
||
|
||
#### Task 1.6: Combat API Endpoints (1 day / 8 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Create REST API for combat
|
||
|
||
**File:** `/api/app/api/combat.py`
|
||
|
||
**Endpoints:**
|
||
|
||
```python
|
||
"""
|
||
Combat API Blueprint
|
||
|
||
Endpoints:
|
||
- POST /api/v1/combat/start - Initiate combat
|
||
- POST /api/v1/combat/<combat_id>/action - Take combat action
|
||
- GET /api/v1/combat/<combat_id>/state - Get combat state
|
||
- GET /api/v1/combat/<combat_id>/results - Get results after victory
|
||
"""
|
||
|
||
from flask import Blueprint, request, g
|
||
|
||
from app.services.combat_service import CombatService, CombatNotFound, InvalidCombatAction
|
||
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, created_response, error_response, not_found_response
|
||
from app.utils.auth import require_auth
|
||
from app.utils.logging import get_logger
|
||
|
||
logger = get_logger(__file__)
|
||
|
||
combat_bp = Blueprint('combat', __name__)
|
||
|
||
|
||
@combat_bp.route('/start', methods=['POST'])
|
||
@require_auth
|
||
def start_combat():
|
||
"""
|
||
Initiate a new combat encounter.
|
||
|
||
Request JSON:
|
||
{
|
||
"session_id": "session_123",
|
||
"character_id": "char_abc",
|
||
"enemies": [
|
||
{
|
||
"name": "Goblin",
|
||
"level": 2,
|
||
"stats": {...}
|
||
}
|
||
]
|
||
}
|
||
|
||
Returns:
|
||
201 Created with combat encounter data
|
||
"""
|
||
data = request.get_json()
|
||
|
||
# Validate request
|
||
session_id = data.get('session_id')
|
||
character_id = data.get('character_id')
|
||
enemies = data.get('enemies', [])
|
||
|
||
if not session_id or not character_id:
|
||
return error_response("session_id and character_id required", 400)
|
||
|
||
# Load character
|
||
char_service = get_character_service()
|
||
character = char_service.get_character(character_id, g.user_id)
|
||
|
||
# Initiate combat
|
||
combat_service = CombatService(get_appwrite_service())
|
||
encounter = combat_service.initiate_combat(session_id, character, enemies)
|
||
|
||
return created_response({
|
||
"combat_id": encounter.combat_id,
|
||
"turn_order": encounter.turn_order,
|
||
"current_turn": encounter.turn_order[0],
|
||
"round": encounter.round_number
|
||
})
|
||
|
||
|
||
@combat_bp.route('/<combat_id>/action', methods=['POST'])
|
||
@require_auth
|
||
def combat_action(combat_id: str):
|
||
"""
|
||
Take a combat action.
|
||
|
||
Request JSON:
|
||
{
|
||
"action_type": "attack", // "attack", "spell", "item", "defend"
|
||
"ability_id": "basic_attack",
|
||
"target_id": "enemy_1",
|
||
"item_id": null // for item use
|
||
}
|
||
|
||
Returns:
|
||
200 OK with action result
|
||
"""
|
||
data = request.get_json()
|
||
|
||
action_type = data.get('action_type')
|
||
ability_id = data.get('ability_id')
|
||
target_id = data.get('target_id')
|
||
item_id = data.get('item_id')
|
||
|
||
try:
|
||
combat_service = CombatService(get_appwrite_service())
|
||
result = combat_service.process_action(
|
||
combat_id,
|
||
action_type,
|
||
ability_id,
|
||
target_id,
|
||
item_id
|
||
)
|
||
|
||
# Get updated encounter state
|
||
encounter = combat_service.get_encounter(combat_id)
|
||
|
||
return success_response({
|
||
"action_result": result,
|
||
"combat_state": {
|
||
"current_turn": encounter.turn_order[encounter.current_turn_index] if encounter.turn_order else None,
|
||
"round": encounter.round_number,
|
||
"status": encounter.status.value,
|
||
"combatants": [c.to_dict() for c in encounter.combatants]
|
||
}
|
||
})
|
||
|
||
except CombatNotFound:
|
||
return not_found_response("Combat not found")
|
||
except InvalidCombatAction as e:
|
||
return error_response(str(e), 400)
|
||
|
||
|
||
@combat_bp.route('/<combat_id>/state', methods=['GET'])
|
||
@require_auth
|
||
def get_combat_state(combat_id: str):
|
||
"""Get current combat state."""
|
||
try:
|
||
combat_service = CombatService(get_appwrite_service())
|
||
encounter = combat_service.get_encounter(combat_id)
|
||
|
||
return success_response({
|
||
"combat_id": encounter.combat_id,
|
||
"status": encounter.status.value,
|
||
"round": encounter.round_number,
|
||
"turn_order": encounter.turn_order,
|
||
"current_turn_index": encounter.current_turn_index,
|
||
"combatants": [c.to_dict() for c in encounter.combatants],
|
||
"combat_log": encounter.combat_log[-10:] # Last 10 messages
|
||
})
|
||
|
||
except CombatNotFound:
|
||
return not_found_response("Combat not found")
|
||
|
||
|
||
@combat_bp.route('/<combat_id>/results', methods=['GET'])
|
||
@require_auth
|
||
def get_combat_results(combat_id: str):
|
||
"""Get combat results (loot, XP, etc.)."""
|
||
try:
|
||
combat_service = CombatService(get_appwrite_service())
|
||
encounter = combat_service.get_encounter(combat_id)
|
||
|
||
if encounter.status not in [CombatStatus.VICTORY, CombatStatus.DEFEAT]:
|
||
return error_response("Combat is still in progress", 400)
|
||
|
||
# Calculate rewards (TODO: implement loot/XP system)
|
||
results = {
|
||
"victory": encounter.status == CombatStatus.VICTORY,
|
||
"xp_gained": 100, # Placeholder
|
||
"gold_gained": 50, # Placeholder
|
||
"loot": [] # Placeholder
|
||
}
|
||
|
||
return success_response(results)
|
||
|
||
except CombatNotFound:
|
||
return not_found_response("Combat not found")
|
||
```
|
||
|
||
**Don't forget to register blueprint in `/api/app/__init__.py`:**
|
||
|
||
```python
|
||
from app.api.combat import combat_bp
|
||
app.register_blueprint(combat_bp, url_prefix='/api/v1/combat')
|
||
```
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- `POST /api/v1/combat/start` creates combat encounter
|
||
- `POST /api/v1/combat/<session_id>/action` processes actions
|
||
- `GET /api/v1/combat/<session_id>/state` returns current state
|
||
- `POST /api/v1/combat/<session_id>/flee` attempts to flee
|
||
- `POST /api/v1/combat/<session_id>/enemy-turn` executes enemy AI
|
||
- `GET /api/v1/combat/enemies` lists enemy templates (public)
|
||
- `GET /api/v1/combat/enemies/<id>` gets enemy details (public)
|
||
- All combat endpoints require authentication (except enemy listing)
|
||
- 19 integration tests passing
|
||
|
||
---
|
||
|
||
#### Task 1.7: Manual API Testing (4 hours) ⏭️ SKIPPED
|
||
|
||
**Objective:** Test combat flow end-to-end via API
|
||
|
||
**Test Cases:**
|
||
|
||
1. **Start Combat**
|
||
```bash
|
||
curl -X POST http://localhost:5000/api/v1/combat/start \
|
||
-H "Authorization: Bearer <token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"session_id": "session_123",
|
||
"character_id": "char_abc",
|
||
"enemies": [
|
||
{
|
||
"name": "Goblin",
|
||
"level": 2,
|
||
"stats": {
|
||
"strength": 8,
|
||
"defense": 5,
|
||
"max_hp": 30
|
||
}
|
||
}
|
||
]
|
||
}'
|
||
```
|
||
|
||
2. **Take Attack Action**
|
||
```bash
|
||
curl -X POST http://localhost:5000/api/v1/combat/<combat_id>/action \
|
||
-H "Authorization: Bearer <token>" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"action_type": "attack",
|
||
"ability_id": "basic_attack",
|
||
"target_id": "enemy_0"
|
||
}'
|
||
```
|
||
|
||
3. **Get Combat State**
|
||
```bash
|
||
curl -X GET http://localhost:5000/api/v1/combat/<combat_id>/state \
|
||
-H "Authorization: Bearer <token>"
|
||
```
|
||
|
||
**Document in `/api/docs/API_TESTING.md`**
|
||
|
||
**Acceptance Criteria:** ⏭️ SKIPPED (covered by automated tests)
|
||
- All endpoints return correct responses - ✅ via test_combat_api.py
|
||
- Combat flows from start to victory/defeat - ✅ via test_combat_service.py
|
||
- Damage calculations verified - ✅ via test_damage_calculator.py
|
||
- Effects process correctly - ✅ via test_combat_service.py
|
||
- Turn order maintained - ✅ via test_combat_service.py
|
||
|
||
> **Note:** Manual testing skipped in favor of 108 comprehensive automated tests.
|
||
|
||
---
|
||
|
||
### Week 2: Inventory & Equipment System ✅ COMPLETE
|
||
|
||
#### Task 2.1: Item Data Models ✅ COMPLETE
|
||
|
||
**Objective:** Implement Diablo-style item generation with affixes
|
||
|
||
**Files Implemented:**
|
||
- `/api/app/models/items.py` - Item dataclass with affix support
|
||
- `/api/app/models/affixes.py` - Affix, BaseItemTemplate dataclasses
|
||
- `/api/app/models/enums.py` - ItemRarity, AffixType, AffixTier enums
|
||
|
||
**Item Model - New Fields for Generated Items:**
|
||
|
||
```python
|
||
@dataclass
|
||
class Item:
|
||
# ... existing fields ...
|
||
|
||
# Affix tracking (for generated items)
|
||
applied_affixes: List[str] = field(default_factory=list)
|
||
base_template_id: Optional[str] = None
|
||
generated_name: Optional[str] = None
|
||
is_generated: bool = False
|
||
|
||
def get_display_name(self) -> str:
|
||
"""Return generated name if available, otherwise base name."""
|
||
return self.generated_name if self.generated_name else self.name
|
||
```
|
||
|
||
**Affix Model:**
|
||
|
||
```python
|
||
@dataclass
|
||
class Affix:
|
||
affix_id: str
|
||
name: str # Display name ("Flaming", "of Strength")
|
||
affix_type: AffixType # PREFIX or SUFFIX
|
||
tier: AffixTier # MINOR, MAJOR, LEGENDARY
|
||
stat_bonuses: Dict[str, int] # {"strength": 3, "dexterity": 2}
|
||
damage_bonus: int = 0
|
||
defense_bonus: int = 0
|
||
damage_type: Optional[DamageType] = None # For elemental prefixes
|
||
elemental_ratio: float = 0.0
|
||
allowed_item_types: List[str] = field(default_factory=list)
|
||
```
|
||
|
||
**BaseItemTemplate Model:**
|
||
|
||
```python
|
||
@dataclass
|
||
class BaseItemTemplate:
|
||
template_id: str
|
||
name: str # "Dagger", "Longsword"
|
||
item_type: str # "weapon" or "armor"
|
||
base_damage: int = 0
|
||
base_defense: int = 0
|
||
base_value: int = 0
|
||
required_level: int = 1
|
||
min_rarity: str = "common" # Minimum rarity this can generate as
|
||
drop_weight: int = 100 # Relative drop chance
|
||
```
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- Item model supports both static and generated items
|
||
- Affix system with PREFIX/SUFFIX types
|
||
- Three affix tiers (MINOR, MAJOR, LEGENDARY)
|
||
- BaseItemTemplate for procedural generation foundation
|
||
- All models have to_dict()/from_dict() serialization
|
||
|
||
---
|
||
|
||
#### Task 2.2: Item Data Files ✅ COMPLETE
|
||
|
||
**Objective:** Create YAML data files for item generation system
|
||
|
||
**Directory Structure:**
|
||
|
||
```
|
||
/api/app/data/
|
||
├── items/ # Static items (consumables, quest items)
|
||
│ └── consumables/
|
||
│ └── potions.yaml
|
||
├── base_items/ # Base templates for generation
|
||
│ ├── weapons.yaml # 13 weapon templates
|
||
│ └── armor.yaml # 12 armor templates
|
||
└── affixes/ # Prefix/suffix definitions
|
||
├── prefixes.yaml # 18 prefixes
|
||
└── suffixes.yaml # 11 suffixes
|
||
```
|
||
|
||
**Example Base Weapon Template (`/api/app/data/base_items/weapons.yaml`):**
|
||
|
||
```yaml
|
||
dagger:
|
||
template_id: "dagger"
|
||
name: "Dagger"
|
||
item_type: "weapon"
|
||
base_damage: 6
|
||
damage_type: "physical"
|
||
crit_chance: 0.08
|
||
crit_multiplier: 2.0
|
||
base_value: 15
|
||
required_level: 1
|
||
drop_weight: 100
|
||
```
|
||
|
||
**Example Prefix Affix (`/api/app/data/affixes/prefixes.yaml`):**
|
||
|
||
```yaml
|
||
flaming:
|
||
affix_id: "flaming"
|
||
name: "Flaming"
|
||
affix_type: "prefix"
|
||
tier: "minor"
|
||
damage_type: "fire"
|
||
elemental_ratio: 0.25
|
||
damage_bonus: 3
|
||
allowed_item_types: ["weapon"]
|
||
```
|
||
|
||
**Example Suffix Affix (`/api/app/data/affixes/suffixes.yaml`):**
|
||
|
||
```yaml
|
||
of_strength:
|
||
affix_id: "of_strength"
|
||
name: "of Strength"
|
||
affix_type: "suffix"
|
||
tier: "minor"
|
||
stat_bonuses:
|
||
strength: 3
|
||
```
|
||
|
||
**Items Created:**
|
||
- **Base Templates:** 25 total (13 weapons, 12 armor across cloth/leather/chain/plate)
|
||
- **Prefixes:** 18 total (elemental, material, quality, defensive, legendary)
|
||
- **Suffixes:** 11 total (stat bonuses, animal totems, defensive, legendary)
|
||
- **Static Consumables:** Health/mana potions (small/medium/large)
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- Base templates cover all weapon/armor categories
|
||
- Affixes balanced across tiers
|
||
- YAML files valid and loadable
|
||
|
||
---
|
||
|
||
#### Task 2.2.1: Item Generator Service ✅ COMPLETE
|
||
|
||
**Objective:** Implement procedural item generation with Diablo-style naming
|
||
|
||
**Files Implemented:**
|
||
- `/api/app/services/item_generator.py` - Main generation service (535 lines)
|
||
- `/api/app/services/affix_loader.py` - Loads affixes from YAML (316 lines)
|
||
- `/api/app/services/base_item_loader.py` - Loads base templates from YAML (274 lines)
|
||
|
||
**ItemGenerator Usage:**
|
||
|
||
```python
|
||
from app.services.item_generator import get_item_generator
|
||
from app.models.enums import ItemRarity
|
||
|
||
generator = get_item_generator()
|
||
|
||
# Generate specific item
|
||
item = generator.generate_item(
|
||
item_type="weapon",
|
||
rarity=ItemRarity.EPIC,
|
||
character_level=5
|
||
)
|
||
# Result: "Flaming Longsword of Strength" (1 prefix + 1 suffix)
|
||
|
||
# Generate random loot drop with luck influence
|
||
item = generator.generate_loot_drop(
|
||
character_level=10,
|
||
luck_stat=12 # Higher luck = better rarity chance
|
||
)
|
||
```
|
||
|
||
**Affix Distribution by Rarity:**
|
||
|
||
| Rarity | Affix Count | Distribution |
|
||
|--------|-------------|--------------|
|
||
| COMMON | 0 | Plain item |
|
||
| UNCOMMON | 0 | Plain item |
|
||
| RARE | 1 | 50% prefix OR 50% suffix |
|
||
| EPIC | 2 | 1 prefix AND 1 suffix |
|
||
| LEGENDARY | 3 | Mix (2+1 or 1+2) |
|
||
|
||
**Name Generation Examples:**
|
||
- COMMON: "Dagger"
|
||
- RARE: "Flaming Dagger" or "Dagger of Strength"
|
||
- EPIC: "Flaming Dagger of Strength"
|
||
- LEGENDARY: "Blazing Glacial Dagger of the Titan"
|
||
|
||
**Tier Weights by Rarity:**
|
||
|
||
| Rarity | MINOR | MAJOR | LEGENDARY |
|
||
|--------|-------|-------|-----------|
|
||
| RARE | 80% | 20% | 0% |
|
||
| EPIC | 30% | 70% | 0% |
|
||
| LEGENDARY | 10% | 40% | 50% |
|
||
|
||
**Rarity Rolling (with Luck):**
|
||
|
||
Base chances at luck 8:
|
||
- COMMON: 50%
|
||
- UNCOMMON: 30%
|
||
- RARE: 15%
|
||
- EPIC: 4%
|
||
- LEGENDARY: 1%
|
||
|
||
Luck bonus: `(luck - 8) * 0.005` per threshold
|
||
|
||
**Tests:** `/api/tests/test_item_generator.py` (528 lines, comprehensive coverage)
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- Procedural generation works for all rarities
|
||
- Affix selection respects tier weights
|
||
- Generated names follow Diablo naming convention
|
||
- Luck stat influences rarity rolls
|
||
- Stats properly combined from template + affixes
|
||
|
||
---
|
||
|
||
#### Task 2.3: Implement Inventory Service (1 day / 8 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Service layer for inventory management
|
||
|
||
**File:** `/api/app/services/inventory_service.py`
|
||
|
||
**Actual Implementation:**
|
||
|
||
The InventoryService was implemented as an orchestration layer on top of the existing Character model inventory methods. Key design decisions:
|
||
|
||
1. **Full Object Storage (Not IDs)**: The Character model already stores `List[Item]` for inventory and `Dict[str, Item]` for equipped items. This approach works better for generated items which have unique IDs.
|
||
|
||
2. **Validation Layer**: Added comprehensive validation for:
|
||
- Equipment slot validation (weapon, off_hand, helmet, chest, gloves, boots, accessory_1, accessory_2)
|
||
- Level and class requirements
|
||
- Item type to slot mapping
|
||
|
||
3. **Consumable Effects**: Supports instant healing (HOT effects) and duration-based buffs for combat integration.
|
||
|
||
4. **Tests**: 51 unit tests covering all functionality.
|
||
|
||
**Implementation:**
|
||
|
||
```python
|
||
"""
|
||
Inventory Service
|
||
|
||
Manages character inventory, equipment, and item usage.
|
||
"""
|
||
|
||
from typing import List, Optional
|
||
from app.models.items import Item, ItemType
|
||
from app.models.character import Character
|
||
from app.services.item_loader import ItemLoader
|
||
from app.services.appwrite_service import AppwriteService
|
||
from app.utils.logging import get_logger
|
||
|
||
logger = get_logger(__file__)
|
||
|
||
|
||
class InventoryError(Exception):
|
||
"""Base exception for inventory errors."""
|
||
pass
|
||
|
||
|
||
class ItemNotFound(InventoryError):
|
||
"""Raised when item is not found in inventory."""
|
||
pass
|
||
|
||
|
||
class CannotEquipItem(InventoryError):
|
||
"""Raised when item cannot be equipped."""
|
||
pass
|
||
|
||
|
||
class InventoryService:
|
||
"""Service for managing character inventory."""
|
||
|
||
def __init__(self, appwrite_service: AppwriteService):
|
||
self.appwrite = appwrite_service
|
||
self.item_loader = ItemLoader()
|
||
|
||
def get_inventory(self, character: Character) -> List[Item]:
|
||
"""
|
||
Get character's inventory.
|
||
|
||
Args:
|
||
character: Character instance
|
||
|
||
Returns:
|
||
List of Item instances
|
||
"""
|
||
return [self.item_loader.get_item(item_id) for item_id in character.inventory_item_ids]
|
||
|
||
def add_item(self, character: Character, item_id: str) -> None:
|
||
"""
|
||
Add item to character inventory.
|
||
|
||
Args:
|
||
character: Character instance
|
||
item_id: Item ID to add
|
||
"""
|
||
if item_id not in character.inventory_item_ids:
|
||
character.inventory_item_ids.append(item_id)
|
||
logger.info(f"Added item {item_id} to character {character.character_id}")
|
||
|
||
def remove_item(self, character: Character, item_id: str) -> None:
|
||
"""
|
||
Remove item from inventory.
|
||
|
||
Args:
|
||
character: Character instance
|
||
item_id: Item ID to remove
|
||
|
||
Raises:
|
||
ItemNotFound: If item not in inventory
|
||
"""
|
||
if item_id not in character.inventory_item_ids:
|
||
raise ItemNotFound(f"Item {item_id} not in inventory")
|
||
|
||
character.inventory_item_ids.remove(item_id)
|
||
logger.info(f"Removed item {item_id} from character {character.character_id}")
|
||
|
||
def equip_item(self, character: Character, item_id: str, slot: str) -> None:
|
||
"""
|
||
Equip item to character.
|
||
|
||
Args:
|
||
character: Character instance
|
||
item_id: Item ID to equip
|
||
slot: Equipment slot (weapon, helmet, chest, boots, etc.)
|
||
|
||
Raises:
|
||
ItemNotFound: If item not in inventory
|
||
CannotEquipItem: If item cannot be equipped
|
||
"""
|
||
if item_id not in character.inventory_item_ids:
|
||
raise ItemNotFound(f"Item {item_id} not in inventory")
|
||
|
||
item = self.item_loader.get_item(item_id)
|
||
|
||
# Validate item type matches slot
|
||
if item.item_type == ItemType.WEAPON and slot != "weapon":
|
||
raise CannotEquipItem("Weapon can only be equipped in weapon slot")
|
||
|
||
if item.item_type == ItemType.ARMOR:
|
||
# Armor has sub-types (helmet, chest, boots)
|
||
# Add validation based on item.armor_slot field
|
||
pass
|
||
|
||
if item.item_type == ItemType.CONSUMABLE:
|
||
raise CannotEquipItem("Consumables cannot be equipped")
|
||
|
||
# Check level requirement
|
||
if character.level < item.required_level:
|
||
raise CannotEquipItem(f"Requires level {item.required_level}")
|
||
|
||
# Unequip current item in slot (if any)
|
||
current_item_id = character.equipped.get(slot)
|
||
if current_item_id:
|
||
# Current item returns to inventory (already there)
|
||
pass
|
||
|
||
# Equip new item
|
||
character.equipped[slot] = item_id
|
||
|
||
logger.info(f"Equipped {item_id} to {slot} for character {character.character_id}")
|
||
|
||
def unequip_item(self, character: Character, slot: str) -> None:
|
||
"""
|
||
Unequip item from slot.
|
||
|
||
Args:
|
||
character: Character instance
|
||
slot: Equipment slot
|
||
"""
|
||
if slot not in character.equipped:
|
||
return
|
||
|
||
item_id = character.equipped[slot]
|
||
del character.equipped[slot]
|
||
|
||
logger.info(f"Unequipped {item_id} from {slot} for character {character.character_id}")
|
||
|
||
def use_consumable(self, character: Character, item_id: str) -> dict:
|
||
"""
|
||
Use consumable item.
|
||
|
||
Args:
|
||
character: Character instance
|
||
item_id: Consumable item ID
|
||
|
||
Returns:
|
||
Dict with effects applied
|
||
|
||
Raises:
|
||
ItemNotFound: If item not in inventory
|
||
CannotEquipItem: If item is not consumable
|
||
"""
|
||
if item_id not in character.inventory_item_ids:
|
||
raise ItemNotFound(f"Item {item_id} not in inventory")
|
||
|
||
item = self.item_loader.get_item(item_id)
|
||
|
||
if item.item_type != ItemType.CONSUMABLE:
|
||
raise CannotEquipItem("Only consumables can be used")
|
||
|
||
# Apply effects (healing, mana, buffs)
|
||
effects_applied = []
|
||
|
||
if hasattr(item, 'hp_restore') and item.hp_restore > 0:
|
||
old_hp = character.current_hp
|
||
character.current_hp = min(character.current_hp + item.hp_restore, character.stats.max_hp)
|
||
actual_healing = character.current_hp - old_hp
|
||
effects_applied.append(f"Restored {actual_healing} HP")
|
||
|
||
if hasattr(item, 'mp_restore') and item.mp_restore > 0:
|
||
old_mp = character.current_mp
|
||
character.current_mp = min(character.current_mp + item.mp_restore, character.stats.max_mp)
|
||
actual_restore = character.current_mp - old_mp
|
||
effects_applied.append(f"Restored {actual_restore} MP")
|
||
|
||
# Remove item from inventory (consumables are single-use)
|
||
self.remove_item(character, item_id)
|
||
|
||
logger.info(f"Used consumable {item_id} for character {character.character_id}")
|
||
|
||
return {
|
||
"item_used": item.name,
|
||
"effects": effects_applied
|
||
}
|
||
```
|
||
|
||
**Also create `/api/app/services/item_loader.py`:**
|
||
|
||
```python
|
||
"""
|
||
Item Loader
|
||
|
||
Loads items from YAML data files.
|
||
"""
|
||
|
||
import os
|
||
import yaml
|
||
from typing import Dict, Optional
|
||
from app.models.items import Item
|
||
from app.utils.logging import get_logger
|
||
|
||
logger = get_logger(__file__)
|
||
|
||
|
||
class ItemLoader:
|
||
"""Loads and caches items from YAML files."""
|
||
|
||
def __init__(self):
|
||
self.items: Dict[str, Item] = {}
|
||
self._load_all_items()
|
||
|
||
def _load_all_items(self) -> None:
|
||
"""Load all items from YAML files."""
|
||
base_dir = "app/data/items"
|
||
categories = ["weapons", "armor", "consumables"]
|
||
|
||
for category in categories:
|
||
category_dir = os.path.join(base_dir, category)
|
||
if not os.path.exists(category_dir):
|
||
continue
|
||
|
||
for yaml_file in os.listdir(category_dir):
|
||
if not yaml_file.endswith('.yaml'):
|
||
continue
|
||
|
||
filepath = os.path.join(category_dir, yaml_file)
|
||
self._load_items_from_file(filepath)
|
||
|
||
logger.info(f"Loaded {len(self.items)} items from YAML files")
|
||
|
||
def _load_items_from_file(self, filepath: str) -> None:
|
||
"""Load items from a single YAML file."""
|
||
with open(filepath, 'r') as f:
|
||
items_data = yaml.safe_load(f)
|
||
|
||
for item_data in items_data:
|
||
item = Item.from_dict(item_data)
|
||
self.items[item.item_id] = item
|
||
|
||
def get_item(self, item_id: str) -> Optional[Item]:
|
||
"""Get item by ID."""
|
||
return self.items.get(item_id)
|
||
|
||
def get_all_items(self) -> Dict[str, Item]:
|
||
"""Get all loaded items."""
|
||
return self.items
|
||
```
|
||
|
||
**Note on Generated Items:**
|
||
|
||
The inventory service must handle both static items (loaded by ID) and generated items
|
||
(stored as full objects). Generated items have unique IDs (`gen_<uuid>`) and cannot be
|
||
looked up from YAML - they must be stored/retrieved as complete Item objects.
|
||
|
||
```python
|
||
# For static items (consumables, quest items)
|
||
item = item_loader.get_item("health_potion_small")
|
||
|
||
# For generated items - store full object
|
||
generated_item = generator.generate_loot_drop(level, luck)
|
||
character.inventory.append(generated_item.to_dict()) # Store full item data
|
||
```
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- [x] Inventory service can add/remove items - `add_item()`, `remove_item()`, `drop_item()`
|
||
- [x] Equip/unequip works with validation - `equip_item()`, `unequip_item()` with slot/level/type checks
|
||
- [x] Consumables can be used (healing, mana restore) - `use_consumable()`, `use_consumable_in_combat()`
|
||
- [x] Character's equipped items tracked - via `get_equipped_items()`, `get_equipped_item()`
|
||
- [x] **Generated items stored as full objects (not just IDs)** - Character model uses `List[Item]`
|
||
- [x] Bulk operations - `add_items()`, `get_items_by_type()`, `get_equippable_items()`
|
||
|
||
**Tests:** `/api/tests/test_inventory_service.py` - 51 tests
|
||
|
||
---
|
||
|
||
#### Task 2.4: Inventory API Endpoints (1 day / 8 hours) ✅ COMPLETE
|
||
|
||
**Objective:** REST API for inventory management
|
||
|
||
**Files Implemented:**
|
||
- `/api/app/api/inventory.py` - API blueprint (530 lines)
|
||
- `/api/tests/test_inventory_api.py` - Integration tests (25 tests)
|
||
|
||
**Endpoints Implemented:**
|
||
|
||
| Method | Endpoint | Description |
|
||
|--------|----------|-------------|
|
||
| GET | `/api/v1/characters/<id>/inventory` | Get inventory + equipped items |
|
||
| POST | `/api/v1/characters/<id>/inventory/equip` | Equip item to slot |
|
||
| POST | `/api/v1/characters/<id>/inventory/unequip` | Unequip from slot |
|
||
| POST | `/api/v1/characters/<id>/inventory/use` | Use consumable item |
|
||
| DELETE | `/api/v1/characters/<id>/inventory/<item_id>` | Drop/remove item |
|
||
|
||
**Exception Handling:**
|
||
- `CharacterNotFound` → 404 Not Found
|
||
- `ItemNotFoundError` → 404 Not Found
|
||
- `InvalidSlotError` → 422 Validation Error
|
||
- `CannotEquipError` → 400 Bad Request
|
||
- `CannotUseItemError` → 400 Bad Request
|
||
- `InventoryFullError` → 400 Bad Request
|
||
|
||
**Response Examples:**
|
||
|
||
```json
|
||
// GET /api/v1/characters/{id}/inventory
|
||
{
|
||
"result": {
|
||
"inventory": [{"item_id": "...", "name": "...", ...}],
|
||
"equipped": {
|
||
"weapon": {...},
|
||
"helmet": null,
|
||
...
|
||
},
|
||
"inventory_count": 5,
|
||
"max_inventory": 100
|
||
}
|
||
}
|
||
|
||
// POST /api/v1/characters/{id}/inventory/equip
|
||
{
|
||
"result": {
|
||
"message": "Equipped Flaming Dagger to weapon slot",
|
||
"equipped": {...},
|
||
"unequipped_item": null
|
||
}
|
||
}
|
||
```
|
||
|
||
**Blueprint registered in `/api/app/__init__.py`**
|
||
|
||
**Tests:** 25 passing (`/api/tests/test_inventory_api.py`)
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- [x] All inventory endpoints functional
|
||
- [x] Authentication required on all endpoints
|
||
- [x] Ownership validation enforced
|
||
- [x] Errors handled gracefully with proper HTTP status codes
|
||
|
||
---
|
||
|
||
#### Task 2.5: Update Character Stats Calculation (4 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Ensure `get_effective_stats()` includes equipped items' combat bonuses
|
||
|
||
**Files Modified:**
|
||
- `/api/app/models/stats.py` - Added `damage_bonus`, `defense_bonus`, `resistance_bonus` fields
|
||
- `/api/app/models/character.py` - Updated `get_effective_stats()` to populate bonus fields
|
||
|
||
**Implementation Summary:**
|
||
|
||
The Stats model now has three equipment bonus fields that are populated by `get_effective_stats()`:
|
||
|
||
```python
|
||
# Stats model additions
|
||
damage_bonus: int = 0 # From weapons
|
||
defense_bonus: int = 0 # From armor
|
||
resistance_bonus: int = 0 # From armor
|
||
|
||
# Updated computed properties
|
||
@property
|
||
def damage(self) -> int:
|
||
return (self.strength // 2) + self.damage_bonus
|
||
|
||
@property
|
||
def defense(self) -> int:
|
||
return (self.constitution // 2) + self.defense_bonus
|
||
|
||
@property
|
||
def resistance(self) -> int:
|
||
return (self.wisdom // 2) + self.resistance_bonus
|
||
```
|
||
|
||
The `get_effective_stats()` method now applies:
|
||
1. `stat_bonuses` dict from all equipped items (as before)
|
||
2. Weapon `damage` → `damage_bonus`
|
||
3. Armor `defense` → `defense_bonus`
|
||
4. Armor `resistance` → `resistance_bonus`
|
||
|
||
**Tests Added:**
|
||
- `/api/tests/test_stats.py` - 11 new tests for bonus fields
|
||
- `/api/tests/test_character.py` - 6 new tests for equipment combat bonuses
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- [x] Equipped weapons add damage (via `damage_bonus`)
|
||
- [x] Equipped armor adds defense/resistance (via `defense_bonus`/`resistance_bonus`)
|
||
- [x] Stat bonuses from items apply correctly
|
||
- [x] Skills still apply bonuses
|
||
- [x] Effects still modify stats
|
||
|
||
---
|
||
|
||
#### Task 2.6: Equipment-Combat Integration (4 hours) ✅ COMPLETE
|
||
|
||
**Objective:** Fully integrate equipment stats into combat damage calculations, replacing hardcoded weapon damage values with effective_stats properties.
|
||
|
||
**Files Modified:**
|
||
- `/api/app/models/stats.py` - Updated damage formula, added spell_power system
|
||
- `/api/app/models/items.py` - Added spell_power field for magical weapons
|
||
- `/api/app/models/character.py` - Populate spell_power_bonus in get_effective_stats()
|
||
- `/api/app/models/combat.py` - Added weapon property fields to Combatant
|
||
- `/api/app/services/combat_service.py` - Updated combatant creation and attack execution
|
||
- `/api/app/services/damage_calculator.py` - Use stats properties instead of weapon_damage param
|
||
|
||
**Implementation Summary:**
|
||
|
||
**1. Updated Damage Formula (Stats Model)**
|
||
|
||
Changed damage scaling from `STR // 2` to `int(STR * 0.75)` for better progression:
|
||
|
||
```python
|
||
# Old formula
|
||
@property
|
||
def damage(self) -> int:
|
||
return (self.strength // 2) + self.damage_bonus
|
||
|
||
# New formula (0.75 scaling factor)
|
||
@property
|
||
def damage(self) -> int:
|
||
return int(self.strength * 0.75) + self.damage_bonus
|
||
```
|
||
|
||
**2. Added Spell Power System**
|
||
|
||
Symmetric system for magical weapons (staves, wands):
|
||
|
||
```python
|
||
# Stats model additions
|
||
spell_power_bonus: int = 0 # From magical weapons
|
||
|
||
@property
|
||
def spell_power(self) -> int:
|
||
"""Magical damage: int(INT * 0.75) + spell_power_bonus."""
|
||
return int(self.intelligence * 0.75) + self.spell_power_bonus
|
||
|
||
# Item model additions
|
||
spell_power: int = 0 # Spell power bonus for magical weapons
|
||
|
||
def is_magical_weapon(self) -> bool:
|
||
"""Check if this is a magical weapon (uses spell_power)."""
|
||
return self.is_weapon() and self.spell_power > 0
|
||
```
|
||
|
||
**3. Combatant Weapon Properties**
|
||
|
||
Added weapon properties to Combatant model for combat-time access:
|
||
|
||
```python
|
||
# Weapon combat properties
|
||
weapon_crit_chance: float = 0.05
|
||
weapon_crit_multiplier: float = 2.0
|
||
weapon_damage_type: Optional[DamageType] = None
|
||
|
||
# Elemental weapon support
|
||
elemental_damage_type: Optional[DamageType] = None
|
||
physical_ratio: float = 1.0
|
||
elemental_ratio: float = 0.0
|
||
```
|
||
|
||
**4. DamageCalculator Refactored**
|
||
|
||
Removed `weapon_damage` parameter - now uses `attacker_stats.damage` directly:
|
||
|
||
```python
|
||
# Old signature
|
||
def calculate_physical_damage(
|
||
attacker_stats: Stats,
|
||
defender_stats: Stats,
|
||
weapon_damage: int, # Separate parameter
|
||
...
|
||
)
|
||
|
||
# New signature
|
||
def calculate_physical_damage(
|
||
attacker_stats: Stats, # stats.damage includes weapon bonus
|
||
defender_stats: Stats,
|
||
...
|
||
)
|
||
|
||
# Formula now uses:
|
||
base_damage = attacker_stats.damage + ability_base_power # Physical
|
||
base_damage = attacker_stats.spell_power + ability_base_power # Magical
|
||
```
|
||
|
||
**5. Combat Service Updates**
|
||
|
||
- `_create_combatant_from_character()` extracts weapon properties from equipped weapon
|
||
- `_create_combatant_from_enemy()` uses `stats.damage_bonus = template.base_damage`
|
||
- Removed hardcoded `_get_weapon_damage()` method
|
||
- `_execute_attack()` handles elemental weapons with split damage
|
||
|
||
**Tests Updated:**
|
||
- `/api/tests/test_stats.py` - Updated damage formula tests (0.75 scaling)
|
||
- `/api/tests/test_character.py` - Updated equipment bonus tests
|
||
- `/api/tests/test_damage_calculator.py` - Removed weapon_damage parameter from calls
|
||
- `/api/tests/test_combat_service.py` - Added `equipped` attribute to mock fixture
|
||
|
||
**Test Results:** 140 tests passing for all modified components
|
||
|
||
**Acceptance Criteria:** ✅ MET
|
||
- [x] Damage uses `effective_stats.damage` (includes weapon bonus)
|
||
- [x] Spell power uses `effective_stats.spell_power` (includes staff/wand bonus)
|
||
- [x] 0.75 scaling factor for both physical and magical damage
|
||
- [x] Weapon crit chance/multiplier flows through to combat
|
||
- [x] Elemental weapons support split physical/elemental damage
|
||
- [x] Enemy combatants use template base_damage correctly
|
||
- [x] All existing tests pass with updated formulas
|
||
|
||
---
|
||
|
||
### Future Work: Combat Loot Integration
|
||
|
||
**Status:** Planned for future phase
|
||
|
||
The ItemGenerator is ready for integration with combat loot drops. Future implementation will:
|
||
|
||
**1. Update Enemy Loot Tables** - Add procedural generation options:
|
||
|
||
```yaml
|
||
# Example enhanced enemy loot entry
|
||
loot_table:
|
||
- type: "static"
|
||
item_id: "health_potion_small"
|
||
drop_chance: 0.5
|
||
- type: "generated"
|
||
item_type: "weapon"
|
||
rarity_range: ["rare", "epic"]
|
||
drop_chance: 0.1
|
||
```
|
||
|
||
**2. Integrate with CombatService._calculate_rewards()** - Use ItemGenerator for loot rolls
|
||
|
||
**3. Boss Guaranteed Drops** - Higher-tier enemies guarantee better rarity
|
||
|
||
**4. Luck Stat Integration** - Player luck affects all loot rolls
|
||
|
||
**Implementation Notes:**
|
||
- Current enemy loot tables use `item_id` references (static items only)
|
||
- ItemGenerator provides `generate_loot_drop(character_level, luck_stat)` method
|
||
- Generated items must be stored as full objects (not IDs) in character inventory
|
||
- Consider adding `LootService` wrapper for consistent loot generation across all sources
|
||
|
||
---
|
||
|
||
### Week 3: Combat UI
|
||
|
||
#### Task 3.1: Create Combat Template (1 day / 8 hours)
|
||
|
||
**Objective:** Build HTMX-powered combat interface
|
||
|
||
**File:** `/public_web/templates/game/combat.html`
|
||
|
||
**Layout:**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ COMBAT ENCOUNTER │
|
||
├───────────────┬─────────────────────────┬───────────────────┤
|
||
│ │ │ │
|
||
│ YOUR │ COMBAT LOG │ TURN ORDER │
|
||
│ CHARACTER │ │ ─────────── │
|
||
│ ───────── │ Goblin attacks you │ 1. Aragorn ✓ │
|
||
│ HP: ████ 80 │ for 12 damage! │ 2. Goblin │
|
||
│ MP: ███ 60 │ │ 3. Orc │
|
||
│ │ You attack Goblin │ │
|
||
│ ENEMY │ for 18 damage! │ ACTIVE EFFECTS │
|
||
│ ───────── │ CRITICAL HIT! │ ─────────── │
|
||
│ Goblin │ │ 🛡️ Defending │
|
||
│ HP: ██ 12 │ Goblin is stunned! │ (1 turn) │
|
||
│ │ │ │
|
||
│ │ ───────────────── │ │
|
||
│ │ ACTION BUTTONS │ │
|
||
│ │ ───────────────── │ │
|
||
│ │ [Attack] [Spell] │ │
|
||
│ │ [Item] [Defend] │ │
|
||
│ │ │ │
|
||
└───────────────┴─────────────────────────┴───────────────────┘
|
||
```
|
||
|
||
**Implementation:**
|
||
|
||
```html
|
||
{% extends "base.html" %}
|
||
|
||
{% block title %}Combat - Code of Conquest{% endblock %}
|
||
|
||
{% block extra_head %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/combat.css') }}">
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="combat-container">
|
||
<h1 class="combat-title">⚔️ COMBAT ENCOUNTER</h1>
|
||
|
||
<div class="combat-grid">
|
||
{# Left Panel - Combatants #}
|
||
<aside class="combat-panel combat-combatants">
|
||
<div class="combatant-card player-card">
|
||
<h3>{{ character.name }}</h3>
|
||
<div class="hp-bar">
|
||
<div class="hp-fill" style="width: {{ (character.current_hp / character.stats.max_hp * 100)|int }}%"></div>
|
||
<span class="hp-text">HP: {{ character.current_hp }} / {{ character.stats.max_hp }}</span>
|
||
</div>
|
||
<div class="mp-bar">
|
||
<div class="mp-fill" style="width: {{ (character.current_mp / character.stats.max_mp * 100)|int }}%"></div>
|
||
<span class="mp-text">MP: {{ character.current_mp }} / {{ character.stats.max_mp }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vs-divider">VS</div>
|
||
|
||
{% for enemy in enemies %}
|
||
<div class="combatant-card enemy-card" id="enemy-{{ loop.index0 }}">
|
||
<h3>{{ enemy.name }}</h3>
|
||
<div class="hp-bar">
|
||
<div class="hp-fill enemy" style="width: {{ (enemy.current_hp / enemy.stats.max_hp * 100)|int }}%"></div>
|
||
<span class="hp-text">HP: {{ enemy.current_hp }} / {{ enemy.stats.max_hp }}</span>
|
||
</div>
|
||
{% if enemy.current_hp > 0 %}
|
||
<button class="btn btn-target" onclick="selectTarget('{{ enemy.combatant_id }}')">
|
||
Target
|
||
</button>
|
||
{% else %}
|
||
<span class="defeated-badge">DEFEATED</span>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</aside>
|
||
|
||
{# Middle Panel - Combat Log & Actions #}
|
||
<section class="combat-panel combat-main">
|
||
<div class="combat-log" id="combat-log">
|
||
<h3>Combat Log</h3>
|
||
<div class="log-entries">
|
||
{% for entry in combat_log[-10:] %}
|
||
<div class="log-entry">{{ entry }}</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="combat-actions" id="combat-actions">
|
||
<h3>Your Turn</h3>
|
||
<div class="action-buttons">
|
||
<button class="btn btn-action btn-attack"
|
||
hx-post="/combat/{{ combat_id }}/action"
|
||
hx-vals='{"action_type": "attack", "ability_id": "basic_attack", "target_id": ""}'
|
||
hx-target="#combat-container"
|
||
hx-swap="outerHTML">
|
||
⚔️ Attack
|
||
</button>
|
||
|
||
<button class="btn btn-action btn-spell"
|
||
onclick="openSpellMenu()">
|
||
✨ Cast Spell
|
||
</button>
|
||
|
||
<button class="btn btn-action btn-item"
|
||
onclick="openItemMenu()">
|
||
🎒 Use Item
|
||
</button>
|
||
|
||
<button class="btn btn-action btn-defend"
|
||
hx-post="/combat/{{ combat_id }}/action"
|
||
hx-vals='{"action_type": "defend"}'
|
||
hx-target="#combat-container"
|
||
hx-swap="outerHTML">
|
||
🛡️ Defend
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{# Right Panel - Turn Order & Effects #}
|
||
<aside class="combat-panel combat-sidebar">
|
||
<div class="turn-order">
|
||
<h3>Turn Order</h3>
|
||
<ol>
|
||
{% for combatant_id in turn_order %}
|
||
<li class="{% if loop.index0 == current_turn_index %}active-turn{% endif %}">
|
||
{{ get_combatant_name(combatant_id) }}
|
||
{% if loop.index0 == current_turn_index %}✓{% endif %}
|
||
</li>
|
||
{% endfor %}
|
||
</ol>
|
||
</div>
|
||
|
||
<div class="active-effects">
|
||
<h3>Active Effects</h3>
|
||
{% for effect in character.active_effects %}
|
||
<div class="effect-badge {{ effect.effect_type }}">
|
||
{{ effect.name }} ({{ effect.duration }})
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
|
||
{# Modal Container #}
|
||
<div id="modal-container"></div>
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script>
|
||
let selectedTargetId = null;
|
||
|
||
function selectTarget(targetId) {
|
||
selectedTargetId = targetId;
|
||
|
||
// Update UI to show selected target
|
||
document.querySelectorAll('.btn-target').forEach(btn => {
|
||
btn.classList.remove('selected');
|
||
});
|
||
event.target.classList.add('selected');
|
||
}
|
||
|
||
function openSpellMenu() {
|
||
// TODO: Open modal with spell selection
|
||
}
|
||
|
||
function openItemMenu() {
|
||
// TODO: Open modal with item selection
|
||
}
|
||
|
||
// Auto-scroll combat log to bottom
|
||
const logDiv = document.querySelector('.log-entries');
|
||
if (logDiv) {
|
||
logDiv.scrollTop = logDiv.scrollHeight;
|
||
}
|
||
</script>
|
||
{% endblock %}
|
||
```
|
||
|
||
**Also create `/public_web/static/css/combat.css`**
|
||
|
||
**Acceptance Criteria:**
|
||
- 3-column layout works
|
||
- Combat log displays messages
|
||
- HP/MP bars update dynamically
|
||
- Action buttons trigger HTMX requests
|
||
- Turn order displays correctly
|
||
- Active effects shown
|
||
|
||
---
|
||
|
||
#### Task 3.2: Combat HTMX Integration (1 day / 8 hours)
|
||
|
||
**Objective:** Wire combat UI to API via HTMX
|
||
|
||
**File:** `/public_web/app/views/combat.py`
|
||
|
||
**Implementation:**
|
||
|
||
```python
|
||
"""
|
||
Combat Views
|
||
|
||
Routes for combat UI.
|
||
"""
|
||
|
||
from flask import Blueprint, render_template, request, g, redirect, url_for
|
||
|
||
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__)
|
||
|
||
combat_bp = Blueprint('combat', __name__)
|
||
|
||
|
||
@combat_bp.route('/<combat_id>')
|
||
@require_auth
|
||
def combat_view(combat_id: str):
|
||
"""Display combat interface."""
|
||
api_client = APIClient()
|
||
|
||
try:
|
||
# Get combat state
|
||
response = api_client.get(f'/combat/{combat_id}/state')
|
||
combat_state = response['result']
|
||
|
||
return render_template(
|
||
'game/combat.html',
|
||
combat_id=combat_id,
|
||
combat_state=combat_state,
|
||
turn_order=combat_state['turn_order'],
|
||
current_turn_index=combat_state['current_turn_index'],
|
||
combat_log=combat_state['combat_log'],
|
||
character=combat_state['combatants'][0], # Player is first
|
||
enemies=combat_state['combatants'][1:] # Rest are enemies
|
||
)
|
||
|
||
except APIError as e:
|
||
logger.error(f"Failed to load combat {combat_id}: {e}")
|
||
return redirect(url_for('game.play'))
|
||
|
||
|
||
@combat_bp.route('/<combat_id>/action', methods=['POST'])
|
||
@require_auth
|
||
def combat_action(combat_id: str):
|
||
"""Process combat action (HTMX endpoint)."""
|
||
api_client = APIClient()
|
||
|
||
action_data = {
|
||
'action_type': request.form.get('action_type'),
|
||
'ability_id': request.form.get('ability_id'),
|
||
'target_id': request.form.get('target_id'),
|
||
'item_id': request.form.get('item_id')
|
||
}
|
||
|
||
try:
|
||
# Submit action to API
|
||
response = api_client.post(f'/combat/{combat_id}/action', json=action_data)
|
||
result = response['result']
|
||
|
||
# Check if combat ended
|
||
if result['combat_state']['status'] in ['victory', 'defeat']:
|
||
return redirect(url_for('combat.combat_results', combat_id=combat_id))
|
||
|
||
# Re-render combat view with updated state
|
||
return render_template(
|
||
'game/combat.html',
|
||
combat_id=combat_id,
|
||
combat_state=result['combat_state'],
|
||
turn_order=result['combat_state']['turn_order'],
|
||
current_turn_index=result['combat_state']['current_turn_index'],
|
||
combat_log=result['combat_state']['combat_log'],
|
||
character=result['combat_state']['combatants'][0],
|
||
enemies=result['combat_state']['combatants'][1:]
|
||
)
|
||
|
||
except APIError as e:
|
||
logger.error(f"Combat action failed: {e}")
|
||
return render_template('partials/error.html', error=str(e))
|
||
|
||
|
||
@combat_bp.route('/<combat_id>/results')
|
||
@require_auth
|
||
def combat_results(combat_id: str):
|
||
"""Display combat results (victory/defeat)."""
|
||
api_client = APIClient()
|
||
|
||
try:
|
||
response = api_client.get(f'/combat/{combat_id}/results')
|
||
results = response['result']
|
||
|
||
return render_template(
|
||
'game/combat_results.html',
|
||
victory=results['victory'],
|
||
xp_gained=results['xp_gained'],
|
||
gold_gained=results['gold_gained'],
|
||
loot=results['loot']
|
||
)
|
||
|
||
except APIError as e:
|
||
logger.error(f"Failed to load combat results: {e}")
|
||
return redirect(url_for('game.play'))
|
||
```
|
||
|
||
**Register blueprint in `/public_web/app/__init__.py`:**
|
||
|
||
```python
|
||
from app.views.combat import combat_bp
|
||
app.register_blueprint(combat_bp, url_prefix='/combat')
|
||
```
|
||
|
||
**Acceptance Criteria:**
|
||
- Combat view loads from API
|
||
- Action buttons submit to API
|
||
- Combat state updates dynamically
|
||
- Combat results shown at end
|
||
- Errors handled gracefully
|
||
|
||
---
|
||
|
||
#### Task 3.3: Inventory UI (1 day / 8 hours)
|
||
|
||
**Objective:** Add inventory accordion to character panel
|
||
|
||
**File:** `/public_web/templates/game/partials/character_panel.html`
|
||
|
||
**Add Inventory Section:**
|
||
|
||
```html
|
||
{# Existing character panel code #}
|
||
|
||
{# Add Inventory Accordion #}
|
||
<div class="panel-accordion" data-accordion="inventory">
|
||
<button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
|
||
<span>Inventory <span class="count">({{ character.inventory|length }}/{{ inventory_max }})</span></span>
|
||
<span class="accordion-icon">▼</span>
|
||
</button>
|
||
<div class="panel-accordion-content">
|
||
<div class="inventory-grid">
|
||
{% for item in inventory %}
|
||
<div class="inventory-item {{ item.rarity }}"
|
||
hx-get="/inventory/{{ character.character_id }}/item/{{ item.item_id }}"
|
||
hx-target="#modal-container"
|
||
hx-swap="innerHTML">
|
||
<img src="{{ item.icon_url or '/static/img/items/default.png' }}" alt="{{ item.name }}">
|
||
<span class="item-name">{{ item.name }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{# Equipment Section #}
|
||
<div class="panel-accordion" data-accordion="equipment">
|
||
<button class="panel-accordion-header" onclick="togglePanelAccordion(this)">
|
||
<span>Equipment</span>
|
||
<span class="accordion-icon">▼</span>
|
||
</button>
|
||
<div class="panel-accordion-content">
|
||
<div class="equipment-slots">
|
||
<div class="equipment-slot">
|
||
<label>Weapon:</label>
|
||
{% if character.equipped.weapon %}
|
||
<span class="equipped-item">{{ get_item_name(character.equipped.weapon) }}</span>
|
||
<button class="btn-small"
|
||
hx-post="/inventory/{{ character.character_id }}/unequip"
|
||
hx-vals='{"slot": "weapon"}'
|
||
hx-target="#character-panel"
|
||
hx-swap="outerHTML">
|
||
Unequip
|
||
</button>
|
||
{% else %}
|
||
<span class="empty-slot">Empty</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="equipment-slot">
|
||
<label>Helmet:</label>
|
||
{# Similar for helmet, chest, boots, etc. #}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Create `/public_web/templates/game/partials/item_modal.html`:**
|
||
|
||
```html
|
||
<div class="modal-overlay" onclick="closeModal()">
|
||
<div class="modal-content" onclick="event.stopPropagation()">
|
||
<div class="modal-header">
|
||
<h2 class="item-name {{ item.rarity }}">{{ item.name }}</h2>
|
||
<button class="modal-close" onclick="closeModal()">×</button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<p class="item-description">{{ item.description }}</p>
|
||
|
||
<div class="item-stats">
|
||
{% if item.item_type == 'weapon' %}
|
||
<p><strong>Damage:</strong> {{ item.damage }}</p>
|
||
<p><strong>Crit Chance:</strong> {{ (item.crit_chance * 100)|int }}%</p>
|
||
{% elif item.item_type == 'armor' %}
|
||
<p><strong>Defense:</strong> {{ item.defense }}</p>
|
||
<p><strong>Resistance:</strong> {{ item.resistance }}</p>
|
||
{% elif item.item_type == 'consumable' %}
|
||
<p><strong>HP Restore:</strong> {{ item.hp_restore }}</p>
|
||
<p><strong>MP Restore:</strong> {{ item.mp_restore }}</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<p class="item-value">Value: {{ item.value }} gold</p>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
{% if item.item_type == 'weapon' %}
|
||
<button class="btn btn-primary"
|
||
hx-post="/inventory/{{ character_id }}/equip"
|
||
hx-vals='{"item_id": "{{ item.item_id }}", "slot": "weapon"}'
|
||
hx-target="#character-panel"
|
||
hx-swap="outerHTML">
|
||
Equip Weapon
|
||
</button>
|
||
{% elif item.item_type == 'consumable' %}
|
||
<button class="btn btn-primary"
|
||
hx-post="/inventory/{{ character_id }}/use"
|
||
hx-vals='{"item_id": "{{ item.item_id }}"}'
|
||
hx-target="#character-panel"
|
||
hx-swap="outerHTML">
|
||
Use Item
|
||
</button>
|
||
{% endif %}
|
||
|
||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
**Acceptance Criteria:**
|
||
- Inventory displays in character panel
|
||
- Click item shows modal with details
|
||
- Equip/unequip works via HTMX
|
||
- Use consumable works
|
||
- Equipment slots show equipped items
|
||
|
||
---
|
||
|
||
#### Task 3.4: Combat Testing & Polish (1 day / 8 hours)
|
||
|
||
**Objective:** Playtest combat and fix bugs
|
||
|
||
**Testing Checklist:**
|
||
- [ ] Start combat from story session
|
||
- [ ] Turn order correct
|
||
- [ ] Attack deals damage
|
||
- [ ] Critical hits work
|
||
- [ ] Spells consume mana
|
||
- [ ] Effects apply and tick correctly
|
||
- [ ] Items can be used in combat
|
||
- [ ] Defend action works
|
||
- [ ] Victory awards XP/gold/loot
|
||
- [ ] Defeat handling works
|
||
- [ ] Combat log readable
|
||
- [ ] HP/MP bars update
|
||
- [ ] Multiple enemies work
|
||
- [ ] Combat state persists (refresh page)
|
||
|
||
**Bug Fixes & Polish:**
|
||
- Fix any calculation errors
|
||
- Improve combat log messages
|
||
- Add visual feedback (animations, highlights)
|
||
- Improve mobile responsiveness
|
||
- Add loading states
|
||
|
||
**Acceptance Criteria:**
|
||
- Combat flows smoothly start to finish
|
||
- No critical bugs
|
||
- UX feels responsive and clear
|
||
- Ready for real gameplay
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
---
|
||
|
||
## Phase 4C: NPC Shop (Days 15-18)
|
||
|
||
### 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
|
||
|
||
### Combat System
|
||
- [ ] Turn-based combat works end-to-end
|
||
- [ ] Damage calculations correct (physical, magical, critical)
|
||
- [ ] Effects process correctly (DOT, HOT, buffs, debuffs, shields, stun)
|
||
- [ ] Combat UI functional and responsive
|
||
- [ ] Victory awards XP, gold, loot
|
||
- [ ] Combat state persists
|
||
|
||
### Inventory System
|
||
- [ ] Inventory displays in UI
|
||
- [ ] Equip/unequip items works
|
||
- [ ] Consumables can be used
|
||
- [ ] Equipment affects character stats
|
||
- [ ] Item YAML data loaded correctly
|
||
|
||
### Skill Trees
|
||
- [ ] Visual skill tree UI works
|
||
- [ ] Prerequisites enforced
|
||
- [ ] Unlock skills with skill points
|
||
- [ ] Respec functionality works
|
||
- [ ] Stat bonuses apply immediately
|
||
|
||
### Leveling
|
||
- [ ] XP awarded after combat
|
||
- [ ] Level up triggers at threshold
|
||
- [ ] Skill points granted on level up
|
||
- [ ] Level up modal shown
|
||
- [ ] Character stats increase
|
||
|
||
### NPC Shop
|
||
- [ ] Shop inventory displays
|
||
- [ ] Purchase validation works
|
||
- [ ] Items added to inventory
|
||
- [ ] Gold deducted correctly
|
||
- [ ] Transactions logged
|
||
|
||
---
|
||
|
||
## Next Steps After Phase 4
|
||
|
||
Once Phase 4 is complete, you'll have a fully playable combat game with progression. The next logical phases are:
|
||
|
||
**Phase 5: Story Progression & Quests** (Original Phase 4 from roadmap)
|
||
- AI-driven story progression
|
||
- Action prompts (button-based gameplay)
|
||
- Quest system (YAML-driven, context-aware)
|
||
- Full gameplay loop: Explore → Combat → Quests → Level Up
|
||
|
||
**Phase 6: Multiplayer Sessions**
|
||
- Invite-based co-op
|
||
- Time-limited sessions
|
||
- AI-generated campaigns
|
||
|
||
**Phase 7: Marketplace & Economy**
|
||
- Player-to-player trading
|
||
- Auction system
|
||
- Economy balancing
|
||
|
||
---
|
||
|
||
## Appendix: Testing Strategy
|
||
|
||
### Manual Testing Checklist
|
||
|
||
**Combat:**
|
||
- [ ] Start combat from story
|
||
- [ ] Turn order correct
|
||
- [ ] Attack deals damage
|
||
- [ ] Spells work
|
||
- [ ] Items usable in combat
|
||
- [ ] Defend action
|
||
- [ ] Victory conditions
|
||
- [ ] Defeat handling
|
||
|
||
**Inventory:**
|
||
- [ ] Add items
|
||
- [ ] Remove items
|
||
- [ ] Equip weapons
|
||
- [ ] Equip armor
|
||
- [ ] Use consumables
|
||
- [ ] Inventory UI updates
|
||
|
||
**Skills:**
|
||
- [ ] View skill trees
|
||
- [ ] Unlock skills
|
||
- [ ] Prerequisites enforced
|
||
- [ ] Stat bonuses apply
|
||
- [ ] Respec works
|
||
|
||
**Shop:**
|
||
- [ ] Browse inventory
|
||
- [ ] Purchase items
|
||
- [ ] Insufficient gold handling
|
||
- [ ] Transaction logging
|
||
|
||
---
|
||
|
||
## Document Maintenance
|
||
|
||
**Update this document as you complete tasks:**
|
||
- Mark tasks complete with ✅
|
||
- Add notes about implementation decisions
|
||
- Update time estimates based on actual progress
|
||
- Document any blockers or challenges
|
||
|
||
**Good luck with Phase 4 implementation!** 🚀
|