first commit
This commit is contained in:
452
api/app/models/character.py
Normal file
452
api/app/models/character.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
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)"
|
||||
)
|
||||
Reference in New Issue
Block a user