From 6b9e6eeeb2b0486bf9d58e9ea134affee928bc53 Mon Sep 17 00:00:00 2001 From: anadoris007 Date: Mon, 20 Apr 2026 21:28:30 +0530 Subject: [PATCH] feat(narrative): add CharacterEngine with emotional state tracking Character profiles now track a six-dimension emotional state (anger, fear, joy, sadness, trust, surprise) that evolves based on actions taken. Initial deltas for all 13 OASIS actions chosen as a balanced baseline (see module docstring for tuning guidance). Tests: 5/5 passing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../services/narrative/character_engine.py | 128 ++++++++++++++++++ backend/tests/test_character_engine.py | 42 ++++++ 2 files changed, 170 insertions(+) create mode 100644 backend/app/services/narrative/character_engine.py create mode 100644 backend/tests/test_character_engine.py diff --git a/backend/app/services/narrative/character_engine.py b/backend/app/services/narrative/character_engine.py new file mode 100644 index 00000000..67d3934f --- /dev/null +++ b/backend/app/services/narrative/character_engine.py @@ -0,0 +1,128 @@ +"""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 [], + "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 diff --git a/backend/tests/test_character_engine.py b/backend/tests/test_character_engine.py new file mode 100644 index 00000000..a2795ebc --- /dev/null +++ b/backend/tests/test_character_engine.py @@ -0,0 +1,42 @@ +from app.services.narrative.character_engine import ( + CharacterEngine, + create_initial_character, + apply_action_emotional_delta, +) + + +def test_create_initial_character_has_neutral_emotions(): + char = create_initial_character(char_id="elena", name="Elena Voss") + assert char["emotional_state"]["current"]["anger"] == 0.0 + assert char["emotional_state"]["current"]["joy"] == 0.0 + # Trust starts at a neutral-positive baseline, not zero + assert char["emotional_state"]["current"]["trust"] == 0.5 + + +def test_create_initial_character_stores_name_and_id(): + char = create_initial_character(char_id="elena", name="Elena Voss") + assert char["id"] == "elena" + assert char["name"] == "Elena Voss" + + +def test_apply_delta_clamps_to_zero_one_range(): + char = create_initial_character(char_id="x", name="X") + # Hammer a single action many times to exceed the clamp + for _ in range(20): + apply_action_emotional_delta(char, "DISLIKE_POST") + for emo, val in char["emotional_state"]["current"].items(): + assert 0.0 <= val <= 1.0, f"{emo}={val} outside [0,1]" + + +def test_create_post_increases_confidence_proxy(): + # Speaking out should bump joy slightly (a proxy for confidence) + char = create_initial_character(char_id="x", name="X") + baseline_joy = char["emotional_state"]["current"]["joy"] + apply_action_emotional_delta(char, "CREATE_POST") + assert char["emotional_state"]["current"]["joy"] >= baseline_joy + + +def test_dislike_post_increases_anger(): + char = create_initial_character(char_id="x", name="X") + apply_action_emotional_delta(char, "DISLIKE_POST") + assert char["emotional_state"]["current"]["anger"] > 0.0