first commit
This commit is contained in:
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})"
|
||||
)
|
||||
Reference in New Issue
Block a user