Files
Code_of_Conquest/api/app/models/character.py
2025-11-24 23:10:55 -06:00

453 lines
16 KiB
Python

"""
Character data model - the core entity for player characters.
This module defines the Character dataclass which represents a player's character
with all their stats, inventory, progression, and the critical get_effective_stats()
method that calculates final stats from all sources.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from app.models.stats import Stats
from app.models.items import Item
from app.models.skills import PlayerClass, SkillNode
from app.models.effects import Effect
from app.models.enums import EffectType, StatType
from app.models.origins import Origin
@dataclass
class Character:
"""
Represents a player's character.
This is the central data model that ties together all character-related data:
stats, class, inventory, progression, and quests.
The critical method is get_effective_stats() which calculates the final stats
by combining base stats + equipment bonuses + skill bonuses + active effects.
Attributes:
character_id: Unique identifier
user_id: Owner's user ID (from Appwrite auth)
name: Character name
player_class: Character's class (determines base stats and skill trees)
origin: Character's backstory origin (saved for AI DM narrative hooks)
level: Current level
experience: Current XP points
base_stats: Base stats (from class + level-ups)
unlocked_skills: List of skill_ids that have been unlocked
inventory: All items the character owns
equipped: Currently equipped items by slot
Slots: "weapon", "armor", "helmet", "boots", "accessory", etc.
gold: Currency amount
active_quests: List of quest IDs currently in progress
discovered_locations: List of location IDs the character has visited
current_location: Current location ID (tracks character position)
"""
character_id: str
user_id: str
name: str
player_class: PlayerClass
origin: Origin
level: int = 1
experience: int = 0
# Stats and progression
base_stats: Stats = field(default_factory=Stats)
unlocked_skills: List[str] = field(default_factory=list)
# Inventory and equipment
inventory: List[Item] = field(default_factory=list)
equipped: Dict[str, Item] = field(default_factory=dict)
gold: int = 0
# Quests and exploration
active_quests: List[str] = field(default_factory=list)
discovered_locations: List[str] = field(default_factory=list)
current_location: Optional[str] = None # Set to origin starting location on creation
# NPC interaction tracking (persists across sessions)
# Each entry: {npc_id: {interaction_count, relationship_level, dialogue_history, ...}}
# dialogue_history: List[{player_line: str, npc_response: str}]
npc_interactions: Dict[str, Dict] = field(default_factory=dict)
def get_effective_stats(self, active_effects: Optional[List[Effect]] = None) -> Stats:
"""
Calculate final effective stats from all sources.
This is the CRITICAL METHOD that combines:
1. Base stats (from character)
2. Equipment bonuses (from equipped items)
3. Skill tree bonuses (from unlocked skills)
4. Active effect modifiers (buffs/debuffs)
Args:
active_effects: Currently active effects on this character (from combat)
Returns:
Stats instance with all modifiers applied
"""
# Start with a copy of base stats
effective = self.base_stats.copy()
# Apply equipment bonuses
for item in self.equipped.values():
for stat_name, bonus in item.stat_bonuses.items():
if hasattr(effective, stat_name):
current_value = getattr(effective, stat_name)
setattr(effective, stat_name, current_value + bonus)
# Apply skill tree bonuses
skill_bonuses = self._get_skill_bonuses()
for stat_name, bonus in skill_bonuses.items():
if hasattr(effective, stat_name):
current_value = getattr(effective, stat_name)
setattr(effective, stat_name, current_value + bonus)
# Apply active effect modifiers (buffs/debuffs)
if active_effects:
for effect in active_effects:
if effect.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
if effect.stat_affected:
stat_name = effect.stat_affected.value
if hasattr(effective, stat_name):
current_value = getattr(effective, stat_name)
modifier = effect.power * effect.stacks
if effect.effect_type == EffectType.BUFF:
setattr(effective, stat_name, current_value + modifier)
else: # DEBUFF
# Stats can't go below 1
setattr(effective, stat_name, max(1, current_value - modifier))
return effective
def _get_skill_bonuses(self) -> Dict[str, int]:
"""
Calculate total stat bonuses from unlocked skills.
Returns:
Dictionary of stat bonuses from skill tree
"""
bonuses: Dict[str, int] = {}
# Get all skill nodes from all trees
all_skills = self.player_class.get_all_skills()
# Sum up bonuses from unlocked skills
for skill in all_skills:
if skill.skill_id in self.unlocked_skills:
skill_bonuses = skill.get_stat_bonuses()
for stat_name, bonus in skill_bonuses.items():
bonuses[stat_name] = bonuses.get(stat_name, 0) + bonus
return bonuses
def get_unlocked_abilities(self) -> List[str]:
"""
Get all ability IDs unlocked by this character's skills.
Returns:
List of ability_ids from skill tree + class starting abilities
"""
abilities = list(self.player_class.starting_abilities)
# Get all skill nodes from all trees
all_skills = self.player_class.get_all_skills()
# Collect abilities from unlocked skills
for skill in all_skills:
if skill.skill_id in self.unlocked_skills:
abilities.extend(skill.get_unlocked_abilities())
return abilities
@property
def class_id(self) -> str:
"""Get class ID for template access."""
return self.player_class.class_id
@property
def origin_id(self) -> str:
"""Get origin ID for template access."""
return self.origin.id
@property
def origin_name(self) -> str:
"""Get origin display name for template access."""
return self.origin.name
@property
def available_skill_points(self) -> int:
"""Calculate available skill points (1 per level minus unlocked skills)."""
return self.level - len(self.unlocked_skills)
@property
def max_hp(self) -> int:
"""
Calculate max HP from constitution.
Uses the Stats.hit_points property which calculates: 10 + (constitution * 2)
"""
effective_stats = self.get_effective_stats()
return effective_stats.hit_points
@property
def current_hp(self) -> int:
"""
Get current HP.
Outside of combat, characters are at full health.
During combat, this would be tracked separately in the combat state.
"""
# For now, always return max HP (full health outside combat)
# TODO: Track combat damage separately when implementing combat system
return self.max_hp
def can_afford(self, cost: int) -> bool:
"""Check if character has enough gold."""
return self.gold >= cost
def add_gold(self, amount: int) -> None:
"""Add gold to character's wallet."""
self.gold += amount
def remove_gold(self, amount: int) -> bool:
"""
Remove gold from character's wallet.
Returns:
True if successful, False if insufficient gold
"""
if not self.can_afford(amount):
return False
self.gold -= amount
return True
def add_item(self, item: Item) -> None:
"""Add an item to character's inventory."""
self.inventory.append(item)
def remove_item(self, item_id: str) -> Optional[Item]:
"""
Remove an item from inventory by ID.
Returns:
The removed Item or None if not found
"""
for i, item in enumerate(self.inventory):
if item.item_id == item_id:
return self.inventory.pop(i)
return None
def equip_item(self, item: Item, slot: str) -> Optional[Item]:
"""
Equip an item to a specific slot.
Args:
item: Item to equip
slot: Equipment slot ("weapon", "armor", etc.)
Returns:
Previously equipped item in that slot (or None)
"""
# Remove from inventory
self.remove_item(item.item_id)
# Unequip current item in slot if present
previous = self.equipped.get(slot)
if previous:
self.add_item(previous)
# Equip new item
self.equipped[slot] = item
return previous
def unequip_item(self, slot: str) -> Optional[Item]:
"""
Unequip an item from a slot.
Args:
slot: Equipment slot to unequip from
Returns:
The unequipped Item or None if slot was empty
"""
if slot not in self.equipped:
return None
item = self.equipped.pop(slot)
self.add_item(item)
return item
def add_experience(self, xp: int) -> bool:
"""
Add experience points and check for level up.
Args:
xp: Amount of experience to add
Returns:
True if character leveled up, False otherwise
"""
self.experience += xp
required_xp = self._calculate_xp_for_next_level()
if self.experience >= required_xp:
self.level_up()
return True
return False
def level_up(self) -> None:
"""
Level up the character.
- Increases level
- Resets experience to overflow amount
- Could grant stat increases (future enhancement)
"""
required_xp = self._calculate_xp_for_next_level()
overflow_xp = self.experience - required_xp
self.level += 1
self.experience = overflow_xp
# Future: Apply stat increases based on class
# For now, stats are increased manually via skill points
def _calculate_xp_for_next_level(self) -> int:
"""
Calculate XP required for next level.
Formula: 100 * (level ^ 1.5)
This creates an exponential curve: 100, 282, 519, 800, 1118...
Returns:
XP required for next level
"""
return int(100 * (self.level ** 1.5))
def to_dict(self) -> Dict[str, Any]:
"""
Serialize character to dictionary for JSON storage.
Returns:
Dictionary containing all character data
"""
return {
"character_id": self.character_id,
"user_id": self.user_id,
"name": self.name,
"player_class": self.player_class.to_dict(),
"origin": self.origin.to_dict(),
"level": self.level,
"experience": self.experience,
"base_stats": self.base_stats.to_dict(),
"unlocked_skills": self.unlocked_skills,
"inventory": [item.to_dict() for item in self.inventory],
"equipped": {slot: item.to_dict() for slot, item in self.equipped.items()},
"gold": self.gold,
"active_quests": self.active_quests,
"discovered_locations": self.discovered_locations,
"current_location": self.current_location,
"npc_interactions": self.npc_interactions,
# Computed properties for AI templates
"current_hp": self.current_hp,
"max_hp": self.max_hp,
}
def to_story_dict(self) -> Dict[str, Any]:
"""
Serialize only story-relevant character data for AI prompts.
This trimmed version reduces token usage by excluding mechanical
details that aren't needed for narrative generation (IDs, full
inventory details, skill trees, etc.).
Returns:
Dictionary containing story-relevant character data
"""
effective_stats = self.get_effective_stats()
# Get equipped item names for context (not full details)
equipped_summary = {}
for slot, item in self.equipped.items():
equipped_summary[slot] = item.name
# Get skill names from unlocked skills
skill_names = []
all_skills = self.player_class.get_all_skills()
for skill in all_skills:
if skill.skill_id in self.unlocked_skills:
skill_names.append({
"name": skill.name,
"level": 1 # Skills don't have levels, but template expects this
})
return {
"name": self.name,
"level": self.level,
"player_class": self.player_class.name,
"origin_name": self.origin.name,
"current_hp": self.current_hp,
"max_hp": self.max_hp,
"gold": self.gold,
# Stats for display and checks
"stats": effective_stats.to_dict(),
"base_stats": self.base_stats.to_dict(),
# Simplified collections
"skills": skill_names,
"equipped": equipped_summary,
"inventory_count": len(self.inventory),
"active_quests_count": len(self.active_quests),
# Empty list for templates that check completed_quests
"effects": [], # Active effects passed separately in combat
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Character':
"""
Deserialize character from dictionary.
Args:
data: Dictionary containing character data
Returns:
Character instance
"""
from app.models.skills import PlayerClass
player_class = PlayerClass.from_dict(data["player_class"])
origin = Origin.from_dict(data["origin"])
base_stats = Stats.from_dict(data["base_stats"])
inventory = [Item.from_dict(item) for item in data.get("inventory", [])]
equipped = {slot: Item.from_dict(item) for slot, item in data.get("equipped", {}).items()}
return cls(
character_id=data["character_id"],
user_id=data["user_id"],
name=data["name"],
player_class=player_class,
origin=origin,
level=data.get("level", 1),
experience=data.get("experience", 0),
base_stats=base_stats,
unlocked_skills=data.get("unlocked_skills", []),
inventory=inventory,
equipped=equipped,
gold=data.get("gold", 0),
active_quests=data.get("active_quests", []),
discovered_locations=data.get("discovered_locations", []),
current_location=data.get("current_location"),
npc_interactions=data.get("npc_interactions", {}),
)
def __repr__(self) -> str:
"""String representation of the character."""
return (
f"Character({self.name}, {self.player_class.name}, "
f"Lv{self.level}, {self.gold}g)"
)