MicroFish/backend/app/services/narrative/character_engine.py

130 lines
4.9 KiB
Python

"""Extended character profiles, emotional state, and arc detection.
This module maintains character state beyond what OASIS tracks — backstory,
motivations, emotional state (six-dimension vector), relationships, and arc
stage. State is updated each round based on actions the character takes.
"""
from typing import Dict
EMOTIONS = ["anger", "fear", "joy", "sadness", "trust", "surprise"]
INITIAL_EMOTIONAL_STATE = {
"anger": 0.0,
"fear": 0.0,
"joy": 0.0,
"sadness": 0.0,
"trust": 0.5, # neutral-positive baseline; others start at 0
"surprise": 0.0,
}
def create_initial_character(
char_id: str,
name: str,
backstory: str = "",
motivations: list | None = None,
personality: list | None = None,
) -> dict:
"""Build a new character profile with neutral emotional state."""
return {
"id": char_id,
"name": name,
"backstory": backstory,
"motivations": motivations or [],
"personality_traits": personality or [],
"status": "alive",
"emotional_state": {
"current": dict(INITIAL_EMOTIONAL_STATE),
"history": [],
},
"relationships": {},
"arc": {"archetype": None, "stage": "beginning", "key_moments": []},
}
# ============================================================================
# USER CONTRIBUTION POINT — Emotional deltas per action type
# ============================================================================
#
# Each entry maps an OASIS action type to a dict of emotional changes the
# acting character experiences when they perform that action. Values are
# ADDED to current emotions (clamped to [0.0, 1.0]).
#
# Guidance:
# - Keep individual deltas small (between -0.15 and +0.15) so they
# accumulate gradually over many rounds.
# - Consider BOTH positive and negative effects per action:
# e.g. DISLIKE_POST → anger up, trust down
# - Missing actions default to no emotional change (character still acts
# but doesn't feel anything different about it).
#
# Actions to consider filling in (from action_mapper.py):
# CREATE_POST, LIKE_POST, REPOST, QUOTE_POST, FOLLOW, DO_NOTHING,
# CREATE_COMMENT, DISLIKE_POST, LIKE_COMMENT, DISLIKE_COMMENT,
# SEARCH_POSTS, SEARCH_USER, MUTE
#
# ⬇ REPLACE THE CONTENTS BELOW WITH YOUR CHOICES ⬇
# ============================================================================
ACTION_EMOTIONAL_DELTAS: Dict[str, Dict[str, float]] = {
# Speaking out publicly — small confidence bump
"CREATE_POST": {"joy": 0.04},
# Agreeing with someone — builds trust and a little joy
"LIKE_POST": {"trust": 0.04, "joy": 0.02},
# Spreading word of something — mild surprise/stimulation
"REPOST": {"surprise": 0.02},
# Responding or debating — mild engagement charge
"QUOTE_POST": {"joy": 0.02, "surprise": 0.02},
# Allying with someone — strong trust + joy bump
"FOLLOW": {"trust": 0.08, "joy": 0.04},
# Observing in silence — passive sadness/fear creep over time
"DO_NOTHING": {"sadness": 0.02, "fear": 0.02},
# Dialogue engagement — small positive
"CREATE_COMMENT": {"joy": 0.03},
# Confronting/opposing — anger up, trust down (classic conflict)
"DISLIKE_POST": {"anger": 0.08, "trust": -0.04},
# Validating a response — mild trust building
"LIKE_COMMENT": {"trust": 0.03},
# Mocking/dismissing — mild anger
"DISLIKE_COMMENT": {"anger": 0.04},
# Investigating — curiosity as surprise
"SEARCH_POSTS": {"surprise": 0.03},
# Seeking out a specific person — slight fear (implies concern)
"SEARCH_USER": {"fear": 0.02, "surprise": 0.02},
# Shunning/ignoring — sadness + trust loss
"MUTE": {"sadness": 0.03, "trust": -0.03},
}
# ============================================================================
def apply_action_emotional_delta(character: dict, action_type: str) -> None:
"""Mutate character's emotional state based on an action they took.
Emotions are clamped to [0.0, 1.0]. Unknown action types are a no-op.
"""
deltas = ACTION_EMOTIONAL_DELTAS.get(action_type, {})
current = character["emotional_state"]["current"]
for emotion, delta in deltas.items():
if emotion in current:
current[emotion] = max(0.0, min(1.0, current[emotion] + delta))
class CharacterEngine:
"""Manages the character roster for a single simulation."""
def __init__(self, store):
self.store = store
def initialize_from_profiles(self, oasis_profiles: list) -> list:
"""Bootstrap character roster from existing OASIS profile data."""
characters = []
for profile in oasis_profiles:
char_id = str(profile.get("user_id", profile.get("id", "")))
char = create_initial_character(
char_id=char_id,
name=profile.get("name", "Unknown"),
)
characters.append(char)
self.store.save_characters(characters)
return characters