""" 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, ItemType 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 progress toward next level (resets on level-up) total_xp: Cumulative XP earned across all levels (never decreases) 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 # Current level progress (resets on level-up) total_xp: int = 0 # Cumulative XP (never decreases) # 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) completed_quests: List[str] = field(default_factory=list) quest_states: Dict[str, Dict] = field(default_factory=dict) # quest_id -> CharacterQuestState.to_dict() 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: str, # first_met: str (ISO timestamp), # last_interaction: str (ISO timestamp), # interaction_count: int, # revealed_secrets: List[int], # relationship_level: int (0-100, 50=neutral), # custom_flags: Dict[str, Any], # recent_messages: List[Dict] (last 3 messages for AI context), # format: [{player_message: str, npc_response: str, timestamp: str}, ...], # total_messages: int (total conversation message count), # dialogue_history: List[Dict] (DEPRECATED, use ChatMessageService for full history) # } # Full conversation history stored in chat_messages collection (unlimited) 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): - stat_bonuses dict applied to corresponding stats - Weapon damage added to damage_bonus - Weapon spell_power added to spell_power_bonus - Armor defense/resistance added to defense_bonus/resistance_bonus 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 (including computed damage, defense, resistance properties that incorporate bonuses) """ # Start with a copy of base stats effective = self.base_stats.copy() # Apply equipment bonuses for item in self.equipped.values(): # Apply stat bonuses from item (e.g., +3 strength) 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) # Add weapon damage and spell_power to bonus fields if item.item_type == ItemType.WEAPON: effective.damage_bonus += item.damage effective.spell_power_bonus += item.spell_power # Add armor defense and resistance to bonus fields if item.item_type == ItemType.ARMOR: effective.defense_bonus += item.defense effective.resistance_bonus += item.resistance # 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. Updates both current level progress (experience) and cumulative total (total_xp). The cumulative total never decreases, providing players a sense of overall progression. Args: xp: Amount of experience to add Returns: True if character leveled up, False otherwise """ self.experience += xp self.total_xp += xp # Track cumulative XP (never decreases) 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 by 1 - Resets experience to overflow amount (XP beyond requirement carries over) - Preserves total_xp (cumulative XP is never modified here) - Grants 1 skill point (level - unlocked_skills count) - 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 # Reset current level progress # total_xp remains unchanged - it's cumulative and never decreases # 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)) @property def xp_to_next_level(self) -> int: """ Get XP remaining until next level. This is a computed property for UI display showing progress bars and "X/Y XP to next level" displays. Returns: Amount of XP needed to reach next level """ return self._calculate_xp_for_next_level() - self.experience 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, "total_xp": self.total_xp, "xp_to_next_level": self.xp_to_next_level, "xp_required_for_next_level": self._calculate_xp_for_next_level(), "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, "completed_quests": self.completed_quests, "quest_states": self.quest_states, "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), total_xp=data.get("total_xp", 0), # Default 0 for legacy data 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", []), completed_quests=data.get("completed_quests", []), quest_states=data.get("quest_states", {}), 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)" )