""" NPC (Non-Player Character) data models """ from datetime import datetime from typing import Optional, List, Dict from enum import Enum from pydantic import BaseModel, Field class NPCRole(str, Enum): """NPC roles in the story""" QUEST_GIVER = "Quest Giver" MERCHANT = "Merchant" ALLY = "Ally" RIVAL = "Rival" VILLAIN = "Villain" MENTOR = "Mentor" INFORMANT = "Informant" GUARD = "Guard" NOBLE = "Noble" COMMONER = "Commoner" MONSTER = "Monster" COMPANION = "Companion" class NPCDisposition(str, Enum): """NPC attitude toward party""" HOSTILE = "Hostile" UNFRIENDLY = "Unfriendly" NEUTRAL = "Neutral" FRIENDLY = "Friendly" HELPFUL = "Helpful" class NPCPersonality(BaseModel): """NPC personality traits""" traits: List[str] = Field(default_factory=list, description="2-3 personality traits") mannerisms: List[str] = Field(default_factory=list, description="Physical mannerisms") voice_description: str = Field(default="", description="How they sound") motivations: List[str] = Field(default_factory=list, description="What drives them") fears: List[str] = Field(default_factory=list, description="What they fear") secrets: List[str] = Field(default_factory=list, description="Hidden secrets") class NPCRelationship(BaseModel): """Relationship between NPC and character/party""" character_id: str = Field(description="Character or 'party' for group") relationship_type: str = Field(description="Type of relationship") affinity: int = Field(ge=-100, le=100, default=0, description="Relationship strength") history: str = Field(default="", description="Shared history") notes: str = Field(default="", description="Relationship notes") class NPC(BaseModel): """Non-player character""" # Core identity id: Optional[str] = Field(default=None, description="NPC ID") name: str = Field(min_length=1, max_length=100, description="NPC name") title: Optional[str] = Field(default=None, description="Title/honorific") # Basic info race: str = Field(description="NPC race/species") age: Optional[str] = Field(default=None, description="Age or age category") gender: Optional[str] = Field(default=None, description="Gender") occupation: str = Field(description="Occupation/profession") # Role in campaign role: NPCRole = Field(description="Story role") disposition: NPCDisposition = Field(default=NPCDisposition.NEUTRAL) importance: int = Field(ge=1, le=5, default=3, description="Story importance (1-5)") # Description appearance: str = Field(description="Physical description") personality: NPCPersonality = Field(default_factory=NPCPersonality) # Game stats (optional) challenge_rating: Optional[float] = Field(default=None, description="CR if combatant") armor_class: Optional[int] = Field(default=None) hit_points: Optional[int] = Field(default=None) # Story elements backstory: str = Field(default="", description="NPC backstory") current_situation: str = Field(default="", description="Current circumstances") goals: List[str] = Field(default_factory=list, description="NPC goals") connections: List[str] = Field(default_factory=list, description="Connected NPCs/factions") # Relationships relationships: List[NPCRelationship] = Field(default_factory=list) # Location & availability location: Optional[str] = Field(default=None, description="Current location") availability: str = Field(default="Available", description="When/where to find them") # Dialogue greeting: Optional[str] = Field(default=None, description="Standard greeting") catchphrase: Optional[str] = Field(default=None, description="Memorable phrase") dialogue_samples: List[str] = Field(default_factory=list, description="Sample dialogue") # Items & abilities notable_items: List[str] = Field(default_factory=list, description="Important items") special_abilities: List[str] = Field(default_factory=list, description="Special abilities") # Metadata campaign_id: Optional[str] = Field(default=None, description="Associated campaign") first_appearance: Optional[int] = Field(default=None, description="Session first appeared") last_appearance: Optional[int] = Field(default=None, description="Session last appeared") is_alive: bool = Field(default=True, description="Living status") created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) # GM notes gm_notes: str = Field(default="", description="Private GM notes") plot_hooks: List[str] = Field(default_factory=list, description="Plot hooks involving this NPC") def add_relationship(self, character_id: str, relationship_type: str, affinity: int = 0): """Add or update relationship""" for rel in self.relationships: if rel.character_id == character_id: rel.relationship_type = relationship_type rel.affinity = affinity self.updated_at = datetime.now() return # Create new relationship new_rel = NPCRelationship( character_id=character_id, relationship_type=relationship_type, affinity=affinity ) self.relationships.append(new_rel) self.updated_at = datetime.now() def change_disposition(self, new_disposition: NPCDisposition): """Change NPC disposition""" self.disposition = new_disposition self.updated_at = datetime.now() def update_location(self, location: str): """Update NPC location""" self.location = location self.updated_at = datetime.now() def record_appearance(self, session_number: int): """Record NPC appearance in session""" if self.first_appearance is None: self.first_appearance = session_number self.last_appearance = session_number self.updated_at = datetime.now() def get_relationship_with(self, character_id: str) -> Optional[NPCRelationship]: """Get relationship with specific character""" for rel in self.relationships: if rel.character_id == character_id: return rel return None def to_roleplay_prompt(self) -> str: """Generate prompt for AI to roleplay this NPC""" return f"""You are roleplaying as {self.name}, a {self.race} {self.occupation}. APPEARANCE: {self.appearance} PERSONALITY: - Traits: {', '.join(self.personality.traits)} - Mannerisms: {', '.join(self.personality.mannerisms)} - Voice: {self.personality.voice_description} BACKGROUND: {self.backstory} CURRENT SITUATION: {self.current_situation} MOTIVATIONS: {', '.join(self.personality.motivations)} DISPOSITION: {self.disposition.value} TYPICAL GREETING: {self.greeting or 'Generic greeting'} Roleplay this character authentically, staying in character and reflecting their personality, motivations, and current circumstances. """ def to_markdown(self) -> str: """Generate markdown NPC sheet""" return f"""# {self.name} {f'*{self.title}*' if self.title else ''} **{self.race} {self.occupation}** | **{self.role.value}** **Disposition:** {self.disposition.value} | **Importance:** {'⭐' * self.importance} ## Appearance {self.appearance} ## Personality **Traits:** {', '.join(self.personality.traits)} **Mannerisms:** {', '.join(self.personality.mannerisms)} **Voice:** {self.personality.voice_description} **Motivations:** {', '.join(self.personality.motivations)} **Fears:** {', '.join(self.personality.fears)} ## Background {self.backstory} ## Current Situation {self.current_situation} ## Goals {chr(10).join(f"- {goal}" for goal in self.goals)} ## Location & Availability **Location:** {self.location or 'Unknown'} **Availability:** {self.availability} ## Connections {', '.join(self.connections)} ## Dialogue **Greeting:** "{self.greeting or 'Hello there.'}" **Catchphrase:** "{self.catchphrase or 'N/A'}" ## Combat Stats {f"**CR:** {self.challenge_rating} | **AC:** {self.armor_class} | **HP:** {self.hit_points}" if self.challenge_rating else "*Not a combatant*"} ## Plot Hooks {chr(10).join(f"- {hook}" for hook in self.plot_hooks)} ## GM Notes {self.gm_notes} """ class Config: json_schema_extra = { "example": { "name": "Elara Moonwhisper", "race": "Elf", "occupation": "Sage", "role": "Quest Giver", "disposition": "Friendly", "appearance": "Ancient elf with silver hair and piercing blue eyes", "personality": { "traits": ["Wise", "Mysterious", "Patient"], "voice_description": "Soft and melodic" } } }