first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
"""
Data models for Code of Conquest.
This package contains all dataclass models used throughout the application.
"""
# Enums
from app.models.enums import (
EffectType,
DamageType,
ItemType,
StatType,
AbilityType,
CombatStatus,
SessionStatus,
ListingStatus,
ListingType,
)
# Core models
from app.models.stats import Stats
from app.models.effects import Effect
from app.models.abilities import Ability, AbilityLoader
from app.models.items import Item
# Progression
from app.models.skills import SkillNode, SkillTree, PlayerClass
# Character
from app.models.character import Character
# Combat
from app.models.combat import Combatant, CombatEncounter
# Session
from app.models.session import (
SessionConfig,
GameState,
ConversationEntry,
GameSession,
)
# Marketplace
from app.models.marketplace import (
Bid,
MarketplaceListing,
Transaction,
ShopItem,
)
__all__ = [
# Enums
"EffectType",
"DamageType",
"ItemType",
"StatType",
"AbilityType",
"CombatStatus",
"SessionStatus",
"ListingStatus",
"ListingType",
# Core models
"Stats",
"Effect",
"Ability",
"AbilityLoader",
"Item",
# Progression
"SkillNode",
"SkillTree",
"PlayerClass",
# Character
"Character",
# Combat
"Combatant",
"CombatEncounter",
# Session
"SessionConfig",
"GameState",
"ConversationEntry",
"GameSession",
# Marketplace
"Bid",
"MarketplaceListing",
"Transaction",
"ShopItem",
]

237
api/app/models/abilities.py Normal file
View File

@@ -0,0 +1,237 @@
"""
Ability system for combat actions and spells.
This module defines abilities (attacks, spells, skills) that can be used in combat.
Abilities are loaded from YAML configuration files for data-driven design.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
import yaml
import os
from pathlib import Path
from app.models.enums import AbilityType, DamageType, EffectType, StatType
from app.models.effects import Effect
from app.models.stats import Stats
@dataclass
class Ability:
"""
Represents an action that can be taken in combat.
Abilities can deal damage, apply effects, heal, or perform other actions.
They are loaded from YAML files for easy game design iteration.
Attributes:
ability_id: Unique identifier
name: Display name
description: What the ability does
ability_type: Category (attack, spell, skill, etc.)
base_power: Base damage or healing value
damage_type: Type of damage dealt (physical, fire, etc.)
scaling_stat: Which stat scales this ability's power (if any)
scaling_factor: Multiplier for scaling stat (default 0.5)
mana_cost: MP required to use this ability
cooldown: Turns before ability can be used again
effects_applied: List of effects applied to target on hit
is_aoe: Whether this affects multiple targets
target_count: Number of targets if AoE (0 = all)
"""
ability_id: str
name: str
description: str
ability_type: AbilityType
base_power: int = 0
damage_type: Optional[DamageType] = None
scaling_stat: Optional[StatType] = None
scaling_factor: float = 0.5
mana_cost: int = 0
cooldown: int = 0
effects_applied: List[Effect] = field(default_factory=list)
is_aoe: bool = False
target_count: int = 1
def calculate_power(self, caster_stats: Stats) -> int:
"""
Calculate final power based on caster's stats.
Formula: base_power + (scaling_stat × scaling_factor)
Minimum power is always 1.
Args:
caster_stats: The caster's effective stats
Returns:
Final power value for damage or healing
"""
power = self.base_power
if self.scaling_stat:
stat_value = getattr(caster_stats, self.scaling_stat.value)
power += int(stat_value * self.scaling_factor)
return max(1, power)
def get_effects_to_apply(self) -> List[Effect]:
"""
Get a copy of effects that should be applied to target(s).
Creates new Effect instances to avoid sharing references.
Returns:
List of Effect instances to apply
"""
return [
Effect(
effect_id=f"{self.ability_id}_{effect.name}_{id(effect)}",
name=effect.name,
effect_type=effect.effect_type,
duration=effect.duration,
power=effect.power,
stat_affected=effect.stat_affected,
stacks=effect.stacks,
max_stacks=effect.max_stacks,
source=self.ability_id,
)
for effect in self.effects_applied
]
def to_dict(self) -> Dict[str, Any]:
"""
Serialize ability to a dictionary.
Returns:
Dictionary containing all ability data
"""
data = asdict(self)
data["ability_type"] = self.ability_type.value
if self.damage_type:
data["damage_type"] = self.damage_type.value
if self.scaling_stat:
data["scaling_stat"] = self.scaling_stat.value
data["effects_applied"] = [effect.to_dict() for effect in self.effects_applied]
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Ability':
"""
Deserialize ability from a dictionary.
Args:
data: Dictionary containing ability data
Returns:
Ability instance
"""
# Convert string values back to enums
ability_type = AbilityType(data["ability_type"])
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
scaling_stat = StatType(data["scaling_stat"]) if data.get("scaling_stat") else None
# Deserialize effects
effects = []
if "effects_applied" in data and data["effects_applied"]:
effects = [Effect.from_dict(e) for e in data["effects_applied"]]
return cls(
ability_id=data["ability_id"],
name=data["name"],
description=data["description"],
ability_type=ability_type,
base_power=data.get("base_power", 0),
damage_type=damage_type,
scaling_stat=scaling_stat,
scaling_factor=data.get("scaling_factor", 0.5),
mana_cost=data.get("mana_cost", 0),
cooldown=data.get("cooldown", 0),
effects_applied=effects,
is_aoe=data.get("is_aoe", False),
target_count=data.get("target_count", 1),
)
def __repr__(self) -> str:
"""String representation of the ability."""
return (
f"Ability({self.name}, {self.ability_type.value}, "
f"power={self.base_power}, cost={self.mana_cost}MP, "
f"cooldown={self.cooldown}t)"
)
class AbilityLoader:
"""
Loads abilities from YAML configuration files.
This allows game designers to define abilities without touching code.
"""
def __init__(self, data_dir: Optional[str] = None):
"""
Initialize the ability loader.
Args:
data_dir: Path to directory containing ability YAML files
Defaults to /app/data/abilities/
"""
if data_dir is None:
# Default to app/data/abilities relative to this file
current_file = Path(__file__)
app_dir = current_file.parent.parent # Go up to /app
data_dir = str(app_dir / "data" / "abilities")
self.data_dir = Path(data_dir)
self._ability_cache: Dict[str, Ability] = {}
def load_ability(self, ability_id: str) -> Optional[Ability]:
"""
Load a single ability by ID.
Args:
ability_id: Unique ability identifier
Returns:
Ability instance or None if not found
"""
# Check cache first
if ability_id in self._ability_cache:
return self._ability_cache[ability_id]
# Load from YAML file
yaml_file = self.data_dir / f"{ability_id}.yaml"
if not yaml_file.exists():
return None
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
ability = Ability.from_dict(data)
self._ability_cache[ability_id] = ability
return ability
def load_all_abilities(self) -> Dict[str, Ability]:
"""
Load all abilities from the data directory.
Returns:
Dictionary mapping ability_id to Ability instance
"""
if not self.data_dir.exists():
return {}
abilities = {}
for yaml_file in self.data_dir.glob("*.yaml"):
with open(yaml_file, 'r') as f:
data = yaml.safe_load(f)
ability = Ability.from_dict(data)
abilities[ability.ability_id] = ability
self._ability_cache[ability.ability_id] = ability
return abilities
def clear_cache(self) -> None:
"""Clear the ability cache, forcing reload on next access."""
self._ability_cache.clear()

View File

@@ -0,0 +1,296 @@
"""
Action Prompt Model
This module defines the ActionPrompt dataclass for button-based story actions.
Each action prompt represents a predefined action that players can take during
story progression, with tier-based availability and context filtering.
Usage:
from app.models.action_prompt import ActionPrompt, ActionCategory, LocationType
action = ActionPrompt(
prompt_id="ask_locals",
category=ActionCategory.ASK_QUESTION,
display_text="Ask locals for information",
description="Talk to NPCs to learn about quests and rumors",
tier_required=UserTier.FREE,
context_filter=[LocationType.TOWN, LocationType.TAVERN],
dm_prompt_template="The player asks locals about {{ topic }}..."
)
if action.is_available(UserTier.FREE, LocationType.TOWN):
# Show action button to player
pass
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional, Any, Dict
from app.ai.model_selector import UserTier
@dataclass
class CheckRequirement:
"""
Defines the dice check required for an action.
Used to determine outcomes before AI narration.
"""
check_type: str # "search" or "skill"
skill: Optional[str] = None # For skill checks: perception, persuasion, etc.
difficulty: str = "medium" # trivial, easy, medium, hard, very_hard
def to_dict(self) -> dict:
"""Serialize for API response."""
return {
"check_type": self.check_type,
"skill": self.skill,
"difficulty": self.difficulty,
}
@classmethod
def from_dict(cls, data: dict) -> "CheckRequirement":
"""Create from dictionary."""
return cls(
check_type=data.get("check_type", "skill"),
skill=data.get("skill"),
difficulty=data.get("difficulty", "medium"),
)
class ActionCategory(str, Enum):
"""Categories of story actions."""
ASK_QUESTION = "ask_question" # Gather information from NPCs
TRAVEL = "travel" # Move to a new location
GATHER_INFO = "gather_info" # Search or investigate
REST = "rest" # Rest and recover
INTERACT = "interact" # Interact with objects/environment
EXPLORE = "explore" # Explore the area
SPECIAL = "special" # Special tier-specific actions
class LocationType(str, Enum):
"""Types of locations in the game world."""
TOWN = "town" # Populated settlements
TAVERN = "tavern" # Taverns and inns
WILDERNESS = "wilderness" # Outdoor areas, forests, fields
DUNGEON = "dungeon" # Dungeons and caves
SAFE_AREA = "safe_area" # Protected zones, temples
LIBRARY = "library" # Libraries and archives
ANY = "any" # Available in all locations
@dataclass
class ActionPrompt:
"""
Represents a predefined story action that players can select.
Action prompts are displayed as buttons in the story UI. Each action
has tier requirements and context filters to determine availability.
Attributes:
prompt_id: Unique identifier for the action
category: Category of action (ASK_QUESTION, TRAVEL, etc.)
display_text: Text shown on the action button
description: Tooltip/help text explaining the action
tier_required: Minimum subscription tier required
context_filter: List of location types where action is available
dm_prompt_template: Jinja2 template for generating AI prompt
icon: Optional icon name for the button
cooldown_turns: Optional cooldown in turns before action can be used again
"""
prompt_id: str
category: ActionCategory
display_text: str
description: str
tier_required: UserTier
context_filter: List[LocationType]
dm_prompt_template: str
icon: Optional[str] = None
cooldown_turns: int = 0
requires_check: Optional[CheckRequirement] = None
def is_available(self, user_tier: UserTier, location_type: LocationType) -> bool:
"""
Check if this action is available for a user at a location.
Args:
user_tier: The user's subscription tier
location_type: The current location type
Returns:
True if the action is available, False otherwise
"""
# Check tier requirement
if not self._tier_meets_requirement(user_tier):
return False
# Check location filter
if not self._location_matches_filter(location_type):
return False
return True
def _tier_meets_requirement(self, user_tier: UserTier) -> bool:
"""
Check if user tier meets the minimum requirement.
Tier hierarchy: FREE < BASIC < PREMIUM < ELITE
Args:
user_tier: The user's subscription tier
Returns:
True if tier requirement is met
"""
tier_order = {
UserTier.FREE: 0,
UserTier.BASIC: 1,
UserTier.PREMIUM: 2,
UserTier.ELITE: 3,
}
user_level = tier_order.get(user_tier, 0)
required_level = tier_order.get(self.tier_required, 0)
return user_level >= required_level
def _location_matches_filter(self, location_type: LocationType) -> bool:
"""
Check if location matches the context filter.
Args:
location_type: The current location type
Returns:
True if location matches filter
"""
# ANY location type matches everything
if LocationType.ANY in self.context_filter:
return True
# Check if location is in the filter list
return location_type in self.context_filter
def is_locked(self, user_tier: UserTier) -> bool:
"""
Check if this action is locked due to tier restriction.
Used to show locked actions with upgrade prompts.
Args:
user_tier: The user's subscription tier
Returns:
True if the action is locked (tier too low)
"""
return not self._tier_meets_requirement(user_tier)
def get_lock_reason(self, user_tier: UserTier) -> Optional[str]:
"""
Get the reason why an action is locked.
Args:
user_tier: The user's subscription tier
Returns:
Lock reason message, or None if not locked
"""
if not self._tier_meets_requirement(user_tier):
tier_names = {
UserTier.FREE: "Free",
UserTier.BASIC: "Basic",
UserTier.PREMIUM: "Premium",
UserTier.ELITE: "Elite",
}
required_name = tier_names.get(self.tier_required, "Unknown")
return f"Requires {required_name} tier or higher"
return None
def to_dict(self) -> dict:
"""
Convert to dictionary for JSON serialization.
Returns:
Dictionary representation of the action prompt
"""
result = {
"prompt_id": self.prompt_id,
"category": self.category.value,
"display_text": self.display_text,
"description": self.description,
"tier_required": self.tier_required.value,
"context_filter": [loc.value for loc in self.context_filter],
"dm_prompt_template": self.dm_prompt_template,
"icon": self.icon,
"cooldown_turns": self.cooldown_turns,
}
if self.requires_check:
result["requires_check"] = self.requires_check.to_dict()
return result
@classmethod
def from_dict(cls, data: dict) -> "ActionPrompt":
"""
Create an ActionPrompt from a dictionary.
Args:
data: Dictionary containing action prompt data
Returns:
ActionPrompt instance
Raises:
ValueError: If required fields are missing or invalid
"""
# Parse category enum
category_str = data.get("category", "")
try:
category = ActionCategory(category_str)
except ValueError:
raise ValueError(f"Invalid action category: {category_str}")
# Parse tier enum
tier_str = data.get("tier_required", "free")
try:
tier_required = UserTier(tier_str)
except ValueError:
raise ValueError(f"Invalid user tier: {tier_str}")
# Parse location types
context_filter_raw = data.get("context_filter", ["any"])
context_filter = []
for loc_str in context_filter_raw:
try:
context_filter.append(LocationType(loc_str.lower()))
except ValueError:
raise ValueError(f"Invalid location type: {loc_str}")
# Parse requires_check if present
requires_check = None
if "requires_check" in data and data["requires_check"]:
requires_check = CheckRequirement.from_dict(data["requires_check"])
return cls(
prompt_id=data.get("prompt_id", ""),
category=category,
display_text=data.get("display_text", ""),
description=data.get("description", ""),
tier_required=tier_required,
context_filter=context_filter,
dm_prompt_template=data.get("dm_prompt_template", ""),
icon=data.get("icon"),
cooldown_turns=data.get("cooldown_turns", 0),
requires_check=requires_check,
)
def __repr__(self) -> str:
"""String representation for debugging."""
return (
f"ActionPrompt(prompt_id='{self.prompt_id}', "
f"category={self.category.value}, "
f"tier={self.tier_required.value})"
)

211
api/app/models/ai_usage.py Normal file
View File

@@ -0,0 +1,211 @@
"""
AI Usage data model for tracking AI generation costs and usage.
This module defines the AIUsageLog dataclass which represents a single AI usage
event for tracking costs, tokens used, and generating usage analytics.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone, date
from typing import Dict, Any, Optional
from enum import Enum
class TaskType(str, Enum):
"""Types of AI tasks that can be tracked."""
STORY_PROGRESSION = "story_progression"
COMBAT_NARRATION = "combat_narration"
QUEST_SELECTION = "quest_selection"
NPC_DIALOGUE = "npc_dialogue"
GENERAL = "general"
@dataclass
class AIUsageLog:
"""
Represents a single AI usage event for cost and usage tracking.
This dataclass captures all relevant information about an AI API call
including the user, model used, tokens consumed, and estimated cost.
Used for:
- Cost monitoring and budgeting
- Usage analytics per user/tier
- Rate limiting enforcement
- Billing and invoicing (future)
Attributes:
log_id: Unique identifier for this usage log entry
user_id: User who made the request
timestamp: When the request was made
model: Model identifier (e.g., "meta/meta-llama-3-8b-instruct")
tokens_input: Number of input tokens (prompt)
tokens_output: Number of output tokens (response)
tokens_total: Total tokens used (input + output)
estimated_cost: Estimated cost in USD
task_type: Type of task (story, combat, quest, npc)
session_id: Optional game session ID for context
character_id: Optional character ID for context
request_duration_ms: How long the request took in milliseconds
success: Whether the request completed successfully
error_message: Error message if the request failed
"""
log_id: str
user_id: str
timestamp: datetime
model: str
tokens_input: int
tokens_output: int
tokens_total: int
estimated_cost: float
task_type: TaskType
session_id: Optional[str] = None
character_id: Optional[str] = None
request_duration_ms: int = 0
success: bool = True
error_message: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""
Convert usage log to dictionary for storage.
Returns:
Dictionary representation suitable for Appwrite storage
"""
return {
"user_id": self.user_id,
"timestamp": self.timestamp.isoformat() if isinstance(self.timestamp, datetime) else self.timestamp,
"model": self.model,
"tokens_input": self.tokens_input,
"tokens_output": self.tokens_output,
"tokens_total": self.tokens_total,
"estimated_cost": self.estimated_cost,
"task_type": self.task_type.value if isinstance(self.task_type, TaskType) else self.task_type,
"session_id": self.session_id,
"character_id": self.character_id,
"request_duration_ms": self.request_duration_ms,
"success": self.success,
"error_message": self.error_message,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AIUsageLog":
"""
Create AIUsageLog from dictionary.
Args:
data: Dictionary with usage log data
Returns:
AIUsageLog instance
"""
# Parse timestamp
timestamp = data.get("timestamp")
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
elif timestamp is None:
timestamp = datetime.now(timezone.utc)
# Parse task type
task_type = data.get("task_type", "general")
if isinstance(task_type, str):
try:
task_type = TaskType(task_type)
except ValueError:
task_type = TaskType.GENERAL
return cls(
log_id=data.get("log_id", ""),
user_id=data.get("user_id", ""),
timestamp=timestamp,
model=data.get("model", ""),
tokens_input=data.get("tokens_input", 0),
tokens_output=data.get("tokens_output", 0),
tokens_total=data.get("tokens_total", 0),
estimated_cost=data.get("estimated_cost", 0.0),
task_type=task_type,
session_id=data.get("session_id"),
character_id=data.get("character_id"),
request_duration_ms=data.get("request_duration_ms", 0),
success=data.get("success", True),
error_message=data.get("error_message"),
)
@dataclass
class DailyUsageSummary:
"""
Summary of AI usage for a specific day.
Used for reporting and rate limiting checks.
Attributes:
date: The date of this summary
user_id: User ID
total_requests: Number of AI requests made
total_tokens: Total tokens consumed
total_input_tokens: Total input tokens
total_output_tokens: Total output tokens
estimated_cost: Total estimated cost in USD
requests_by_task: Breakdown of requests by task type
"""
date: date
user_id: str
total_requests: int
total_tokens: int
total_input_tokens: int
total_output_tokens: int
estimated_cost: float
requests_by_task: Dict[str, int] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert summary to dictionary."""
return {
"date": self.date.isoformat() if isinstance(self.date, date) else self.date,
"user_id": self.user_id,
"total_requests": self.total_requests,
"total_tokens": self.total_tokens,
"total_input_tokens": self.total_input_tokens,
"total_output_tokens": self.total_output_tokens,
"estimated_cost": self.estimated_cost,
"requests_by_task": self.requests_by_task,
}
@dataclass
class MonthlyUsageSummary:
"""
Summary of AI usage for a specific month.
Used for billing and cost projections.
Attributes:
year: Year
month: Month (1-12)
user_id: User ID
total_requests: Number of AI requests made
total_tokens: Total tokens consumed
estimated_cost: Total estimated cost in USD
daily_breakdown: List of daily summaries
"""
year: int
month: int
user_id: str
total_requests: int
total_tokens: int
estimated_cost: float
daily_breakdown: list = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert summary to dictionary."""
return {
"year": self.year,
"month": self.month,
"user_id": self.user_id,
"total_requests": self.total_requests,
"total_tokens": self.total_tokens,
"estimated_cost": self.estimated_cost,
"daily_breakdown": self.daily_breakdown,
}

452
api/app/models/character.py Normal file
View 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)"
)

414
api/app/models/combat.py Normal file
View File

@@ -0,0 +1,414 @@
"""
Combat system data models.
This module defines the combat-related dataclasses including Combatant (a wrapper
for characters/enemies in combat) and CombatEncounter (the combat state manager).
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
import random
from app.models.stats import Stats
from app.models.effects import Effect
from app.models.abilities import Ability
from app.models.enums import CombatStatus, EffectType
@dataclass
class Combatant:
"""
Represents a character or enemy in combat.
This wraps either a player Character or an NPC/enemy for combat purposes,
tracking combat-specific state like current HP/MP, active effects, and cooldowns.
Attributes:
combatant_id: Unique identifier (character_id or enemy_id)
name: Display name
is_player: True if player character, False if NPC/enemy
current_hp: Current hit points
max_hp: Maximum hit points
current_mp: Current mana points
max_mp: Maximum mana points
stats: Current combat stats (use get_effective_stats() from Character)
active_effects: Effects currently applied to this combatant
abilities: Available abilities for this combatant
cooldowns: Map of ability_id to turns remaining
initiative: Turn order value (rolled at combat start)
"""
combatant_id: str
name: str
is_player: bool
current_hp: int
max_hp: int
current_mp: int
max_mp: int
stats: Stats
active_effects: List[Effect] = field(default_factory=list)
abilities: List[str] = field(default_factory=list) # ability_ids
cooldowns: Dict[str, int] = field(default_factory=dict)
initiative: int = 0
def is_alive(self) -> bool:
"""Check if combatant is still alive."""
return self.current_hp > 0
def is_dead(self) -> bool:
"""Check if combatant is dead."""
return self.current_hp <= 0
def is_stunned(self) -> bool:
"""Check if combatant is stunned and cannot act."""
return any(e.effect_type == EffectType.STUN for e in self.active_effects)
def take_damage(self, damage: int) -> int:
"""
Apply damage to this combatant.
Damage is reduced by shields first, then HP.
Args:
damage: Amount of damage to apply
Returns:
Actual damage dealt to HP (after shields)
"""
remaining_damage = damage
# Apply shield absorption
for effect in self.active_effects:
if effect.effect_type == EffectType.SHIELD and remaining_damage > 0:
remaining_damage = effect.reduce_shield(remaining_damage)
# Apply remaining damage to HP
hp_damage = min(remaining_damage, self.current_hp)
self.current_hp -= hp_damage
return hp_damage
def heal(self, amount: int) -> int:
"""
Heal this combatant.
Args:
amount: Amount to heal
Returns:
Actual amount healed (capped at max_hp)
"""
old_hp = self.current_hp
self.current_hp = min(self.max_hp, self.current_hp + amount)
return self.current_hp - old_hp
def restore_mana(self, amount: int) -> int:
"""
Restore mana to this combatant.
Args:
amount: Amount to restore
Returns:
Actual amount restored (capped at max_mp)
"""
old_mp = self.current_mp
self.current_mp = min(self.max_mp, self.current_mp + amount)
return self.current_mp - old_mp
def can_use_ability(self, ability_id: str, ability: Ability) -> bool:
"""
Check if ability can be used right now.
Args:
ability_id: Ability identifier
ability: Ability instance
Returns:
True if ability can be used, False otherwise
"""
# Check if ability is available to this combatant
if ability_id not in self.abilities:
return False
# Check mana cost
if self.current_mp < ability.mana_cost:
return False
# Check cooldown
if ability_id in self.cooldowns and self.cooldowns[ability_id] > 0:
return False
return True
def use_ability_cost(self, ability: Ability, ability_id: str) -> None:
"""
Apply the costs of using an ability (mana, cooldown).
Args:
ability: Ability being used
ability_id: Ability identifier
"""
# Consume mana
self.current_mp -= ability.mana_cost
# Set cooldown
if ability.cooldown > 0:
self.cooldowns[ability_id] = ability.cooldown
def tick_effects(self) -> List[Dict[str, Any]]:
"""
Process all active effects for this turn.
Returns:
List of effect tick results
"""
results = []
expired_effects = []
for effect in self.active_effects:
result = effect.tick()
# Apply effect results
if effect.effect_type == EffectType.DOT:
self.take_damage(result["value"])
elif effect.effect_type == EffectType.HOT:
self.heal(result["value"])
results.append(result)
# Mark expired effects for removal
if result.get("expired", False):
expired_effects.append(effect)
# Remove expired effects
for effect in expired_effects:
self.active_effects.remove(effect)
return results
def tick_cooldowns(self) -> None:
"""Reduce all ability cooldowns by 1 turn."""
for ability_id in list(self.cooldowns.keys()):
self.cooldowns[ability_id] -= 1
if self.cooldowns[ability_id] <= 0:
del self.cooldowns[ability_id]
def add_effect(self, effect: Effect) -> None:
"""
Add an effect to this combatant.
If the same effect already exists, stack it instead.
Args:
effect: Effect to add
"""
# Check if effect already exists
for existing in self.active_effects:
if existing.name == effect.name and existing.effect_type == effect.effect_type:
# Stack the effect
existing.apply_stack(effect.duration)
return
# New effect, add it
self.active_effects.append(effect)
def to_dict(self) -> Dict[str, Any]:
"""Serialize combatant to dictionary."""
return {
"combatant_id": self.combatant_id,
"name": self.name,
"is_player": self.is_player,
"current_hp": self.current_hp,
"max_hp": self.max_hp,
"current_mp": self.current_mp,
"max_mp": self.max_mp,
"stats": self.stats.to_dict(),
"active_effects": [e.to_dict() for e in self.active_effects],
"abilities": self.abilities,
"cooldowns": self.cooldowns,
"initiative": self.initiative,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Combatant':
"""Deserialize combatant from dictionary."""
stats = Stats.from_dict(data["stats"])
active_effects = [Effect.from_dict(e) for e in data.get("active_effects", [])]
return cls(
combatant_id=data["combatant_id"],
name=data["name"],
is_player=data["is_player"],
current_hp=data["current_hp"],
max_hp=data["max_hp"],
current_mp=data["current_mp"],
max_mp=data["max_mp"],
stats=stats,
active_effects=active_effects,
abilities=data.get("abilities", []),
cooldowns=data.get("cooldowns", {}),
initiative=data.get("initiative", 0),
)
@dataclass
class CombatEncounter:
"""
Represents a combat encounter state.
Manages turn order, combatants, combat log, and victory/defeat conditions.
Attributes:
encounter_id: Unique identifier
combatants: All fighters in this combat
turn_order: Combatant IDs sorted by initiative (highest first)
current_turn_index: Index in turn_order for current turn
round_number: Current round (increments each full turn cycle)
combat_log: History of all actions taken
status: Current combat status (active, victory, defeat, fled)
"""
encounter_id: str
combatants: List[Combatant] = field(default_factory=list)
turn_order: List[str] = field(default_factory=list)
current_turn_index: int = 0
round_number: int = 1
combat_log: List[Dict[str, Any]] = field(default_factory=list)
status: CombatStatus = CombatStatus.ACTIVE
def initialize_combat(self) -> None:
"""
Initialize combat by rolling initiative and setting turn order.
Initiative: d20 + dexterity bonus
"""
# Roll initiative for all combatants
for combatant in self.combatants:
# d20 + dexterity bonus
roll = random.randint(1, 20)
dex_bonus = combatant.stats.dexterity // 2
combatant.initiative = roll + dex_bonus
# Sort combatants by initiative (highest first)
sorted_combatants = sorted(self.combatants, key=lambda c: c.initiative, reverse=True)
self.turn_order = [c.combatant_id for c in sorted_combatants]
self.log_action("combat_start", None, f"Combat begins! Round {self.round_number}")
def get_current_combatant(self) -> Optional[Combatant]:
"""Get the combatant whose turn it currently is."""
if not self.turn_order:
return None
current_id = self.turn_order[self.current_turn_index]
return self.get_combatant(current_id)
def get_combatant(self, combatant_id: str) -> Optional[Combatant]:
"""Get a combatant by ID."""
for combatant in self.combatants:
if combatant.combatant_id == combatant_id:
return combatant
return None
def advance_turn(self) -> None:
"""Advance to the next combatant's turn."""
self.current_turn_index += 1
# If we've cycled through all combatants, start a new round
if self.current_turn_index >= len(self.turn_order):
self.current_turn_index = 0
self.round_number += 1
self.log_action("round_start", None, f"Round {self.round_number} begins")
def start_turn(self) -> List[Dict[str, Any]]:
"""
Process the start of a turn.
- Tick all effects on current combatant
- Tick cooldowns
- Check for stun
Returns:
List of effect tick results
"""
combatant = self.get_current_combatant()
if not combatant:
return []
# Process effects
effect_results = combatant.tick_effects()
# Reduce cooldowns
combatant.tick_cooldowns()
return effect_results
def check_end_condition(self) -> CombatStatus:
"""
Check if combat should end.
Victory: All enemy combatants dead
Defeat: All player combatants dead
Returns:
Updated combat status
"""
players_alive = any(c.is_alive() and c.is_player for c in self.combatants)
enemies_alive = any(c.is_alive() and not c.is_player for c in self.combatants)
if not enemies_alive and players_alive:
self.status = CombatStatus.VICTORY
self.log_action("combat_end", None, "Victory! All enemies defeated!")
elif not players_alive:
self.status = CombatStatus.DEFEAT
self.log_action("combat_end", None, "Defeat! All players have fallen!")
return self.status
def log_action(self, action_type: str, combatant_id: Optional[str], message: str, details: Optional[Dict[str, Any]] = None) -> None:
"""
Log a combat action.
Args:
action_type: Type of action (attack, spell, item_use, etc.)
combatant_id: ID of acting combatant (or None for system messages)
message: Human-readable message
details: Additional action details
"""
entry = {
"round": self.round_number,
"action_type": action_type,
"combatant_id": combatant_id,
"message": message,
"details": details or {},
}
self.combat_log.append(entry)
def to_dict(self) -> Dict[str, Any]:
"""Serialize combat encounter to dictionary."""
return {
"encounter_id": self.encounter_id,
"combatants": [c.to_dict() for c in self.combatants],
"turn_order": self.turn_order,
"current_turn_index": self.current_turn_index,
"round_number": self.round_number,
"combat_log": self.combat_log,
"status": self.status.value,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'CombatEncounter':
"""Deserialize combat encounter from dictionary."""
combatants = [Combatant.from_dict(c) for c in data.get("combatants", [])]
status = CombatStatus(data.get("status", "active"))
return cls(
encounter_id=data["encounter_id"],
combatants=combatants,
turn_order=data.get("turn_order", []),
current_turn_index=data.get("current_turn_index", 0),
round_number=data.get("round_number", 1),
combat_log=data.get("combat_log", []),
status=status,
)

208
api/app/models/effects.py Normal file
View File

@@ -0,0 +1,208 @@
"""
Effect system for temporary status modifiers in combat.
This module defines the Effect dataclass which represents temporary buffs,
debuffs, damage over time, healing over time, stuns, and shields.
"""
from dataclasses import dataclass, asdict
from typing import Dict, Any, Optional
from app.models.enums import EffectType, StatType
@dataclass
class Effect:
"""
Represents a temporary effect applied to a combatant.
Effects are processed at the start of each turn via the tick() method.
They can stack up to max_stacks, and duration refreshes on re-application.
Attributes:
effect_id: Unique identifier for this effect instance
name: Display name of the effect
effect_type: Type of effect (BUFF, DEBUFF, DOT, HOT, STUN, SHIELD)
duration: Turns remaining before effect expires
power: Damage/healing per turn OR stat modifier amount
stat_affected: Which stat is modified (for BUFF/DEBUFF only)
stacks: Number of times this effect has been stacked
max_stacks: Maximum number of stacks allowed (default 5)
source: Who/what applied this effect (character_id or ability_id)
"""
effect_id: str
name: str
effect_type: EffectType
duration: int
power: int
stat_affected: Optional[StatType] = None
stacks: int = 1
max_stacks: int = 5
source: str = ""
def tick(self) -> Dict[str, Any]:
"""
Process one turn of this effect.
Returns a dictionary describing what happened this turn, including:
- effect_name: Name of the effect
- effect_type: Type of effect
- value: Damage dealt (DOT) or healing done (HOT)
- shield_remaining: Current shield strength (SHIELD only)
- stunned: True if this is a stun effect (STUN only)
- stat_modifier: Amount stats are modified (BUFF/DEBUFF only)
- expired: True if effect duration reached 0
Returns:
Dictionary with effect processing results
"""
result = {
"effect_name": self.name,
"effect_type": self.effect_type.value,
"value": 0,
"expired": False,
}
# Process effect based on type
if self.effect_type == EffectType.DOT:
# Damage over time: deal damage equal to power × stacks
result["value"] = self.power * self.stacks
result["message"] = f"{self.name} deals {result['value']} damage"
elif self.effect_type == EffectType.HOT:
# Heal over time: heal equal to power × stacks
result["value"] = self.power * self.stacks
result["message"] = f"{self.name} heals {result['value']} HP"
elif self.effect_type == EffectType.STUN:
# Stun: prevents actions this turn
result["stunned"] = True
result["message"] = f"{self.name} prevents actions"
elif self.effect_type == EffectType.SHIELD:
# Shield: absorbs damage (power × stacks = shield strength)
result["shield_remaining"] = self.power * self.stacks
result["message"] = f"{self.name} absorbs up to {result['shield_remaining']} damage"
elif self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
# Buff/Debuff: modify stats
result["stat_affected"] = self.stat_affected.value if self.stat_affected else None
result["stat_modifier"] = self.power * self.stacks
if self.effect_type == EffectType.BUFF:
result["message"] = f"{self.name} increases {result['stat_affected']} by {result['stat_modifier']}"
else:
result["message"] = f"{self.name} decreases {result['stat_affected']} by {result['stat_modifier']}"
# Decrease duration
self.duration -= 1
if self.duration <= 0:
result["expired"] = True
result["message"] = f"{self.name} has expired"
return result
def apply_stack(self, additional_duration: int = 0) -> None:
"""
Apply an additional stack of this effect.
Increases stack count (up to max_stacks) and refreshes duration.
If additional_duration is provided, it's added to current duration.
Args:
additional_duration: Extra turns to add (default 0 = refresh only)
"""
if self.stacks < self.max_stacks:
self.stacks += 1
# Refresh duration or extend it
if additional_duration > 0:
self.duration = max(self.duration, additional_duration)
else:
# Find the base duration (current + turns already consumed)
# For refresh behavior, we'd need to store original_duration
# For now, just use the provided duration
pass
def reduce_shield(self, damage: int) -> int:
"""
Reduce shield strength by damage amount.
Only applicable for SHIELD effects. Returns remaining damage after shield.
Args:
damage: Amount of damage to absorb
Returns:
Remaining damage after shield absorption
"""
if self.effect_type != EffectType.SHIELD:
return damage
shield_strength = self.power * self.stacks
if damage >= shield_strength:
# Shield breaks completely
remaining_damage = damage - shield_strength
self.power = 0 # Shield depleted
self.duration = 0 # Effect expires
return remaining_damage
else:
# Shield partially absorbs damage
damage_per_stack = damage / self.stacks
self.power = max(0, int(self.power - damage_per_stack))
return 0
def to_dict(self) -> Dict[str, Any]:
"""
Serialize effect to a dictionary.
Returns:
Dictionary containing all effect data
"""
data = asdict(self)
data["effect_type"] = self.effect_type.value
if self.stat_affected:
data["stat_affected"] = self.stat_affected.value
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Effect':
"""
Deserialize effect from a dictionary.
Args:
data: Dictionary containing effect data
Returns:
Effect instance
"""
# Convert string values back to enums
effect_type = EffectType(data["effect_type"])
stat_affected = StatType(data["stat_affected"]) if data.get("stat_affected") else None
return cls(
effect_id=data["effect_id"],
name=data["name"],
effect_type=effect_type,
duration=data["duration"],
power=data["power"],
stat_affected=stat_affected,
stacks=data.get("stacks", 1),
max_stacks=data.get("max_stacks", 5),
source=data.get("source", ""),
)
def __repr__(self) -> str:
"""String representation of the effect."""
if self.effect_type in [EffectType.BUFF, EffectType.DEBUFF]:
return (
f"Effect({self.name}, {self.effect_type.value}, "
f"{self.stat_affected.value if self.stat_affected else 'N/A'} "
f"{'+' if self.effect_type == EffectType.BUFF else '-'}{self.power * self.stacks}, "
f"{self.duration}t, {self.stacks}x)"
)
else:
return (
f"Effect({self.name}, {self.effect_type.value}, "
f"power={self.power * self.stacks}, "
f"duration={self.duration}t, stacks={self.stacks}x)"
)

113
api/app/models/enums.py Normal file
View File

@@ -0,0 +1,113 @@
"""
Enumeration types for the Code of Conquest game system.
This module defines all enum types used throughout the data models to ensure
type safety and prevent invalid values.
"""
from enum import Enum
class EffectType(Enum):
"""Types of effects that can be applied to combatants."""
BUFF = "buff" # Temporarily increase stats
DEBUFF = "debuff" # Temporarily decrease stats
DOT = "dot" # Damage over time (poison, bleed, burn)
HOT = "hot" # Heal over time (regeneration)
STUN = "stun" # Prevent actions (skip turn)
SHIELD = "shield" # Absorb damage before HP loss
class DamageType(Enum):
"""Types of damage that can be dealt in combat."""
PHYSICAL = "physical" # Standard weapon damage
FIRE = "fire" # Fire-based magic damage
ICE = "ice" # Ice-based magic damage
LIGHTNING = "lightning" # Lightning-based magic damage
HOLY = "holy" # Holy/divine damage
SHADOW = "shadow" # Dark/shadow magic damage
POISON = "poison" # Poison damage (usually DoT)
class ItemType(Enum):
"""Categories of items in the game."""
WEAPON = "weapon" # Adds damage, may have special effects
ARMOR = "armor" # Adds defense/resistance
CONSUMABLE = "consumable" # One-time use (potions, scrolls)
QUEST_ITEM = "quest_item" # Story-related, non-tradeable
class StatType(Enum):
"""Character attribute types."""
STRENGTH = "strength" # Physical power
DEXTERITY = "dexterity" # Agility and precision
CONSTITUTION = "constitution" # Endurance and health
INTELLIGENCE = "intelligence" # Magical power
WISDOM = "wisdom" # Perception and insight
CHARISMA = "charisma" # Social influence
class AbilityType(Enum):
"""Categories of abilities that can be used in combat or exploration."""
ATTACK = "attack" # Basic physical attack
SPELL = "spell" # Magical spell
SKILL = "skill" # Special class ability
ITEM_USE = "item_use" # Using a consumable item
DEFEND = "defend" # Defensive action
class CombatStatus(Enum):
"""Status of a combat encounter."""
ACTIVE = "active" # Combat is ongoing
VICTORY = "victory" # Player(s) won
DEFEAT = "defeat" # Player(s) lost
FLED = "fled" # Player(s) escaped
class SessionStatus(Enum):
"""Status of a game session."""
ACTIVE = "active" # Session is ongoing
COMPLETED = "completed" # Session ended normally
TIMEOUT = "timeout" # Session ended due to inactivity
class ListingStatus(Enum):
"""Status of a marketplace listing."""
ACTIVE = "active" # Listing is live
SOLD = "sold" # Item has been sold
EXPIRED = "expired" # Listing time ran out
REMOVED = "removed" # Seller cancelled listing
class ListingType(Enum):
"""Type of marketplace listing."""
AUCTION = "auction" # Bidding system
FIXED_PRICE = "fixed_price" # Immediate purchase at set price
class SessionType(Enum):
"""Type of game session."""
SOLO = "solo" # Single-player session
MULTIPLAYER = "multiplayer" # Multi-player party session
class LocationType(Enum):
"""Types of locations in the game world."""
TOWN = "town" # Town or city
TAVERN = "tavern" # Tavern or inn
WILDERNESS = "wilderness" # Outdoor wilderness areas
DUNGEON = "dungeon" # Underground dungeons/caves
RUINS = "ruins" # Ancient ruins
LIBRARY = "library" # Library or archive
SAFE_AREA = "safe_area" # Safe rest areas

196
api/app/models/items.py Normal file
View File

@@ -0,0 +1,196 @@
"""
Item system for equipment, consumables, and quest items.
This module defines the Item dataclass representing all types of items in the game,
including weapons, armor, consumables, and quest items.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from app.models.enums import ItemType, DamageType
from app.models.effects import Effect
@dataclass
class Item:
"""
Represents an item in the game (weapon, armor, consumable, or quest item).
Items can provide passive stat bonuses when equipped, have weapon/armor stats,
or provide effects when consumed.
Attributes:
item_id: Unique identifier
name: Display name
item_type: Category (weapon, armor, consumable, quest_item)
description: Item lore and information
value: Gold value for buying/selling
is_tradeable: Whether item can be sold on marketplace
stat_bonuses: Passive bonuses to stats when equipped
Example: {"strength": 5, "constitution": 3}
effects_on_use: Effects applied when consumed (consumables only)
Weapon-specific attributes:
damage: Base weapon damage
damage_type: Type of damage (physical, fire, etc.)
crit_chance: Probability of critical hit (0.0 to 1.0)
crit_multiplier: Damage multiplier on critical hit
Armor-specific attributes:
defense: Physical defense bonus
resistance: Magical resistance bonus
Requirements (future):
required_level: Minimum character level to use
required_class: Class restriction (if any)
"""
item_id: str
name: str
item_type: ItemType
description: str
value: int = 0
is_tradeable: bool = True
# Passive bonuses (equipment)
stat_bonuses: Dict[str, int] = field(default_factory=dict)
# Active effects (consumables)
effects_on_use: List[Effect] = field(default_factory=list)
# Weapon-specific
damage: int = 0
damage_type: Optional[DamageType] = None
crit_chance: float = 0.05 # 5% default critical hit chance
crit_multiplier: float = 2.0 # 2x damage on critical hit
# Armor-specific
defense: int = 0
resistance: int = 0
# Requirements (future expansion)
required_level: int = 1
required_class: Optional[str] = None
def is_weapon(self) -> bool:
"""Check if this item is a weapon."""
return self.item_type == ItemType.WEAPON
def is_armor(self) -> bool:
"""Check if this item is armor."""
return self.item_type == ItemType.ARMOR
def is_consumable(self) -> bool:
"""Check if this item is a consumable."""
return self.item_type == ItemType.CONSUMABLE
def is_quest_item(self) -> bool:
"""Check if this item is a quest item."""
return self.item_type == ItemType.QUEST_ITEM
def can_equip(self, character_level: int, character_class: Optional[str] = None) -> bool:
"""
Check if a character can equip this item.
Args:
character_level: Character's current level
character_class: Character's class (if class restrictions exist)
Returns:
True if item can be equipped, False otherwise
"""
# Check level requirement
if character_level < self.required_level:
return False
# Check class requirement
if self.required_class and character_class != self.required_class:
return False
return True
def get_total_stat_bonus(self, stat_name: str) -> int:
"""
Get the total bonus for a specific stat from this item.
Args:
stat_name: Name of the stat (e.g., "strength", "intelligence")
Returns:
Bonus value for that stat (0 if not present)
"""
return self.stat_bonuses.get(stat_name, 0)
def to_dict(self) -> Dict[str, Any]:
"""
Serialize item to a dictionary.
Returns:
Dictionary containing all item data
"""
data = asdict(self)
data["item_type"] = self.item_type.value
if self.damage_type:
data["damage_type"] = self.damage_type.value
data["effects_on_use"] = [effect.to_dict() for effect in self.effects_on_use]
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Item':
"""
Deserialize item from a dictionary.
Args:
data: Dictionary containing item data
Returns:
Item instance
"""
# Convert string values back to enums
item_type = ItemType(data["item_type"])
damage_type = DamageType(data["damage_type"]) if data.get("damage_type") else None
# Deserialize effects
effects = []
if "effects_on_use" in data and data["effects_on_use"]:
effects = [Effect.from_dict(e) for e in data["effects_on_use"]]
return cls(
item_id=data["item_id"],
name=data["name"],
item_type=item_type,
description=data["description"],
value=data.get("value", 0),
is_tradeable=data.get("is_tradeable", True),
stat_bonuses=data.get("stat_bonuses", {}),
effects_on_use=effects,
damage=data.get("damage", 0),
damage_type=damage_type,
crit_chance=data.get("crit_chance", 0.05),
crit_multiplier=data.get("crit_multiplier", 2.0),
defense=data.get("defense", 0),
resistance=data.get("resistance", 0),
required_level=data.get("required_level", 1),
required_class=data.get("required_class"),
)
def __repr__(self) -> str:
"""String representation of the item."""
if self.is_weapon():
return (
f"Item({self.name}, weapon, dmg={self.damage}, "
f"crit={self.crit_chance*100:.0f}%, value={self.value}g)"
)
elif self.is_armor():
return (
f"Item({self.name}, armor, def={self.defense}, "
f"res={self.resistance}, value={self.value}g)"
)
elif self.is_consumable():
return (
f"Item({self.name}, consumable, "
f"effects={len(self.effects_on_use)}, value={self.value}g)"
)
else:
return f"Item({self.name}, quest_item)"

181
api/app/models/location.py Normal file
View File

@@ -0,0 +1,181 @@
"""
Location data models for the world exploration system.
This module defines Location and Region dataclasses that represent structured
game world data. Locations are loaded from YAML files and provide rich context
for AI narrative generation.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional
from app.models.enums import LocationType
@dataclass
class Location:
"""
Represents a defined location in the game world.
Locations are persistent world entities with NPCs, quests, and connections
to other locations. They are loaded from YAML files at runtime.
Attributes:
location_id: Unique identifier (e.g., "crossville_tavern")
name: Display name (e.g., "The Rusty Anchor Tavern")
location_type: Type of location (town, tavern, wilderness, dungeon, etc.)
region_id: Parent region this location belongs to
description: Full description for AI narrative context
lore: Optional historical/background information
ambient_description: Atmospheric details for AI narration
available_quests: Quest IDs that can be discovered at this location
npc_ids: List of NPC IDs present at this location
discoverable_locations: Location IDs that can be revealed from here
is_starting_location: Whether this is a valid origin starting point
tags: Additional metadata tags for filtering/categorization
"""
location_id: str
name: str
location_type: LocationType
region_id: str
description: str
lore: Optional[str] = None
ambient_description: Optional[str] = None
available_quests: List[str] = field(default_factory=list)
npc_ids: List[str] = field(default_factory=list)
discoverable_locations: List[str] = field(default_factory=list)
is_starting_location: bool = False
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""
Serialize location to dictionary for JSON responses.
Returns:
Dictionary containing all location data
"""
return {
"location_id": self.location_id,
"name": self.name,
"location_type": self.location_type.value,
"region_id": self.region_id,
"description": self.description,
"lore": self.lore,
"ambient_description": self.ambient_description,
"available_quests": self.available_quests,
"npc_ids": self.npc_ids,
"discoverable_locations": self.discoverable_locations,
"is_starting_location": self.is_starting_location,
"tags": self.tags,
}
def to_story_dict(self) -> Dict[str, Any]:
"""
Serialize location for AI narrative context.
Returns a trimmed version with only narrative-relevant data
to reduce token usage in AI prompts.
Returns:
Dictionary containing story-relevant location data
"""
return {
"name": self.name,
"type": self.location_type.value,
"description": self.description,
"ambient": self.ambient_description,
"lore": self.lore,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Location':
"""
Deserialize location from dictionary.
Args:
data: Dictionary containing location data (from YAML or JSON)
Returns:
Location instance
"""
# Handle location_type - can be string or LocationType enum
location_type = data.get("location_type", "town")
if isinstance(location_type, str):
location_type = LocationType(location_type)
return cls(
location_id=data["location_id"],
name=data["name"],
location_type=location_type,
region_id=data["region_id"],
description=data["description"],
lore=data.get("lore"),
ambient_description=data.get("ambient_description"),
available_quests=data.get("available_quests", []),
npc_ids=data.get("npc_ids", []),
discoverable_locations=data.get("discoverable_locations", []),
is_starting_location=data.get("is_starting_location", False),
tags=data.get("tags", []),
)
def __repr__(self) -> str:
"""String representation of the location."""
return f"Location({self.location_id}, {self.name}, {self.location_type.value})"
@dataclass
class Region:
"""
Represents a geographical region containing multiple locations.
Regions group related locations together for organizational purposes
and can contain region-wide lore or events.
Attributes:
region_id: Unique identifier (e.g., "crossville")
name: Display name (e.g., "Crossville Province")
description: Region overview and atmosphere
location_ids: List of all location IDs in this region
"""
region_id: str
name: str
description: str
location_ids: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""
Serialize region to dictionary.
Returns:
Dictionary containing all region data
"""
return {
"region_id": self.region_id,
"name": self.name,
"description": self.description,
"location_ids": self.location_ids,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Region':
"""
Deserialize region from dictionary.
Args:
data: Dictionary containing region data
Returns:
Region instance
"""
return cls(
region_id=data["region_id"],
name=data["name"],
description=data["description"],
location_ids=data.get("location_ids", []),
)
def __repr__(self) -> str:
"""String representation of the region."""
return f"Region({self.region_id}, {self.name}, {len(self.location_ids)} locations)"

View File

@@ -0,0 +1,401 @@
"""
Marketplace and economy data models.
This module defines the marketplace-related dataclasses including
MarketplaceListing, Bid, Transaction, and ShopItem for the player economy.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from datetime import datetime
from app.models.items import Item
from app.models.enums import ListingType, ListingStatus
@dataclass
class Bid:
"""
Represents a bid on an auction listing.
Attributes:
bidder_id: User ID of the bidder
bidder_name: Character name of the bidder
amount: Bid amount in gold
timestamp: ISO timestamp of when bid was placed
"""
bidder_id: str
bidder_name: str
amount: int
timestamp: str = ""
def __post_init__(self):
"""Initialize timestamp if not provided."""
if not self.timestamp:
self.timestamp = datetime.utcnow().isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Serialize bid to dictionary."""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Bid':
"""Deserialize bid from dictionary."""
return cls(
bidder_id=data["bidder_id"],
bidder_name=data["bidder_name"],
amount=data["amount"],
timestamp=data.get("timestamp", ""),
)
@dataclass
class MarketplaceListing:
"""
Represents an item listing on the player marketplace.
Supports both fixed-price and auction-style listings.
Attributes:
listing_id: Unique identifier
seller_id: User ID of the seller
character_id: Character ID of the seller
item_data: Full item details being sold
listing_type: "auction" or "fixed_price"
price: For fixed_price listings
starting_bid: Minimum bid for auction listings
current_bid: Current highest bid for auction listings
buyout_price: Optional instant-buy price for auctions
bids: Bid history for auction listings
auction_end: ISO timestamp when auction ends
status: Listing status (active, sold, expired, removed)
created_at: ISO timestamp of listing creation
"""
listing_id: str
seller_id: str
character_id: str
item_data: Item
listing_type: ListingType
status: ListingStatus = ListingStatus.ACTIVE
created_at: str = ""
# Fixed price fields
price: int = 0
# Auction fields
starting_bid: int = 0
current_bid: int = 0
buyout_price: int = 0
bids: List[Bid] = field(default_factory=list)
auction_end: str = ""
def __post_init__(self):
"""Initialize timestamps if not provided."""
if not self.created_at:
self.created_at = datetime.utcnow().isoformat()
def is_auction(self) -> bool:
"""Check if this is an auction listing."""
return self.listing_type == ListingType.AUCTION
def is_fixed_price(self) -> bool:
"""Check if this is a fixed-price listing."""
return self.listing_type == ListingType.FIXED_PRICE
def is_active(self) -> bool:
"""Check if listing is active."""
return self.status == ListingStatus.ACTIVE
def has_ended(self) -> bool:
"""Check if auction has ended (for auction listings)."""
if not self.is_auction() or not self.auction_end:
return False
end_time = datetime.fromisoformat(self.auction_end)
return datetime.utcnow() >= end_time
def can_bid(self, bid_amount: int) -> bool:
"""
Check if a bid amount is valid.
Args:
bid_amount: Proposed bid amount
Returns:
True if bid is valid, False otherwise
"""
if not self.is_auction() or not self.is_active():
return False
if self.has_ended():
return False
# First bid must meet starting bid
if not self.bids and bid_amount < self.starting_bid:
return False
# Subsequent bids must exceed current bid
if self.bids and bid_amount <= self.current_bid:
return False
return True
def place_bid(self, bidder_id: str, bidder_name: str, amount: int) -> bool:
"""
Place a bid on this auction.
Args:
bidder_id: User ID of bidder
bidder_name: Character name of bidder
amount: Bid amount
Returns:
True if bid was accepted, False otherwise
"""
if not self.can_bid(amount):
return False
bid = Bid(
bidder_id=bidder_id,
bidder_name=bidder_name,
amount=amount,
)
self.bids.append(bid)
self.current_bid = amount
return True
def buyout(self) -> bool:
"""
Attempt to buy out the auction immediately.
Returns:
True if buyout is available and successful, False otherwise
"""
if not self.is_auction() or not self.buyout_price:
return False
if not self.is_active() or self.has_ended():
return False
self.current_bid = self.buyout_price
self.status = ListingStatus.SOLD
return True
def get_winning_bidder(self) -> Optional[Bid]:
"""
Get the current winning bid.
Returns:
Winning Bid or None if no bids
"""
if not self.bids:
return None
# Bids are added chronologically, last one is highest
return self.bids[-1]
def cancel_listing(self) -> bool:
"""
Cancel this listing (seller action).
Returns:
True if successfully cancelled, False if cannot be cancelled
"""
if not self.is_active():
return False
# Cannot cancel auction with bids
if self.is_auction() and self.bids:
return False
self.status = ListingStatus.REMOVED
return True
def complete_sale(self) -> None:
"""Mark listing as sold."""
self.status = ListingStatus.SOLD
def expire_listing(self) -> None:
"""Mark listing as expired."""
self.status = ListingStatus.EXPIRED
def to_dict(self) -> Dict[str, Any]:
"""Serialize listing to dictionary."""
return {
"listing_id": self.listing_id,
"seller_id": self.seller_id,
"character_id": self.character_id,
"item_data": self.item_data.to_dict(),
"listing_type": self.listing_type.value,
"status": self.status.value,
"created_at": self.created_at,
"price": self.price,
"starting_bid": self.starting_bid,
"current_bid": self.current_bid,
"buyout_price": self.buyout_price,
"bids": [bid.to_dict() for bid in self.bids],
"auction_end": self.auction_end,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'MarketplaceListing':
"""Deserialize listing from dictionary."""
item_data = Item.from_dict(data["item_data"])
listing_type = ListingType(data["listing_type"])
status = ListingStatus(data.get("status", "active"))
bids = [Bid.from_dict(b) for b in data.get("bids", [])]
return cls(
listing_id=data["listing_id"],
seller_id=data["seller_id"],
character_id=data["character_id"],
item_data=item_data,
listing_type=listing_type,
status=status,
created_at=data.get("created_at", ""),
price=data.get("price", 0),
starting_bid=data.get("starting_bid", 0),
current_bid=data.get("current_bid", 0),
buyout_price=data.get("buyout_price", 0),
bids=bids,
auction_end=data.get("auction_end", ""),
)
@dataclass
class Transaction:
"""
Record of a completed transaction.
Tracks all sales for auditing and analytics.
Attributes:
transaction_id: Unique identifier
buyer_id: User ID of buyer
seller_id: User ID of seller
listing_id: Marketplace listing ID (if from marketplace)
item_data: Item that was sold
price: Final sale price in gold
timestamp: ISO timestamp of transaction
transaction_type: "marketplace_sale", "shop_purchase", etc.
"""
transaction_id: str
buyer_id: str
seller_id: str
item_data: Item
price: int
transaction_type: str
listing_id: str = ""
timestamp: str = ""
def __post_init__(self):
"""Initialize timestamp if not provided."""
if not self.timestamp:
self.timestamp = datetime.utcnow().isoformat()
def to_dict(self) -> Dict[str, Any]:
"""Serialize transaction to dictionary."""
return {
"transaction_id": self.transaction_id,
"buyer_id": self.buyer_id,
"seller_id": self.seller_id,
"listing_id": self.listing_id,
"item_data": self.item_data.to_dict(),
"price": self.price,
"timestamp": self.timestamp,
"transaction_type": self.transaction_type,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Transaction':
"""Deserialize transaction from dictionary."""
item_data = Item.from_dict(data["item_data"])
return cls(
transaction_id=data["transaction_id"],
buyer_id=data["buyer_id"],
seller_id=data["seller_id"],
listing_id=data.get("listing_id", ""),
item_data=item_data,
price=data["price"],
timestamp=data.get("timestamp", ""),
transaction_type=data["transaction_type"],
)
@dataclass
class ShopItem:
"""
Item sold by NPC shops.
Attributes:
item_id: Item identifier
item: Item details
stock: Available quantity (-1 = unlimited)
price: Fixed gold price
"""
item_id: str
item: Item
stock: int = -1 # -1 = unlimited
price: int = 0
def is_in_stock(self) -> bool:
"""Check if item is available for purchase."""
return self.stock != 0
def purchase(self, quantity: int = 1) -> bool:
"""
Attempt to purchase from stock.
Args:
quantity: Number of items to purchase
Returns:
True if purchase successful, False if insufficient stock
"""
if self.stock == -1: # Unlimited stock
return True
if self.stock < quantity:
return False
self.stock -= quantity
return True
def restock(self, quantity: int) -> None:
"""
Add stock to this shop item.
Args:
quantity: Amount to add to stock
"""
if self.stock == -1: # Unlimited, no need to restock
return
self.stock += quantity
def to_dict(self) -> Dict[str, Any]:
"""Serialize shop item to dictionary."""
return {
"item_id": self.item_id,
"item": self.item.to_dict(),
"stock": self.stock,
"price": self.price,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ShopItem':
"""Deserialize shop item from dictionary."""
item = Item.from_dict(data["item"])
return cls(
item_id=data["item_id"],
item=item,
stock=data.get("stock", -1),
price=data.get("price", 0),
)

477
api/app/models/npc.py Normal file
View File

@@ -0,0 +1,477 @@
"""
NPC data models for persistent non-player characters.
This module defines NPC and related dataclasses that represent structured
NPC definitions loaded from YAML files. NPCs have rich personality, knowledge,
and interaction data that the AI uses for dialogue generation.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional
@dataclass
class NPCPersonality:
"""
NPC personality definition for AI dialogue generation.
Provides the AI with guidance on how to roleplay the NPC's character,
including their general traits, speaking patterns, and distinctive behaviors.
Attributes:
traits: List of personality descriptors (e.g., "gruff", "kind", "suspicious")
speech_style: Description of how the NPC speaks (accent, vocabulary, patterns)
quirks: List of distinctive behaviors or habits
"""
traits: List[str]
speech_style: str
quirks: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize personality to dictionary."""
return {
"traits": self.traits,
"speech_style": self.speech_style,
"quirks": self.quirks,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCPersonality':
"""Deserialize personality from dictionary."""
return cls(
traits=data.get("traits", []),
speech_style=data.get("speech_style", ""),
quirks=data.get("quirks", []),
)
@dataclass
class NPCAppearance:
"""
NPC physical description.
Provides visual context for AI narration and player information.
Attributes:
brief: Short one-line description for lists and quick reference
detailed: Optional longer description for detailed encounters
"""
brief: str
detailed: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Serialize appearance to dictionary."""
return {
"brief": self.brief,
"detailed": self.detailed,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCAppearance':
"""Deserialize appearance from dictionary."""
if isinstance(data, str):
# Handle simple string format
return cls(brief=data)
return cls(
brief=data.get("brief", ""),
detailed=data.get("detailed"),
)
@dataclass
class NPCKnowledgeCondition:
"""
Condition for NPC to reveal secret knowledge.
Defines when and how an NPC will share information they normally keep hidden.
Conditions are evaluated against the character's interaction state.
Attributes:
condition: Expression describing what triggers the reveal
(e.g., "interaction_count >= 3", "relationship_level >= 75")
reveals: The information that gets revealed when condition is met
"""
condition: str
reveals: str
def to_dict(self) -> Dict[str, Any]:
"""Serialize condition to dictionary."""
return {
"condition": self.condition,
"reveals": self.reveals,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledgeCondition':
"""Deserialize condition from dictionary."""
return cls(
condition=data.get("condition", ""),
reveals=data.get("reveals", ""),
)
@dataclass
class NPCKnowledge:
"""
Knowledge an NPC possesses - public and secret.
Organizes what information an NPC knows and under what circumstances
they will share it with players.
Attributes:
public: Knowledge the NPC will freely share with anyone
secret: Knowledge the NPC keeps hidden (for AI reference only)
will_share_if: Conditional reveals based on character interaction state
"""
public: List[str] = field(default_factory=list)
secret: List[str] = field(default_factory=list)
will_share_if: List[NPCKnowledgeCondition] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize knowledge to dictionary."""
return {
"public": self.public,
"secret": self.secret,
"will_share_if": [c.to_dict() for c in self.will_share_if],
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCKnowledge':
"""Deserialize knowledge from dictionary."""
conditions = [
NPCKnowledgeCondition.from_dict(c)
for c in data.get("will_share_if", [])
]
return cls(
public=data.get("public", []),
secret=data.get("secret", []),
will_share_if=conditions,
)
@dataclass
class NPCRelationship:
"""
NPC's relationship with another NPC.
Defines how this NPC feels about other NPCs in the world,
providing context for dialogue and interactions.
Attributes:
npc_id: The other NPC's identifier
attitude: How this NPC feels (e.g., "friendly", "distrustful", "romantic")
reason: Optional explanation for the attitude
"""
npc_id: str
attitude: str
reason: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Serialize relationship to dictionary."""
return {
"npc_id": self.npc_id,
"attitude": self.attitude,
"reason": self.reason,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCRelationship':
"""Deserialize relationship from dictionary."""
return cls(
npc_id=data["npc_id"],
attitude=data["attitude"],
reason=data.get("reason"),
)
@dataclass
class NPCInventoryItem:
"""
Item an NPC has for sale.
Defines items available for purchase from merchant NPCs.
Attributes:
item_id: Reference to item definition
price: Cost in gold
quantity: Stock count (None = unlimited)
"""
item_id: str
price: int
quantity: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
"""Serialize inventory item to dictionary."""
return {
"item_id": self.item_id,
"price": self.price,
"quantity": self.quantity,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCInventoryItem':
"""Deserialize inventory item from dictionary."""
# Handle shorthand format: { item: "ale", price: 2 }
item_id = data.get("item_id") or data.get("item", "")
return cls(
item_id=item_id,
price=data.get("price", 0),
quantity=data.get("quantity"),
)
@dataclass
class NPCDialogueHooks:
"""
Pre-defined dialogue snippets for AI context.
Provides example phrases the AI can use or adapt to maintain
consistent NPC voice across conversations.
Attributes:
greeting: What NPC says when first addressed
farewell: What NPC says when conversation ends
busy: What NPC says when occupied or dismissive
quest_complete: What NPC says when player completes their quest
"""
greeting: Optional[str] = None
farewell: Optional[str] = None
busy: Optional[str] = None
quest_complete: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Serialize dialogue hooks to dictionary."""
return {
"greeting": self.greeting,
"farewell": self.farewell,
"busy": self.busy,
"quest_complete": self.quest_complete,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCDialogueHooks':
"""Deserialize dialogue hooks from dictionary."""
return cls(
greeting=data.get("greeting"),
farewell=data.get("farewell"),
busy=data.get("busy"),
quest_complete=data.get("quest_complete"),
)
@dataclass
class NPC:
"""
Persistent NPC definition.
NPCs are fixed to locations and have rich personality, knowledge,
and interaction data used by the AI for dialogue generation.
Attributes:
npc_id: Unique identifier (e.g., "npc_grom_001")
name: Display name (e.g., "Grom Ironbeard")
role: NPC's job/title (e.g., "bartender", "blacksmith")
location_id: ID of location where this NPC resides
personality: Personality traits and speech patterns
appearance: Physical description
knowledge: What the NPC knows (public and secret)
relationships: How NPC feels about other NPCs
inventory_for_sale: Items NPC sells (if merchant)
dialogue_hooks: Pre-defined dialogue snippets
quest_giver_for: Quest IDs this NPC can give
reveals_locations: Location IDs this NPC can unlock through conversation
tags: Metadata tags for filtering (e.g., "merchant", "quest_giver")
"""
npc_id: str
name: str
role: str
location_id: str
personality: NPCPersonality
appearance: NPCAppearance
knowledge: Optional[NPCKnowledge] = None
relationships: List[NPCRelationship] = field(default_factory=list)
inventory_for_sale: List[NPCInventoryItem] = field(default_factory=list)
dialogue_hooks: Optional[NPCDialogueHooks] = None
quest_giver_for: List[str] = field(default_factory=list)
reveals_locations: List[str] = field(default_factory=list)
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""
Serialize NPC to dictionary for JSON responses.
Returns:
Dictionary containing all NPC data
"""
return {
"npc_id": self.npc_id,
"name": self.name,
"role": self.role,
"location_id": self.location_id,
"personality": self.personality.to_dict(),
"appearance": self.appearance.to_dict(),
"knowledge": self.knowledge.to_dict() if self.knowledge else None,
"relationships": [r.to_dict() for r in self.relationships],
"inventory_for_sale": [i.to_dict() for i in self.inventory_for_sale],
"dialogue_hooks": self.dialogue_hooks.to_dict() if self.dialogue_hooks else None,
"quest_giver_for": self.quest_giver_for,
"reveals_locations": self.reveals_locations,
"tags": self.tags,
}
def to_story_dict(self) -> Dict[str, Any]:
"""
Serialize NPC for AI dialogue context.
Returns a trimmed version focused on roleplay-relevant data
to reduce token usage in AI prompts.
Returns:
Dictionary containing story-relevant NPC data
"""
result = {
"name": self.name,
"role": self.role,
"personality": {
"traits": self.personality.traits,
"speech_style": self.personality.speech_style,
"quirks": self.personality.quirks,
},
"appearance": self.appearance.brief,
}
# Include dialogue hooks if available
if self.dialogue_hooks:
result["dialogue_hooks"] = self.dialogue_hooks.to_dict()
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPC':
"""
Deserialize NPC from dictionary.
Args:
data: Dictionary containing NPC data (from YAML or JSON)
Returns:
NPC instance
"""
# Parse personality
personality_data = data.get("personality", {})
personality = NPCPersonality.from_dict(personality_data)
# Parse appearance
appearance_data = data.get("appearance", {"brief": ""})
appearance = NPCAppearance.from_dict(appearance_data)
# Parse knowledge (optional)
knowledge = None
if data.get("knowledge"):
knowledge = NPCKnowledge.from_dict(data["knowledge"])
# Parse relationships
relationships = [
NPCRelationship.from_dict(r)
for r in data.get("relationships", [])
]
# Parse inventory
inventory = [
NPCInventoryItem.from_dict(i)
for i in data.get("inventory_for_sale", [])
]
# Parse dialogue hooks (optional)
dialogue_hooks = None
if data.get("dialogue_hooks"):
dialogue_hooks = NPCDialogueHooks.from_dict(data["dialogue_hooks"])
return cls(
npc_id=data["npc_id"],
name=data["name"],
role=data["role"],
location_id=data["location_id"],
personality=personality,
appearance=appearance,
knowledge=knowledge,
relationships=relationships,
inventory_for_sale=inventory,
dialogue_hooks=dialogue_hooks,
quest_giver_for=data.get("quest_giver_for", []),
reveals_locations=data.get("reveals_locations", []),
tags=data.get("tags", []),
)
def __repr__(self) -> str:
"""String representation of the NPC."""
return f"NPC({self.npc_id}, {self.name}, {self.role})"
@dataclass
class NPCInteractionState:
"""
Tracks a character's interaction history with an NPC.
Stored on the Character record to persist relationship data
across multiple game sessions.
Attributes:
npc_id: The NPC this state tracks
first_met: ISO timestamp of first interaction
last_interaction: ISO timestamp of most recent interaction
interaction_count: Total number of conversations
revealed_secrets: Indices of secrets that have been revealed
relationship_level: 0-100 scale (50 is neutral)
custom_flags: Arbitrary flags for special conditions
(e.g., {"helped_with_rats": true})
"""
npc_id: str
first_met: str
last_interaction: str
interaction_count: int = 0
revealed_secrets: List[int] = field(default_factory=list)
relationship_level: int = 50
custom_flags: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize interaction state to dictionary."""
return {
"npc_id": self.npc_id,
"first_met": self.first_met,
"last_interaction": self.last_interaction,
"interaction_count": self.interaction_count,
"revealed_secrets": self.revealed_secrets,
"relationship_level": self.relationship_level,
"custom_flags": self.custom_flags,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCInteractionState':
"""Deserialize interaction state from dictionary."""
return cls(
npc_id=data["npc_id"],
first_met=data["first_met"],
last_interaction=data["last_interaction"],
interaction_count=data.get("interaction_count", 0),
revealed_secrets=data.get("revealed_secrets", []),
relationship_level=data.get("relationship_level", 50),
custom_flags=data.get("custom_flags", {}),
)
def __repr__(self) -> str:
"""String representation of the interaction state."""
return (
f"NPCInteractionState({self.npc_id}, "
f"interactions={self.interaction_count}, "
f"relationship={self.relationship_level})"
)

148
api/app/models/origins.py Normal file
View File

@@ -0,0 +1,148 @@
"""
Origin data models - character backstory and starting conditions.
Origins are saved to the character and referenced by the AI DM throughout
the game to create personalized narrative experiences, quest hooks, and
story-driven interactions.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Any
@dataclass
class StartingLocation:
"""
Represents where a character begins their journey.
Attributes:
id: Unique location identifier
name: Display name of the location
region: Larger geographical area this location belongs to
description: Brief description of the location
"""
id: str
name: str
region: str
description: str
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"id": self.id,
"name": self.name,
"region": self.region,
"description": self.description,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'StartingLocation':
"""Deserialize from dictionary."""
return cls(
id=data["id"],
name=data["name"],
region=data["region"],
description=data["description"],
)
@dataclass
class StartingBonus:
"""
Represents mechanical benefits from an origin choice.
Attributes:
trait: Name of the trait/ability granted
description: What the trait represents narratively
effect: Mechanical game effect (stat bonuses, special abilities, etc.)
"""
trait: str
description: str
effect: str
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"trait": self.trait,
"description": self.description,
"effect": self.effect,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'StartingBonus':
"""Deserialize from dictionary."""
return cls(
trait=data["trait"],
description=data["description"],
effect=data["effect"],
)
@dataclass
class Origin:
"""
Represents a character's backstory and starting conditions.
Origins are permanent character attributes that the AI DM uses to
create personalized narratives, generate relevant quest hooks, and
tailor NPC interactions throughout the game.
Attributes:
id: Unique origin identifier (e.g., "soul_revenant")
name: Display name (e.g., "Soul Revenant")
description: Full backstory text that explains the origin
starting_location: Where the character begins their journey
narrative_hooks: List of story elements the AI can reference
starting_bonus: Mechanical benefits from this origin
"""
id: str
name: str
description: str
starting_location: StartingLocation
narrative_hooks: List[str] = field(default_factory=list)
starting_bonus: StartingBonus = None
def to_dict(self) -> Dict[str, Any]:
"""
Serialize origin to dictionary for JSON storage.
Returns:
Dictionary containing all origin data
"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"starting_location": self.starting_location.to_dict(),
"narrative_hooks": self.narrative_hooks,
"starting_bonus": self.starting_bonus.to_dict() if self.starting_bonus else None,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Origin':
"""
Deserialize origin from dictionary.
Args:
data: Dictionary containing origin data
Returns:
Origin instance
"""
starting_location = StartingLocation.from_dict(data["starting_location"])
starting_bonus = None
if data.get("starting_bonus"):
starting_bonus = StartingBonus.from_dict(data["starting_bonus"])
return cls(
id=data["id"],
name=data["name"],
description=data["description"],
starting_location=starting_location,
narrative_hooks=data.get("narrative_hooks", []),
starting_bonus=starting_bonus,
)
def __repr__(self) -> str:
"""String representation of the origin."""
return f"Origin({self.name}, starts at {self.starting_location.name})"

411
api/app/models/session.py Normal file
View File

@@ -0,0 +1,411 @@
"""
Game session data models.
This module defines the session-related dataclasses including SessionConfig,
GameState, and GameSession which manage multiplayer party sessions.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone
from app.models.combat import CombatEncounter
from app.models.enums import SessionStatus, SessionType
from app.models.action_prompt import LocationType
@dataclass
class SessionConfig:
"""
Configuration settings for a game session.
Attributes:
min_players: Minimum players required (session ends if below this)
timeout_minutes: Inactivity timeout in minutes
auto_save_interval: Turns between automatic saves
"""
min_players: int = 1
timeout_minutes: int = 30
auto_save_interval: int = 5
def to_dict(self) -> Dict[str, Any]:
"""Serialize configuration to dictionary."""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'SessionConfig':
"""Deserialize configuration from dictionary."""
return cls(
min_players=data.get("min_players", 1),
timeout_minutes=data.get("timeout_minutes", 30),
auto_save_interval=data.get("auto_save_interval", 5),
)
@dataclass
class GameState:
"""
Current world/quest state for a game session.
Attributes:
current_location: Current location name/ID
location_type: Type of current location (town, tavern, wilderness, etc.)
discovered_locations: All location IDs the party has visited
active_quests: Quest IDs currently in progress
world_events: Server-wide events affecting this session
"""
current_location: str = "crossville_village"
location_type: LocationType = LocationType.TOWN
discovered_locations: List[str] = field(default_factory=list)
active_quests: List[str] = field(default_factory=list)
world_events: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize game state to dictionary."""
return {
"current_location": self.current_location,
"location_type": self.location_type.value,
"discovered_locations": self.discovered_locations,
"active_quests": self.active_quests,
"world_events": self.world_events,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'GameState':
"""Deserialize game state from dictionary."""
# Handle location_type as either string or enum
location_type_value = data.get("location_type", "town")
if isinstance(location_type_value, str):
location_type = LocationType(location_type_value)
else:
location_type = location_type_value
return cls(
current_location=data.get("current_location", "crossville_village"),
location_type=location_type,
discovered_locations=data.get("discovered_locations", []),
active_quests=data.get("active_quests", []),
world_events=data.get("world_events", []),
)
@dataclass
class ConversationEntry:
"""
Single entry in the conversation history.
Attributes:
turn: Turn number
character_id: Acting character's ID
character_name: Acting character's name
action: Player's action/input text
dm_response: AI Dungeon Master's response
timestamp: ISO timestamp of when entry was created
combat_log: Combat actions if any occurred this turn
quest_offered: Quest offering info if a quest was offered this turn
"""
turn: int
character_id: str
character_name: str
action: str
dm_response: str
timestamp: str = ""
combat_log: List[Dict[str, Any]] = field(default_factory=list)
quest_offered: Optional[Dict[str, Any]] = None
def __post_init__(self):
"""Initialize timestamp if not provided."""
if not self.timestamp:
self.timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def to_dict(self) -> Dict[str, Any]:
"""Serialize conversation entry to dictionary."""
result = {
"turn": self.turn,
"character_id": self.character_id,
"character_name": self.character_name,
"action": self.action,
"dm_response": self.dm_response,
"timestamp": self.timestamp,
"combat_log": self.combat_log,
}
if self.quest_offered:
result["quest_offered"] = self.quest_offered
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ConversationEntry':
"""Deserialize conversation entry from dictionary."""
return cls(
turn=data["turn"],
character_id=data.get("character_id", ""),
character_name=data.get("character_name", ""),
action=data["action"],
dm_response=data["dm_response"],
timestamp=data.get("timestamp", ""),
combat_log=data.get("combat_log", []),
quest_offered=data.get("quest_offered"),
)
@dataclass
class GameSession:
"""
Represents a game session (solo or multiplayer).
A session can have one or more players (party) and tracks the entire
game state including conversation history, combat encounters, and
turn order.
Attributes:
session_id: Unique identifier
session_type: Type of session (solo or multiplayer)
solo_character_id: Character ID for single-player sessions (None for multiplayer)
user_id: Owner of the session
party_member_ids: Character IDs in this party (multiplayer only)
config: Session configuration settings
combat_encounter: Current combat (None if not in combat)
conversation_history: Turn-by-turn log of actions and DM responses
game_state: Current world/quest state
turn_order: Character turn order
current_turn: Index in turn_order for current turn
turn_number: Global turn counter
created_at: ISO timestamp of session creation
last_activity: ISO timestamp of last action
status: Current session status (active, completed, timeout)
"""
session_id: str
session_type: SessionType = SessionType.SOLO
solo_character_id: Optional[str] = None
user_id: str = ""
party_member_ids: List[str] = field(default_factory=list)
config: SessionConfig = field(default_factory=SessionConfig)
combat_encounter: Optional[CombatEncounter] = None
conversation_history: List[ConversationEntry] = field(default_factory=list)
game_state: GameState = field(default_factory=GameState)
turn_order: List[str] = field(default_factory=list)
current_turn: int = 0
turn_number: int = 0
created_at: str = ""
last_activity: str = ""
status: SessionStatus = SessionStatus.ACTIVE
def __post_init__(self):
"""Initialize timestamps if not provided."""
if not self.created_at:
self.created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
if not self.last_activity:
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def is_in_combat(self) -> bool:
"""Check if session is currently in combat."""
return self.combat_encounter is not None
def start_combat(self, encounter: CombatEncounter) -> None:
"""
Start a combat encounter.
Args:
encounter: The combat encounter to begin
"""
self.combat_encounter = encounter
self.update_activity()
def end_combat(self) -> None:
"""End the current combat encounter."""
self.combat_encounter = None
self.update_activity()
def advance_turn(self) -> str:
"""
Advance to the next player's turn.
Returns:
Character ID whose turn it now is
"""
if not self.turn_order:
return ""
self.current_turn = (self.current_turn + 1) % len(self.turn_order)
self.turn_number += 1
self.update_activity()
return self.turn_order[self.current_turn]
def get_current_character_id(self) -> Optional[str]:
"""Get the character ID whose turn it currently is."""
if not self.turn_order:
return None
return self.turn_order[self.current_turn]
def add_conversation_entry(self, entry: ConversationEntry) -> None:
"""
Add an entry to the conversation history.
Args:
entry: Conversation entry to add
"""
self.conversation_history.append(entry)
self.update_activity()
def update_activity(self) -> None:
"""Update the last activity timestamp to now."""
self.last_activity = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def add_party_member(self, character_id: str) -> None:
"""
Add a character to the party.
Args:
character_id: Character ID to add
"""
if character_id not in self.party_member_ids:
self.party_member_ids.append(character_id)
self.update_activity()
def remove_party_member(self, character_id: str) -> None:
"""
Remove a character from the party.
Args:
character_id: Character ID to remove
"""
if character_id in self.party_member_ids:
self.party_member_ids.remove(character_id)
# Also remove from turn order
if character_id in self.turn_order:
self.turn_order.remove(character_id)
self.update_activity()
def check_timeout(self) -> bool:
"""
Check if session has timed out due to inactivity.
Returns:
True if session should be marked as timed out
"""
if self.status != SessionStatus.ACTIVE:
return False
# Calculate time since last activity
last_activity_str = self.last_activity.replace("Z", "+00:00")
last_activity_time = datetime.fromisoformat(last_activity_str)
now = datetime.now(timezone.utc)
elapsed_minutes = (now - last_activity_time).total_seconds() / 60
if elapsed_minutes >= self.config.timeout_minutes:
self.status = SessionStatus.TIMEOUT
return True
return False
def check_min_players(self) -> bool:
"""
Check if session still has minimum required players.
Returns:
True if session should continue, False if it should end
"""
if len(self.party_member_ids) < self.config.min_players:
if self.status == SessionStatus.ACTIVE:
self.status = SessionStatus.COMPLETED
return False
return True
def is_solo(self) -> bool:
"""Check if this is a solo session."""
return self.session_type == SessionType.SOLO
def get_character_id(self) -> Optional[str]:
"""
Get the primary character ID for the session.
For solo sessions, returns solo_character_id.
For multiplayer, returns the current character in turn order.
"""
if self.is_solo():
return self.solo_character_id
return self.get_current_character_id()
def to_dict(self) -> Dict[str, Any]:
"""Serialize game session to dictionary."""
return {
"session_id": self.session_id,
"session_type": self.session_type.value,
"solo_character_id": self.solo_character_id,
"user_id": self.user_id,
"party_member_ids": self.party_member_ids,
"config": self.config.to_dict(),
"combat_encounter": self.combat_encounter.to_dict() if self.combat_encounter else None,
"conversation_history": [entry.to_dict() for entry in self.conversation_history],
"game_state": self.game_state.to_dict(),
"turn_order": self.turn_order,
"current_turn": self.current_turn,
"turn_number": self.turn_number,
"created_at": self.created_at,
"last_activity": self.last_activity,
"status": self.status.value,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'GameSession':
"""Deserialize game session from dictionary."""
config = SessionConfig.from_dict(data.get("config", {}))
game_state = GameState.from_dict(data.get("game_state", {}))
conversation_history = [
ConversationEntry.from_dict(entry)
for entry in data.get("conversation_history", [])
]
combat_encounter = None
if data.get("combat_encounter"):
combat_encounter = CombatEncounter.from_dict(data["combat_encounter"])
status = SessionStatus(data.get("status", "active"))
# Handle session_type as either string or enum
session_type_value = data.get("session_type", "solo")
if isinstance(session_type_value, str):
session_type = SessionType(session_type_value)
else:
session_type = session_type_value
return cls(
session_id=data["session_id"],
session_type=session_type,
solo_character_id=data.get("solo_character_id"),
user_id=data.get("user_id", ""),
party_member_ids=data.get("party_member_ids", []),
config=config,
combat_encounter=combat_encounter,
conversation_history=conversation_history,
game_state=game_state,
turn_order=data.get("turn_order", []),
current_turn=data.get("current_turn", 0),
turn_number=data.get("turn_number", 0),
created_at=data.get("created_at", ""),
last_activity=data.get("last_activity", ""),
status=status,
)
def __repr__(self) -> str:
"""String representation of the session."""
if self.is_solo():
return (
f"GameSession({self.session_id}, "
f"type=solo, "
f"char={self.solo_character_id}, "
f"turn={self.turn_number}, "
f"status={self.status.value})"
)
return (
f"GameSession({self.session_id}, "
f"type=multiplayer, "
f"party={len(self.party_member_ids)}, "
f"turn={self.turn_number}, "
f"status={self.status.value})"
)

290
api/app/models/skills.py Normal file
View File

@@ -0,0 +1,290 @@
"""
Skill tree and character class system.
This module defines the progression system including skill nodes, skill trees,
and player classes. Characters unlock skills by spending skill points earned
through leveling.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any, List, Optional
from app.models.stats import Stats
@dataclass
class SkillNode:
"""
Represents a single skill in a skill tree.
Skills can provide passive bonuses, unlock active abilities, or grant
access to new features (like equipment types).
Attributes:
skill_id: Unique identifier
name: Display name
description: What this skill does
tier: Skill tier (1-5, where 1 is basic and 5 is master)
prerequisites: List of skill_ids that must be unlocked first
effects: Dictionary of effects this skill provides
Examples:
- Passive bonuses: {"strength": 5, "defense": 10}
- Ability unlocks: {"unlocks_ability": "shield_bash"}
- Feature access: {"unlocks_equipment": "heavy_armor"}
unlocked: Current unlock status (used during gameplay)
"""
skill_id: str
name: str
description: str
tier: int # 1-5
prerequisites: List[str] = field(default_factory=list)
effects: Dict[str, Any] = field(default_factory=dict)
unlocked: bool = False
def has_prerequisites_met(self, unlocked_skills: List[str]) -> bool:
"""
Check if all prerequisites for this skill are met.
Args:
unlocked_skills: List of skill_ids the character has unlocked
Returns:
True if all prerequisites are met, False otherwise
"""
return all(prereq in unlocked_skills for prereq in self.prerequisites)
def get_stat_bonuses(self) -> Dict[str, int]:
"""
Extract stat bonuses from this skill's effects.
Returns:
Dictionary of stat bonuses (e.g., {"strength": 5, "defense": 3})
"""
bonuses = {}
for key, value in self.effects.items():
# Look for stat names in effects
if key in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]:
bonuses[key] = value
elif key == "defense" or key == "resistance" or key == "hit_points" or key == "mana_points":
bonuses[key] = value
return bonuses
def get_unlocked_abilities(self) -> List[str]:
"""
Extract ability IDs unlocked by this skill.
Returns:
List of ability_ids this skill unlocks
"""
abilities = []
if "unlocks_ability" in self.effects:
ability = self.effects["unlocks_ability"]
if isinstance(ability, list):
abilities.extend(ability)
else:
abilities.append(ability)
return abilities
def to_dict(self) -> Dict[str, Any]:
"""Serialize skill node to dictionary."""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'SkillNode':
"""Deserialize skill node from dictionary."""
return cls(
skill_id=data["skill_id"],
name=data["name"],
description=data["description"],
tier=data["tier"],
prerequisites=data.get("prerequisites", []),
effects=data.get("effects", {}),
unlocked=data.get("unlocked", False),
)
@dataclass
class SkillTree:
"""
Represents a complete skill tree for a character class.
Each class has 2+ skill trees representing different specializations.
Attributes:
tree_id: Unique identifier
name: Display name (e.g., "Shield Bearer", "Pyromancy")
description: Theme and purpose of this tree
nodes: All skill nodes in this tree (organized by tier)
"""
tree_id: str
name: str
description: str
nodes: List[SkillNode] = field(default_factory=list)
def can_unlock(self, skill_id: str, unlocked_skills: List[str]) -> bool:
"""
Check if a specific skill can be unlocked.
Validates:
1. Skill exists in this tree
2. Prerequisites are met
3. Tier progression rules (must unlock tier N before tier N+1)
Args:
skill_id: The skill to check
unlocked_skills: Currently unlocked skill_ids
Returns:
True if skill can be unlocked, False otherwise
"""
# Find the skill node
skill_node = None
for node in self.nodes:
if node.skill_id == skill_id:
skill_node = node
break
if not skill_node:
return False # Skill not in this tree
# Check if already unlocked
if skill_id in unlocked_skills:
return False
# Check prerequisites
if not skill_node.has_prerequisites_met(unlocked_skills):
return False
# Check tier progression
# Must have at least one skill from previous tier unlocked
# (except for tier 1 which is always available)
if skill_node.tier > 1:
has_previous_tier = False
for node in self.nodes:
if node.tier == skill_node.tier - 1 and node.skill_id in unlocked_skills:
has_previous_tier = True
break
if not has_previous_tier:
return False
return True
def get_nodes_by_tier(self, tier: int) -> List[SkillNode]:
"""
Get all skill nodes for a specific tier.
Args:
tier: Tier number (1-5)
Returns:
List of SkillNodes at that tier
"""
return [node for node in self.nodes if node.tier == tier]
def to_dict(self) -> Dict[str, Any]:
"""Serialize skill tree to dictionary."""
data = asdict(self)
data["nodes"] = [node.to_dict() for node in self.nodes]
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'SkillTree':
"""Deserialize skill tree from dictionary."""
nodes = [SkillNode.from_dict(n) for n in data.get("nodes", [])]
return cls(
tree_id=data["tree_id"],
name=data["name"],
description=data["description"],
nodes=nodes,
)
@dataclass
class PlayerClass:
"""
Represents a character class (Vanguard, Assassin, Arcanist, etc.).
Each class has unique base stats, multiple skill trees, and starting equipment.
Attributes:
class_id: Unique identifier
name: Display name
description: Class theme and playstyle
base_stats: Starting stats for this class
skill_trees: List of skill trees (2+ per class)
starting_equipment: List of item_ids for initial equipment
starting_abilities: List of ability_ids available from level 1
"""
class_id: str
name: str
description: str
base_stats: Stats
skill_trees: List[SkillTree] = field(default_factory=list)
starting_equipment: List[str] = field(default_factory=list)
starting_abilities: List[str] = field(default_factory=list)
def get_skill_tree(self, tree_id: str) -> Optional[SkillTree]:
"""
Get a specific skill tree by ID.
Args:
tree_id: Skill tree identifier
Returns:
SkillTree instance or None if not found
"""
for tree in self.skill_trees:
if tree.tree_id == tree_id:
return tree
return None
def get_all_skills(self) -> List[SkillNode]:
"""
Get all skill nodes from all trees.
Returns:
Flat list of all SkillNodes across all trees
"""
all_skills = []
for tree in self.skill_trees:
all_skills.extend(tree.nodes)
return all_skills
def to_dict(self) -> Dict[str, Any]:
"""Serialize player class to dictionary."""
return {
"class_id": self.class_id,
"name": self.name,
"description": self.description,
"base_stats": self.base_stats.to_dict(),
"skill_trees": [tree.to_dict() for tree in self.skill_trees],
"starting_equipment": self.starting_equipment,
"starting_abilities": self.starting_abilities,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PlayerClass':
"""Deserialize player class from dictionary."""
base_stats = Stats.from_dict(data["base_stats"])
skill_trees = [SkillTree.from_dict(t) for t in data.get("skill_trees", [])]
return cls(
class_id=data["class_id"],
name=data["name"],
description=data["description"],
base_stats=base_stats,
skill_trees=skill_trees,
starting_equipment=data.get("starting_equipment", []),
starting_abilities=data.get("starting_abilities", []),
)
def __repr__(self) -> str:
"""String representation of the player class."""
return (
f"PlayerClass({self.name}, "
f"trees={len(self.skill_trees)}, "
f"total_skills={len(self.get_all_skills())})"
)

140
api/app/models/stats.py Normal file
View File

@@ -0,0 +1,140 @@
"""
Character statistics data model.
This module defines the Stats dataclass which represents a character's core
attributes and provides computed properties for derived values like HP and MP.
"""
from dataclasses import dataclass, field, asdict
from typing import Dict, Any
@dataclass
class Stats:
"""
Character statistics representing core attributes.
Attributes:
strength: Physical power, affects melee damage
dexterity: Agility and precision, affects initiative and evasion
constitution: Endurance and health, affects HP and defense
intelligence: Magical power, affects spell damage and MP
wisdom: Perception and insight, affects magical resistance
charisma: Social influence, affects NPC interactions
Computed Properties:
hit_points: Maximum HP = 10 + (constitution × 2)
mana_points: Maximum MP = 10 + (intelligence × 2)
defense: Physical defense = constitution // 2
resistance: Magical resistance = wisdom // 2
"""
strength: int = 10
dexterity: int = 10
constitution: int = 10
intelligence: int = 10
wisdom: int = 10
charisma: int = 10
@property
def hit_points(self) -> int:
"""
Calculate maximum hit points based on constitution.
Formula: 10 + (constitution × 2)
Returns:
Maximum HP value
"""
return 10 + (self.constitution * 2)
@property
def mana_points(self) -> int:
"""
Calculate maximum mana points based on intelligence.
Formula: 10 + (intelligence × 2)
Returns:
Maximum MP value
"""
return 10 + (self.intelligence * 2)
@property
def defense(self) -> int:
"""
Calculate physical defense from constitution.
Formula: constitution // 2
Returns:
Physical defense value (damage reduction)
"""
return self.constitution // 2
@property
def resistance(self) -> int:
"""
Calculate magical resistance from wisdom.
Formula: wisdom // 2
Returns:
Magical resistance value (spell damage reduction)
"""
return self.wisdom // 2
def to_dict(self) -> Dict[str, Any]:
"""
Serialize stats to a dictionary.
Returns:
Dictionary containing all stat values
"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Stats':
"""
Deserialize stats from a dictionary.
Args:
data: Dictionary containing stat values
Returns:
Stats instance
"""
return cls(
strength=data.get("strength", 10),
dexterity=data.get("dexterity", 10),
constitution=data.get("constitution", 10),
intelligence=data.get("intelligence", 10),
wisdom=data.get("wisdom", 10),
charisma=data.get("charisma", 10),
)
def copy(self) -> 'Stats':
"""
Create a deep copy of this Stats instance.
Returns:
New Stats instance with same values
"""
return Stats(
strength=self.strength,
dexterity=self.dexterity,
constitution=self.constitution,
intelligence=self.intelligence,
wisdom=self.wisdom,
charisma=self.charisma,
)
def __repr__(self) -> str:
"""String representation showing all stats and computed properties."""
return (
f"Stats(STR={self.strength}, DEX={self.dexterity}, "
f"CON={self.constitution}, INT={self.intelligence}, "
f"WIS={self.wisdom}, CHA={self.charisma}, "
f"HP={self.hit_points}, MP={self.mana_points}, "
f"DEF={self.defense}, RES={self.resistance})"
)