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