feat(backend): Implement base framework for Perks, Skills, and Derived Stats

This commit is contained in:
Joan
2026-02-25 10:05:14 +01:00
parent aa71a6be7c
commit 185781d168
5 changed files with 931 additions and 1 deletions

View File

@@ -79,8 +79,9 @@ class InitiateCombatRequest(BaseModel):
class CombatActionRequest(BaseModel):
action: str # 'attack', 'defend', 'flee', 'use_item'
action: str # 'attack', 'skill', 'flee', 'use_item'
item_id: Optional[str] = None # For use_item action
skill_id: Optional[str] = None # For skill action
class PvPCombatInitiateRequest(BaseModel):

178
api/services/skills.py Normal file
View File

@@ -0,0 +1,178 @@
"""
Skills service - loads skill definitions and provides skill availability logic.
"""
import json
from typing import Dict, Any, List, Optional
from pathlib import Path
class Skill:
"""Represents a combat skill."""
def __init__(self, skill_id: str, data: Dict[str, Any]):
self.id = skill_id
self.name = data.get('name', skill_id)
self.description = data.get('description', '')
self.icon = data.get('icon', '⚔️')
self.stat_requirement = data.get('stat_requirement', 'strength')
self.stat_threshold = data.get('stat_threshold', 0)
self.level_requirement = data.get('level_requirement', 1)
self.cooldown = data.get('cooldown', 3)
self.stamina_cost = data.get('stamina_cost', 5)
self.effects = data.get('effects', {})
class SkillsManager:
"""Loads and manages skill definitions from JSON."""
def __init__(self, gamedata_path: str = "./gamedata"):
self.gamedata_path = Path(gamedata_path)
self.skills: Dict[str, Skill] = {}
self.load_skills()
def load_skills(self):
"""Load skills from skills.json."""
json_path = self.gamedata_path / 'skills.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
for skill_id, skill_data in data.get('skills', {}).items():
self.skills[skill_id] = Skill(skill_id, skill_data)
print(f"⚔️ Loaded {len(self.skills)} skills")
except FileNotFoundError:
print("⚠️ skills.json not found")
except Exception as e:
print(f"⚠️ Error loading skills.json: {e}")
def get_skill(self, skill_id: str) -> Optional[Skill]:
"""Get a skill by ID."""
return self.skills.get(skill_id)
def get_all_skills(self) -> Dict[str, Skill]:
"""Get all skills."""
return self.skills
def get_available_skills(self, character: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Get all skills available to a character based on their stats and level.
Returns list of skill dicts with availability info.
"""
available = []
for skill_id, skill in self.skills.items():
stat_value = character.get(skill.stat_requirement, 0)
level = character.get('level', 1)
unlocked = (stat_value >= skill.stat_threshold and level >= skill.level_requirement)
skill_info = {
"id": skill.id,
"name": skill.name,
"description": skill.description,
"icon": skill.icon,
"stat_requirement": skill.stat_requirement,
"stat_threshold": skill.stat_threshold,
"level_requirement": skill.level_requirement,
"cooldown": skill.cooldown,
"stamina_cost": skill.stamina_cost,
"unlocked": unlocked,
"effects": skill.effects,
}
available.append(skill_info)
return available
class Perk:
"""Represents a passive perk."""
def __init__(self, perk_id: str, data: Dict[str, Any]):
self.id = perk_id
self.name = data.get('name', perk_id)
self.description = data.get('description', '')
self.icon = data.get('icon', '')
self.requirements = data.get('requirements', {})
self.effects = data.get('effects', {})
class PerksManager:
"""Loads and manages perk definitions from JSON."""
def __init__(self, gamedata_path: str = "./gamedata"):
self.gamedata_path = Path(gamedata_path)
self.perks: Dict[str, Perk] = {}
self.load_perks()
def load_perks(self):
"""Load perks from perks.json."""
json_path = self.gamedata_path / 'perks.json'
try:
with open(json_path, 'r') as f:
data = json.load(f)
for perk_id, perk_data in data.get('perks', {}).items():
self.perks[perk_id] = Perk(perk_id, perk_data)
print(f"⭐ Loaded {len(self.perks)} perks")
except FileNotFoundError:
print("⚠️ perks.json not found")
except Exception as e:
print(f"⚠️ Error loading perks.json: {e}")
def get_perk(self, perk_id: str) -> Optional[Perk]:
"""Get a perk by ID."""
return self.perks.get(perk_id)
def get_all_perks(self) -> Dict[str, Perk]:
"""Get all perks."""
return self.perks
def check_requirements(self, perk: Perk, character: Dict[str, Any]) -> bool:
"""Check if a character meets a perk's requirements."""
for req_key, req_value in perk.requirements.items():
if req_key.endswith('_max'):
# Max constraint (e.g., endurance_max: 8 means END must be ≤ 8)
stat_name = req_key.replace('_max', '')
if character.get(stat_name, 0) > req_value:
return False
else:
# Min constraint
if character.get(req_key, 0) < req_value:
return False
return True
def get_available_perks(self, character: Dict[str, Any], owned_perk_ids: List[str]) -> List[Dict[str, Any]]:
"""
Get all perks with availability status for a character.
"""
available = []
for perk_id, perk in self.perks.items():
meets_requirements = self.check_requirements(perk, character)
owned = perk_id in owned_perk_ids
perk_info = {
"id": perk.id,
"name": perk.name,
"description": perk.description,
"icon": perk.icon,
"requirements": perk.requirements,
"effects": perk.effects,
"meets_requirements": meets_requirements,
"owned": owned,
}
available.append(perk_info)
return available
# Perk points per level
PERK_POINT_INTERVAL = 5 # Every 5 levels
def get_total_perk_points(level: int) -> int:
"""Calculate total perk points available for a given level."""
return level // PERK_POINT_INTERVAL
# Global instances
skills_manager = SkillsManager()
perks_manager = PerksManager()

225
api/services/stats.py Normal file
View File

@@ -0,0 +1,225 @@
"""
Central stat calculation service.
All derived stats are computed here from base attributes + equipment + buffs.
Results are cached in Redis and invalidated on any change.
"""
import json
from typing import Dict, Any, Optional, List
from .. import database as db
from ..items import items_manager as ITEMS_MANAGER
# ─── Game Config (raise per expansion) ───
STAT_CAP = 50 # Max base stat points per attribute
MAX_LEVEL = 60 # Max character level
POINTS_PER_LEVEL = 1 # Stat points granted per level
async def calculate_derived_stats(character_id: int, redis_mgr=None) -> Dict[str, Any]:
"""
Calculate all derived stats for a character.
Checks Redis cache first; if miss, computes from DB and caches result.
Returns dict with all derived stat values.
"""
# 1. Check Redis cache
if redis_mgr and redis_mgr.redis_client:
try:
cached = await redis_mgr.redis_client.get(f"stats:{character_id}")
if cached:
return json.loads(cached)
except Exception:
pass # Graceful degradation — recalculate if Redis fails
# 2. Fetch data from DB
char = await db.get_player_by_id(character_id)
if not char:
return _empty_stats()
equipment = await db.get_all_equipment(character_id)
effects = await db.get_player_effects(character_id)
# 3. Fetch owned perks
owned_perks = await db.get_character_perks(character_id)
owned_perk_ids = [row['perk_id'] for row in owned_perks]
# 4. Compute derived stats
stats = _compute_stats(char, equipment, effects, owned_perk_ids)
# 5. Cache in Redis (5 min TTL)
if redis_mgr and redis_mgr.redis_client:
try:
await redis_mgr.redis_client.setex(
f"stats:{character_id}", 300, json.dumps(stats)
)
except Exception:
pass
return stats
def _compute_stats(char: Dict[str, Any], equipment: Dict[str, Any], effects: List[Dict], perk_ids: List[str] = None) -> Dict[str, Any]:
"""Pure computation of derived stats from base data."""
strength = char.get('strength', 0)
agility = char.get('agility', 0)
endurance = char.get('endurance', 0)
intellect = char.get('intellect', 0)
level = char.get('level', 1)
if perk_ids is None:
perk_ids = []
# ─── Base derived stats from attributes ───
attack_power = 5 + int(strength * 1.5) + level
crit_chance = 0.05 + (agility * 0.005)
crit_damage = 1.5 + (strength * 0.01)
dodge_chance = min(0.25, 0.02 + (agility * 0.005)) # Cap 25%
flee_chance_base = 0.4 + (agility * 0.01)
max_hp = 30 + (endurance * 5) + (level * 3)
max_stamina = 20 + (endurance * 2) + level
status_resistance = endurance * 0.01
block_chance = 0.0
item_effectiveness = 1.0 + (intellect * 0.02)
xp_bonus = 1.0 + (intellect * 0.01)
loot_quality = 1.0 + (intellect * 0.005)
crafting_bonus = intellect * 0.01
carry_weight = 10.0 + (strength * 0.5)
# ─── Equipment bonuses ───
total_armor = 0
weapon_crit = 0.0
weapon_damage_min = 0
weapon_damage_max = 0
has_shield = False
for slot, item_data in equipment.items():
if not item_data or not item_data.get('item_id'):
continue
# Get inventory item to find the item definition
inv_item_sync = item_data # equipment dict already has item_id reference
item_def = ITEMS_MANAGER.get_item(inv_item_sync.get('item_id', ''))
# Try to get item_id from the inventory item if the direct lookup failed
if not item_def:
continue
if item_def.stats:
total_armor += item_def.stats.get('armor', 0)
weapon_crit += item_def.stats.get('crit_chance', 0)
if slot == 'weapon':
weapon_damage_min = item_def.stats.get('damage_min', 0)
weapon_damage_max = item_def.stats.get('damage_max', 0)
if slot == 'offhand':
has_shield = True
# Apply equipment to derived stats
crit_chance += weapon_crit
armor_reduction = total_armor / (total_armor + 50) if total_armor > 0 else 0.0
if has_shield:
block_chance = strength * 0.003
# ─── Buff effects ───
for effect in effects:
effect_name = effect.get('effect_name', '')
value = effect.get('value', 0)
# Future: apply buff modifiers here
# ─── Perk passive bonuses ───
if 'thick_skin' in perk_ids:
max_hp = int(max_hp * 1.10) # +10% max HP
if 'lucky_strike' in perk_ids:
crit_chance += 0.05 # +5% crit chance
if 'quick_learner' in perk_ids:
xp_bonus *= 1.15 # +15% XP
if 'glass_cannon' in perk_ids:
attack_power = int(attack_power * 1.30) # +30% attack
max_hp = int(max_hp * 0.80) # -20% HP
if 'survivor' in perk_ids:
max_hp = int(max_hp * 1.02) # Small HP boost from regen perk
if 'scavenger' in perk_ids:
loot_quality *= 1.10 # +10% loot quality
if 'fleet_footed' in perk_ids:
# Travel stamina reduction tracked for movement system
pass
stats = {
# Core combat
"attack_power": attack_power,
"crit_chance": round(crit_chance, 4),
"crit_damage": round(crit_damage, 2),
"dodge_chance": round(dodge_chance, 4),
"flee_chance_base": round(flee_chance_base, 2),
# Vitals
"max_hp": max_hp,
"max_stamina": max_stamina,
# Defense
"total_armor": total_armor,
"armor_reduction": round(armor_reduction, 4),
"block_chance": round(block_chance, 4),
"status_resistance": round(status_resistance, 4),
# Utility
"item_effectiveness": round(item_effectiveness, 2),
"xp_bonus": round(xp_bonus, 2),
"loot_quality": round(loot_quality, 3),
"crafting_bonus": round(crafting_bonus, 2),
"carry_weight": round(carry_weight, 1),
# Weapon info
"weapon_damage_min": weapon_damage_min,
"weapon_damage_max": weapon_damage_max,
"has_shield": has_shield,
# Perk flags
"has_last_stand": 'last_stand' in perk_ids,
"has_resilient": 'resilient' in perk_ids,
"has_iron_fist": 'iron_fist' in perk_ids,
"has_heavy_hitter": 'heavy_hitter' in perk_ids,
}
return stats
def _empty_stats() -> Dict[str, Any]:
"""Default stats for error cases."""
return {
"attack_power": 5,
"crit_chance": 0.05,
"crit_damage": 1.5,
"dodge_chance": 0.02,
"flee_chance_base": 0.4,
"max_hp": 30,
"max_stamina": 20,
"total_armor": 0,
"armor_reduction": 0.0,
"block_chance": 0.0,
"status_resistance": 0.0,
"item_effectiveness": 1.0,
"xp_bonus": 1.0,
"loot_quality": 1.0,
"crafting_bonus": 0.0,
"carry_weight": 10.0,
"weapon_damage_min": 0,
"weapon_damage_max": 0,
"has_shield": False,
}
async def invalidate_stats_cache(character_id: int, redis_mgr=None):
"""
Delete cached stats for a character. Call this whenever:
- Equipment changes (equip/unequip/break)
- Stat points allocated
- Level up
- Buff applied/expired
"""
if redis_mgr and redis_mgr.redis_client:
try:
await redis_mgr.redis_client.delete(f"stats:{character_id}")
except Exception:
pass
def get_flee_chance(flee_chance_base: float, enemy_level: int) -> float:
"""Calculate actual flee chance against a specific enemy."""
return max(0.1, min(0.9, flee_chance_base - (enemy_level * 0.02)))