Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c5cc599
docs: add narrative layer design spec and implementation plan
anadoris007 Apr 20, 2026
2d782f0
feat(narrative): add action-to-verb mapping for OASIS actions
anadoris007 Apr 20, 2026
05aa9f1
feat(narrative): add StoryStore for file-based narrative persistence
anadoris007 Apr 20, 2026
6b9e6ee
feat(narrative): add CharacterEngine with emotional state tracking
anadoris007 Apr 20, 2026
052fd87
feat(narrative): add actions.jsonl round reader
anadoris007 Apr 20, 2026
9db7b39
feat(narrative): add LLM-driven prose generation
anadoris007 Apr 20, 2026
caec8b5
feat(narrative): orchestrate round translation with state updates
anadoris007 Apr 20, 2026
727f440
feat(narrative): add narrative API blueprint
anadoris007 Apr 20, 2026
d4557fd
feat(narrative): add frontend API client
anadoris007 Apr 20, 2026
0c9b0c0
feat(narrative): add StoryTimelineView with StoryBeat and CharacterCard
anadoris007 Apr 20, 2026
d651cb8
test(narrative): add end-to-end pipeline test
anadoris007 Apr 20, 2026
7d292e2
docs: add God Mode + World State v1 design spec
anadoris007 Apr 20, 2026
1762c79
docs: revise God Mode + World State spec per review feedback
anadoris007 Apr 20, 2026
3cf1e28
docs: add God Mode + World State v1 implementation plan
anadoris007 Apr 20, 2026
1a73b7f
feat(narrative): add WorldStateStore for rules, locations, event log
anadoris007 Apr 20, 2026
16b7ef7
feat(narrative): set status=alive on new characters
anadoris007 Apr 20, 2026
f91dd00
feat(narrative): add god_mode.inject_event handler
anadoris007 Apr 20, 2026
f25f688
feat(narrative): add god_mode.modify_emotion with audit logging
anadoris007 Apr 20, 2026
a569976
feat(narrative): add god_mode.kill_character with auto-death event
anadoris007 Apr 20, 2026
d3bec34
feat(narrative): extend translator with world context + dead filter
anadoris007 Apr 22, 2026
c223d77
test(narrative): add e2e tests for event injection and kill
anadoris007 Apr 22, 2026
aabe9b4
test(narrative): fix dead-filter e2e test — need living actor in round 2
anadoris007 Apr 22, 2026
2eb98a0
feat(narrative): add world + god mode API endpoints
anadoris007 Apr 22, 2026
e7eef9f
feat(narrative): add frontend API client for world + god mode
anadoris007 Apr 22, 2026
4846897
feat(narrative): add GodModeView, WorldBuilderView, cinematic locations
anadoris007 Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,11 @@ def log_response(response):
return response

# 注册蓝图
from .api import graph_bp, simulation_bp, report_bp
from .api import graph_bp, simulation_bp, report_bp, narrative_bp
app.register_blueprint(graph_bp, url_prefix='/api/graph')
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
app.register_blueprint(report_bp, url_prefix='/api/report')
app.register_blueprint(narrative_bp, url_prefix='/api/narrative')

# 健康检查
@app.route('/health')
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
graph_bp = Blueprint('graph', __name__)
simulation_bp = Blueprint('simulation', __name__)
report_bp = Blueprint('report', __name__)
narrative_bp = Blueprint('narrative', __name__)

from . import graph # noqa: E402, F401
from . import simulation # noqa: E402, F401
from . import report # noqa: E402, F401
from . import narrative # noqa: E402, F401

180 changes: 180 additions & 0 deletions backend/app/api/narrative.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Narrative Layer API endpoints.

Routes are attached to the narrative_bp blueprint defined in api/__init__.py.
All endpoints are prefixed with /api/narrative (see app/__init__.py).
"""
import os
import json
from flask import jsonify, request

from . import narrative_bp
from ..config import Config
from ..services.narrative.story_store import StoryStore
from ..services.narrative.narrative_translator import translate_round
from ..services.narrative.character_engine import CharacterEngine
from ..services.narrative.world_state import WorldStateStore
from ..services.narrative.god_mode import (
inject_event as gm_inject_event,
modify_emotion as gm_modify_emotion,
kill_character as gm_kill_character,
)


def _sim_dir(sim_id: str) -> str:
"""Resolve a simulation ID to its on-disk directory."""
return os.path.join(Config.OASIS_SIMULATION_DATA_DIR, sim_id)


@narrative_bp.route('/story/<sim_id>', methods=['GET'])
def get_full_story(sim_id):
"""Return every story beat generated so far for this simulation."""
store = StoryStore(_sim_dir(sim_id))
return jsonify({"sim_id": sim_id, "beats": store.get_all_beats()})


@narrative_bp.route('/story/<sim_id>/round/<int:round_num>', methods=['GET'])
def get_round_story(sim_id, round_num):
"""Return a single round's story beat, or 404 if not yet translated."""
store = StoryStore(_sim_dir(sim_id))
beat = store.get_beat_by_round(round_num)
if not beat:
return jsonify({"error": "Round not translated yet"}), 404
return jsonify(beat)


@narrative_bp.route('/translate', methods=['POST'])
def translate():
"""Translate a specific round on demand.

Body: {"sim_id": str, "round": int, "platform": str = "twitter", "tone": str = "neutral"}
"""
data = request.get_json() or {}
sim_id = data.get('sim_id')
round_num = data.get('round')
platform = data.get('platform', 'twitter')
tone = data.get('tone', 'neutral')

if not sim_id or round_num is None:
return jsonify({"error": "sim_id and round are required"}), 400

try:
beat = translate_round(_sim_dir(sim_id), platform, int(round_num), tone)
return jsonify(beat)
except Exception as e:
return jsonify({"error": str(e)}), 500


@narrative_bp.route('/characters/<sim_id>', methods=['GET'])
def get_characters(sim_id):
"""Return the extended character roster (with emotional state)."""
store = StoryStore(_sim_dir(sim_id))
return jsonify({"characters": store.load_characters()})


@narrative_bp.route('/characters/<sim_id>/init', methods=['POST'])
def initialize_characters(sim_id):
"""Bootstrap narrative character profiles from the simulation's OASIS profiles."""
sim_dir = _sim_dir(sim_id)
profiles_path = os.path.join(sim_dir, 'profiles.json')
if not os.path.exists(profiles_path):
return jsonify({"error": "profiles.json not found for this simulation"}), 404

with open(profiles_path, 'r', encoding='utf-8') as f:
profiles = json.load(f)

store = StoryStore(sim_dir)
engine = CharacterEngine(store)
characters = engine.initialize_from_profiles(profiles)
return jsonify({"count": len(characters), "characters": characters})


# ---------------------------------------------------------------------------
# World State endpoints
# ---------------------------------------------------------------------------

@narrative_bp.route('/world/<sim_id>', methods=['GET'])
def get_world(sim_id):
"""Return the full world_state.json for this simulation."""
store = WorldStateStore(_sim_dir(sim_id))
return jsonify(store.load())


@narrative_bp.route('/world/<sim_id>/rules', methods=['POST'])
def set_rules(sim_id):
"""Replace the full rules list. Body: {"rules": [str, ...]}."""
data = request.get_json() or {}
rules = data.get('rules')
if not isinstance(rules, list):
return jsonify({"error": "rules must be a list of strings"}), 400
store = WorldStateStore(_sim_dir(sim_id))
store.set_rules([str(r) for r in rules])
return jsonify(store.load())


@narrative_bp.route('/world/<sim_id>/locations', methods=['POST'])
def upsert_location(sim_id):
"""Insert or update a location. Body: {"id", "name", "description"}."""
data = request.get_json() or {}
if not data.get('id') or not data.get('name'):
return jsonify({"error": "id and name are required"}), 400
store = WorldStateStore(_sim_dir(sim_id))
loc = store.upsert_location({
"id": str(data['id']),
"name": str(data['name']),
"description": str(data.get('description', '')),
})
return jsonify(loc)


# ---------------------------------------------------------------------------
# God Mode endpoints
# ---------------------------------------------------------------------------

@narrative_bp.route('/godmode/<sim_id>/inject-event', methods=['POST'])
def godmode_inject_event(sim_id):
"""Inject a world event. Body: {"description", "round"? (optional, >=0)}."""
data = request.get_json() or {}
description = data.get('description')
if not description:
return jsonify({"error": "description is required"}), 400

round_num = data.get('round')
if round_num is not None:
try:
round_num = int(round_num)
if round_num < 0:
raise ValueError()
except (TypeError, ValueError):
return jsonify({"error": "round must be a non-negative integer"}), 400

evt = gm_inject_event(_sim_dir(sim_id), description=str(description), round_num=round_num)
return jsonify(evt)


@narrative_bp.route('/godmode/<sim_id>/modify-emotion', methods=['POST'])
def godmode_modify_emotion(sim_id):
"""Overwrite emotion values. Body: {"character_id", "emotions": {name: float}}."""
data = request.get_json() or {}
char_id = data.get('character_id')
emotions = data.get('emotions')
if not char_id or not isinstance(emotions, dict):
return jsonify({"error": "character_id and emotions are required"}), 400
try:
char = gm_modify_emotion(_sim_dir(sim_id), str(char_id), emotions)
except ValueError as e:
return jsonify({"error": str(e)}), 404
return jsonify(char)


@narrative_bp.route('/godmode/<sim_id>/kill', methods=['POST'])
def godmode_kill(sim_id):
"""Mark a character as dead. Body: {"character_id"}."""
data = request.get_json() or {}
char_id = data.get('character_id')
if not char_id:
return jsonify({"error": "character_id is required"}), 400
try:
char = gm_kill_character(_sim_dir(sim_id), str(char_id))
except ValueError as e:
return jsonify({"error": str(e)}), 404
return jsonify(char)
1 change: 1 addition & 0 deletions backend/app/services/narrative/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Narrative Layer — translates OASIS simulation output into story prose."""
47 changes: 47 additions & 0 deletions backend/app/services/narrative/action_mapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Maps OASIS action types to narrative verbs and interpretations.

Used by the Narrative Translator to convert raw simulation actions
(`CREATE_POST`, `LIKE_POST`, etc.) into human-readable story language.
"""

ACTION_TO_VERB = {
"CREATE_POST": "speaks",
"LIKE_POST": "agrees with",
"REPOST": "spreads word of",
"QUOTE_POST": "responds to",
"FOLLOW": "shows loyalty to",
"DO_NOTHING": "observes in silence",
"CREATE_COMMENT": "engages with",
"DISLIKE_POST": "disapproves of",
"LIKE_COMMENT": "validates",
"DISLIKE_COMMENT": "dismisses",
"SEARCH_POSTS": "investigates",
"SEARCH_USER": "seeks out",
"MUTE": "ignores",
}

ACTION_TO_NARRATIVE = {
"CREATE_POST": "Character speaks, declares, or announces",
"LIKE_POST": "Character agrees, supports, or nods",
"REPOST": "Character spreads rumor, amplifies, or gossips",
"QUOTE_POST": "Character responds, debates, or challenges",
"FOLLOW": "Character allies with or shows loyalty to",
"DO_NOTHING": "Character reflects, observes, or waits",
"CREATE_COMMENT": "Character engages in dialogue",
"DISLIKE_POST": "Character opposes, confronts, or disapproves",
"LIKE_COMMENT": "Character validates a response",
"DISLIKE_COMMENT": "Character dismisses or mocks",
"SEARCH_POSTS": "Character investigates or seeks information",
"SEARCH_USER": "Character seeks out a specific person",
"MUTE": "Character avoids, ignores, or shuns",
}


def map_action_to_verb(action_type: str) -> str:
"""Return a narrative verb phrase for an OASIS action type."""
return ACTION_TO_VERB.get(action_type, "does something")


def get_narrative_context(action_type: str) -> str:
"""Return a longer narrative interpretation for an OASIS action type."""
return ACTION_TO_NARRATIVE.get(action_type, "Character takes an unknown action")
129 changes: 129 additions & 0 deletions backend/app/services/narrative/character_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""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
Loading