48 KiB
Narrative Layer Foundation — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the foundational narrative layer — translate OASIS simulation actions into story prose, track extended character state, and display the generated story in a new Vue view.
Architecture: New backend/app/services/narrative/ module with three services (translator, character engine, action mapper). New Flask blueprint at /api/narrative/*. New Vue view StoryTimelineView.vue polls for narrative updates. All state is file-based under uploads/simulations/{sim_id}/narrative/.
Tech Stack: Python 3.11, Flask, Vue 3 + Vite, existing LLM client (backend/app/utils/llm_client.py), existing retry utility (backend/app/utils/retry.py).
Scope boundary: This plan covers ONLY translator + character engine + timeline view. God Mode, timeline branching, world state, enhanced input, and export are separate follow-on plans.
File Structure
New files
| File | Responsibility |
|---|---|
backend/app/services/narrative/__init__.py |
Package init |
backend/app/services/narrative/action_mapper.py |
Maps OASIS action types to narrative verbs/interpretations |
backend/app/services/narrative/character_engine.py |
Extended character profiles, emotional state, arc detection |
backend/app/services/narrative/narrative_translator.py |
Reads actions.jsonl, generates prose via LLM |
backend/app/services/narrative/story_store.py |
File I/O for narrative state (story_beats.json, characters.json, translator_state.json) |
backend/app/api/narrative.py |
Flask blueprint with narrative endpoints |
backend/tests/test_action_mapper.py |
Tests for action mapping |
backend/tests/test_character_engine.py |
Tests for character state updates |
backend/tests/test_narrative_translator.py |
Tests for translation pipeline |
backend/tests/test_story_store.py |
Tests for file I/O |
frontend/src/api/narrative.js |
Frontend API client for narrative endpoints |
frontend/src/views/StoryTimelineView.vue |
Story reading UI |
frontend/src/components/StoryBeat.vue |
Single story paragraph component |
frontend/src/components/CharacterCard.vue |
Character summary with emotion indicators |
Modified files
| File | Change |
|---|---|
backend/app/api/__init__.py |
Add narrative_bp blueprint registration |
backend/app/__init__.py |
Register narrative blueprint |
frontend/src/router/index.js |
Add /story/:simId route |
Task 1: Action Mapper — Static Mapping Table
Files:
-
Create:
backend/app/services/narrative/__init__.py -
Create:
backend/app/services/narrative/action_mapper.py -
Test:
backend/tests/test_action_mapper.py -
Step 1: Create empty package init
Create backend/app/services/narrative/__init__.py with a single empty line.
- Step 2: Write the failing test
Create backend/tests/test_action_mapper.py:
from app.services.narrative.action_mapper import map_action_to_verb, get_narrative_context
def test_create_post_maps_to_speech():
result = map_action_to_verb("CREATE_POST")
assert result == "speaks"
def test_like_post_maps_to_agreement():
result = map_action_to_verb("LIKE_POST")
assert result == "agrees with"
def test_unknown_action_returns_fallback():
result = map_action_to_verb("UNKNOWN_ACTION")
assert result == "does something"
def test_get_narrative_context_returns_interpretation():
ctx = get_narrative_context("REPOST")
assert "rumor" in ctx.lower() or "amplifies" in ctx.lower()
- Step 3: Run test to verify it fails
Run: cd backend && uv run pytest tests/test_action_mapper.py -v
Expected: FAIL with ImportError (module not yet created).
- Step 4: Implement action_mapper.py
Create backend/app/services/narrative/action_mapper.py:
"""Maps OASIS action types to narrative verbs and interpretations."""
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 ACTION_TO_VERB.get(action_type, "does something")
def get_narrative_context(action_type: str) -> str:
return ACTION_TO_NARRATIVE.get(action_type, "Character takes an unknown action")
- Step 5: Run tests to verify they pass
Run: cd backend && uv run pytest tests/test_action_mapper.py -v
Expected: 4 passed.
- Step 6: Commit
git add backend/app/services/narrative/__init__.py backend/app/services/narrative/action_mapper.py backend/tests/test_action_mapper.py
git commit -m "feat(narrative): add action-to-verb mapping for OASIS actions"
Task 2: Story Store — File I/O for Narrative State
Files:
-
Create:
backend/app/services/narrative/story_store.py -
Test:
backend/tests/test_story_store.py -
Step 1: Write the failing test
Create backend/tests/test_story_store.py:
import os
import tempfile
import pytest
from app.services.narrative.story_store import StoryStore
@pytest.fixture
def temp_sim_dir():
with tempfile.TemporaryDirectory() as d:
sim_dir = os.path.join(d, "sim_test123")
os.makedirs(sim_dir)
yield sim_dir
def test_save_and_load_story_beats(temp_sim_dir):
store = StoryStore(temp_sim_dir)
beat = {"round": 1, "prose": "Elena spoke.", "characters": ["elena"]}
store.append_beat(beat)
beats = store.get_all_beats()
assert len(beats) == 1
assert beats[0]["prose"] == "Elena spoke."
def test_translator_state_tracks_offset(temp_sim_dir):
store = StoryStore(temp_sim_dir)
assert store.get_file_offset("twitter") == 0
store.set_file_offset("twitter", 1024)
assert store.get_file_offset("twitter") == 1024
def test_get_beat_by_round(temp_sim_dir):
store = StoryStore(temp_sim_dir)
store.append_beat({"round": 1, "prose": "First"})
store.append_beat({"round": 2, "prose": "Second"})
beat = store.get_beat_by_round(2)
assert beat["prose"] == "Second"
def test_narrative_dir_created_on_first_write(temp_sim_dir):
store = StoryStore(temp_sim_dir)
store.append_beat({"round": 1, "prose": "test"})
narrative_dir = os.path.join(temp_sim_dir, "narrative")
assert os.path.isdir(narrative_dir)
- Step 2: Run test to verify it fails
Run: cd backend && uv run pytest tests/test_story_store.py -v
Expected: FAIL with ImportError.
- Step 3: Implement story_store.py
Create backend/app/services/narrative/story_store.py:
"""File-based persistence for narrative state."""
import os
import json
from typing import Optional
class StoryStore:
"""Manages narrative/*.json files for a single simulation."""
def __init__(self, sim_dir: str):
self.sim_dir = sim_dir
self.narrative_dir = os.path.join(sim_dir, "narrative")
self.beats_path = os.path.join(self.narrative_dir, "story_beats.json")
self.translator_state_path = os.path.join(self.narrative_dir, "translator_state.json")
self.characters_path = os.path.join(self.narrative_dir, "characters.json")
def _ensure_dir(self):
os.makedirs(self.narrative_dir, exist_ok=True)
def append_beat(self, beat: dict) -> None:
self._ensure_dir()
beats = self.get_all_beats()
beats.append(beat)
with open(self.beats_path, "w", encoding="utf-8") as f:
json.dump(beats, f, ensure_ascii=False, indent=2)
def get_all_beats(self) -> list:
if not os.path.exists(self.beats_path):
return []
with open(self.beats_path, "r", encoding="utf-8") as f:
return json.load(f)
def get_beat_by_round(self, round_num: int) -> Optional[dict]:
for beat in self.get_all_beats():
if beat.get("round") == round_num:
return beat
return None
def get_file_offset(self, platform: str) -> int:
if not os.path.exists(self.translator_state_path):
return 0
with open(self.translator_state_path, "r", encoding="utf-8") as f:
state = json.load(f)
return state.get(f"{platform}_offset", 0)
def set_file_offset(self, platform: str, offset: int) -> None:
self._ensure_dir()
state = {}
if os.path.exists(self.translator_state_path):
with open(self.translator_state_path, "r", encoding="utf-8") as f:
state = json.load(f)
state[f"{platform}_offset"] = offset
with open(self.translator_state_path, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
def save_characters(self, characters: list) -> None:
self._ensure_dir()
with open(self.characters_path, "w", encoding="utf-8") as f:
json.dump(characters, f, ensure_ascii=False, indent=2)
def load_characters(self) -> list:
if not os.path.exists(self.characters_path):
return []
with open(self.characters_path, "r", encoding="utf-8") as f:
return json.load(f)
- Step 4: Run tests to verify they pass
Run: cd backend && uv run pytest tests/test_story_store.py -v
Expected: 4 passed.
- Step 5: Commit
git add backend/app/services/narrative/story_store.py backend/tests/test_story_store.py
git commit -m "feat(narrative): add StoryStore for file-based narrative persistence"
Task 3: Character Engine — Initial Emotional State
Files:
- Create:
backend/app/services/narrative/character_engine.py - Test:
backend/tests/test_character_engine.py
Learning mode note: Step 4 of this task asks you (the user) to write the emotional delta rules. Those rules encode your creative judgment about how characters react emotionally — it's the kind of decision that shapes storytelling quality and is better made by a human than an LLM.
- Step 1: Write the failing test
Create backend/tests/test_character_engine.py:
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
assert char["emotional_state"]["current"]["trust"] == 0.5 # neutral baseline
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")
# Apply a large positive anger delta
apply_action_emotional_delta(char, "DISLIKE_POST")
apply_action_emotional_delta(char, "DISLIKE_POST")
apply_action_emotional_delta(char, "DISLIKE_POST")
apply_action_emotional_delta(char, "DISLIKE_POST")
apply_action_emotional_delta(char, "DISLIKE_POST")
assert 0.0 <= char["emotional_state"]["current"]["anger"] <= 1.0
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
- Step 2: Run test to verify it fails
Run: cd backend && uv run pytest tests/test_character_engine.py -v
Expected: FAIL with ImportError.
- Step 3: Implement scaffolding with placeholder deltas
Create backend/app/services/narrative/character_engine.py:
"""Extended character profiles, emotional state, arc detection."""
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,
"surprise": 0.0,
}
def create_initial_character(char_id: str, name: str, backstory: str = "",
motivations: list = None, personality: list = 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 — see Step 4 <<<
ACTION_EMOTIONAL_DELTAS: Dict[str, Dict[str, float]] = {
# TODO: Fill this in — see Task 3, Step 4
}
def apply_action_emotional_delta(character: dict, action_type: str) -> None:
"""Apply an emotional state change based on an action the character took."""
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 character roster for a simulation."""
def __init__(self, store):
self.store = store
def initialize_from_profiles(self, oasis_profiles: list) -> list:
"""Bootstrap character roster from OASIS profiles."""
characters = []
for profile in oasis_profiles:
char = create_initial_character(
char_id=str(profile.get("user_id", profile.get("id", ""))),
name=profile.get("name", "Unknown"),
)
characters.append(char)
self.store.save_characters(characters)
return characters
- Step 4: 🎯 USER CONTRIBUTION — Define Emotional Deltas
This is the key creative decision. The ACTION_EMOTIONAL_DELTAS dictionary maps each OASIS action to changes in the character's emotional state. Your choices here directly shape how characters feel and evolve over a story.
Open backend/app/services/narrative/character_engine.py and fill in the ACTION_EMOTIONAL_DELTAS dictionary. A delta is a dict of {emotion: float} where the float is added to the current emotion value (clamped to 0.0–1.0).
Guidance — things to consider:
CREATE_POST(speaking out) — small confidence bump? small anxiety bump?LIKE_POST(agreeing) — builds trust? slight joy?DISLIKE_POST(confronting) — anger up, trust down?FOLLOW(allying) — trust up, maybe joy?REPOST(spreading rumor) — neutral? or surprise?DO_NOTHING(observing) — sadness creep? fear creep?
Suggested range: keep deltas between -0.15 and +0.15 per action (so they accumulate gradually). Example format:
ACTION_EMOTIONAL_DELTAS: Dict[str, Dict[str, float]] = {
"CREATE_POST": {"joy": 0.05},
"DISLIKE_POST": {"anger": 0.10, "trust": -0.05},
# ... fill in the rest for all 13 actions in action_mapper.py
}
Why your judgment matters: These values are a model of human emotional reaction. An LLM would pick generic values; your intuitions as a storyteller will produce richer, more deliberate character arcs. If you want characters who spiral into darkness easily, use bigger negative deltas. If you want resilient characters, use smaller ones.
Fill in deltas for at least these 7 core actions: CREATE_POST, LIKE_POST, DISLIKE_POST, REPOST, QUOTE_POST, FOLLOW, DO_NOTHING. The other 6 can use defaults (empty dicts).
- Step 5: Run tests to verify they pass
Run: cd backend && uv run pytest tests/test_character_engine.py -v
Expected: 5 passed. If test_dislike_post_increases_anger or test_create_post_increases_confidence_proxy fails, your deltas may be missing the relevant emotion — adjust.
- Step 6: Commit
git add backend/app/services/narrative/character_engine.py backend/tests/test_character_engine.py
git commit -m "feat(narrative): add CharacterEngine with emotional state tracking"
Task 4: Narrative Translator — Read Actions.jsonl
Files:
-
Create:
backend/app/services/narrative/narrative_translator.py -
Test:
backend/tests/test_narrative_translator.py -
Step 1: Write the failing test
Create backend/tests/test_narrative_translator.py:
import os
import json
import tempfile
import pytest
from app.services.narrative.narrative_translator import read_actions_for_round
@pytest.fixture
def actions_file():
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "actions.jsonl")
lines = [
{"round": 1, "agent_id": 1, "agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {"content": "Hi"}, "success": True, "timestamp": "2026-03-26T12:00:00"},
{"round": 1, "agent_id": 2, "agent_name": "Bob", "action_type": "LIKE_POST", "action_args": {"post_id": 1}, "success": True, "timestamp": "2026-03-26T12:00:01"},
{"event_type": "round_end", "round": 1, "timestamp": "2026-03-26T12:00:02"},
{"round": 2, "agent_id": 1, "agent_name": "Alice", "action_type": "REPOST", "action_args": {}, "success": True, "timestamp": "2026-03-26T12:00:03"},
]
with open(path, "w") as f:
for line in lines:
f.write(json.dumps(line) + "\n")
yield path
def test_read_actions_for_round_1(actions_file):
actions, next_offset = read_actions_for_round(actions_file, start_offset=0, target_round=1)
assert len(actions) == 2
assert actions[0]["agent_name"] == "Alice"
assert actions[1]["agent_name"] == "Bob"
assert next_offset > 0
def test_read_actions_resumes_from_offset(actions_file):
# First read round 1
_, offset_after_round_1 = read_actions_for_round(actions_file, start_offset=0, target_round=1)
# Then read round 2 using that offset
actions, _ = read_actions_for_round(actions_file, start_offset=offset_after_round_1, target_round=2)
assert len(actions) == 1
assert actions[0]["action_type"] == "REPOST"
def test_read_actions_missing_file_returns_empty():
actions, offset = read_actions_for_round("/nonexistent/path.jsonl", start_offset=0, target_round=1)
assert actions == []
assert offset == 0
- Step 2: Run test to verify it fails
Run: cd backend && uv run pytest tests/test_narrative_translator.py -v
Expected: FAIL with ImportError.
- Step 3: Implement read_actions_for_round
Create backend/app/services/narrative/narrative_translator.py:
"""Reads OASIS actions.jsonl and translates them into story prose."""
import os
import json
from typing import List, Tuple
def read_actions_for_round(jsonl_path: str, start_offset: int, target_round: int) -> Tuple[List[dict], int]:
"""Read all actions for target_round starting at start_offset.
Returns (actions, new_offset). new_offset is the file position right after
the round_end event (or EOF if not found yet).
"""
if not os.path.exists(jsonl_path):
return [], start_offset
actions = []
new_offset = start_offset
with open(jsonl_path, "r", encoding="utf-8") as f:
f.seek(start_offset)
while True:
line_start = f.tell()
line = f.readline()
if not line:
break
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
# Round-end event marks boundary
if entry.get("event_type") == "round_end" and entry.get("round") == target_round:
new_offset = f.tell()
break
# Skip events, other rounds
if "event_type" in entry:
new_offset = f.tell()
continue
if entry.get("round") != target_round:
new_offset = f.tell()
continue
actions.append(entry)
new_offset = f.tell()
return actions, new_offset
- Step 4: Run tests to verify they pass
Run: cd backend && uv run pytest tests/test_narrative_translator.py -v
Expected: 3 passed.
- Step 5: Commit
git add backend/app/services/narrative/narrative_translator.py backend/tests/test_narrative_translator.py
git commit -m "feat(narrative): add actions.jsonl round reader"
Task 5: Narrative Translator — LLM Prose Generation
Files:
- Modify:
backend/app/services/narrative/narrative_translator.py - Modify:
backend/tests/test_narrative_translator.py
Learning mode note: Step 4 of this task asks you to shape the prose generation prompt. The prompt is the single biggest lever for story quality. This is where your voice as a storyteller gets encoded.
- Step 1: Add failing test for generate_prose
Append to backend/tests/test_narrative_translator.py:
from unittest.mock import patch, MagicMock
from app.services.narrative.narrative_translator import generate_prose
def test_generate_prose_calls_llm_with_context():
actions = [
{"agent_name": "Elena", "action_type": "CREATE_POST", "action_args": {"content": "We must act."}},
{"agent_name": "Marcus", "action_type": "DISLIKE_POST", "action_args": {}},
]
characters = [
{"id": "1", "name": "Elena", "emotional_state": {"current": {"anger": 0.2, "joy": 0.0, "fear": 0.1, "sadness": 0.0, "trust": 0.5, "surprise": 0.0}}},
{"id": "2", "name": "Marcus", "emotional_state": {"current": {"anger": 0.5, "joy": 0.0, "fear": 0.0, "sadness": 0.0, "trust": 0.3, "surprise": 0.0}}},
]
fake_response = "Elena's voice cut through the silence. Marcus scowled, unmoved."
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = fake_response
result = generate_prose(actions, characters, tone="dark political thriller", previous_beats=[])
assert result == fake_response
# Verify the prompt included character names and action info
call_args = mock_llm.call_args
prompt = str(call_args)
assert "Elena" in prompt
assert "Marcus" in prompt
def test_generate_prose_empty_actions_returns_placeholder():
result = generate_prose([], [], tone="any", previous_beats=[])
assert "quiet" in result.lower() or "pause" in result.lower()
- Step 2: Run tests to verify they fail
Run: cd backend && uv run pytest tests/test_narrative_translator.py::test_generate_prose_calls_llm_with_context -v
Expected: FAIL with ImportError for generate_prose or call_llm.
- Step 3: Add LLM client import and prose generator scaffold
Append to backend/app/services/narrative/narrative_translator.py:
from app.utils.llm_client import call_llm
from app.services.narrative.action_mapper import get_narrative_context
def _format_character_summary(character: dict) -> str:
emotions = character["emotional_state"]["current"]
top_emotions = sorted(emotions.items(), key=lambda kv: -kv[1])[:2]
emo_str = ", ".join(f"{e[0]}={e[1]:.1f}" for e in top_emotions)
return f"{character['name']} (feeling: {emo_str})"
def _format_action_line(action: dict) -> str:
name = action.get("agent_name", "Someone")
act = action.get("action_type", "UNKNOWN")
args = action.get("action_args", {})
content = args.get("content", "")
ctx = get_narrative_context(act)
if content:
return f"- {name}: {ctx}. Content: \"{content}\""
return f"- {name}: {ctx}"
# >>> USER CONTRIBUTION POINT — see Step 4 <<<
PROSE_PROMPT_TEMPLATE = """TODO: the user will define this in Step 4"""
def generate_prose(actions: list, characters: list, tone: str, previous_beats: list) -> str:
"""Generate a narrative passage from a round's actions."""
if not actions:
return "A quiet pause settles over the scene. No one acts; no one speaks."
char_summaries = "\n".join(_format_character_summary(c) for c in characters)
action_lines = "\n".join(_format_action_line(a) for a in actions)
prev_prose = "\n\n".join(b.get("prose", "") for b in previous_beats[-2:])
prompt = PROSE_PROMPT_TEMPLATE.format(
tone=tone,
characters=char_summaries,
actions=action_lines,
previous=prev_prose or "(this is the first scene)",
)
return call_llm(prompt)
- Step 4: 🎯 USER CONTRIBUTION — Design the Prose Prompt
This is the storytelling soul of the system. Replace the PROSE_PROMPT_TEMPLATE with your prompt. The template has 4 substitution fields: {tone}, {characters}, {actions}, {previous}.
Guidance — key choices:
- Tense and POV: Third-person past is most versatile. First-person or present can work but limit you.
- Paragraph count: 2-4 is a good range. Too short = feels like a log. Too long = drags.
- Dialogue: Should the prompt encourage dialogue when actions have
content? Probably yes. - "Show don't tell": Instructing the LLM to show emotions through action/dialogue rather than stating them is a classic writing lever.
- Tone consistency: How hard do you push the tone? A strong instruction like "Every line should feel grim" vs a soft one like "Maintain a grim tone."
- Continuity: Instruct it to continue naturally from
{previous}.
Suggested structure (copy and modify):
PROSE_PROMPT_TEMPLATE = """You are a narrative writer working in the tone of {tone}.
Previous story context:
{previous}
Characters in this scene:
{characters}
Events this round:
{actions}
Write a story passage of 2-4 paragraphs that:
- Uses third-person past tense
- Shows character emotions through action, dialogue, and internal thought (never state emotions directly)
- Weaves the events above into prose — never list them like a log
- Continues naturally from the previous context
- Maintains the tone of {tone} throughout
Write only the prose. No headings, no preamble."""
Feel free to modify heavily — try your own instructions, add more rules, or tighten it. The quality of every generated story depends on this prompt.
- Step 5: Run tests to verify they pass
Run: cd backend && uv run pytest tests/test_narrative_translator.py -v
Expected: 5 passed (3 from Task 4 + 2 new).
- Step 6: Commit
git add backend/app/services/narrative/narrative_translator.py backend/tests/test_narrative_translator.py
git commit -m "feat(narrative): add LLM-driven prose generation"
Task 6: Narrative Translator — Translate Round Orchestration
Files:
-
Modify:
backend/app/services/narrative/narrative_translator.py -
Modify:
backend/tests/test_narrative_translator.py -
Step 1: Add failing test
Append to backend/tests/test_narrative_translator.py:
from app.services.narrative.narrative_translator import translate_round
from app.services.narrative.story_store import StoryStore
def test_translate_round_produces_beat(tmp_path):
sim_dir = str(tmp_path / "sim_test")
os.makedirs(sim_dir)
platform_dir = os.path.join(sim_dir, "twitter")
os.makedirs(platform_dir)
actions_path = os.path.join(platform_dir, "actions.jsonl")
lines = [
{"round": 1, "agent_id": 1, "agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {"content": "Hi"}, "success": True, "timestamp": "t"},
{"event_type": "round_end", "round": 1, "timestamp": "t"},
]
with open(actions_path, "w") as f:
for l in lines:
f.write(json.dumps(l) + "\n")
store = StoryStore(sim_dir)
store.save_characters([
{"id": "1", "name": "Alice", "emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0, "sadness": 0, "trust": 0.5, "surprise": 0}}},
])
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "Alice spoke into the void."
beat = translate_round(sim_dir, platform="twitter", target_round=1, tone="neutral")
assert beat["round"] == 1
assert beat["prose"] == "Alice spoke into the void."
assert "Alice" in beat.get("characters", [])
stored_beats = store.get_all_beats()
assert len(stored_beats) == 1
- Step 2: Run test to verify it fails
Run: cd backend && uv run pytest tests/test_narrative_translator.py::test_translate_round_produces_beat -v
Expected: FAIL with ImportError for translate_round.
- Step 3: Implement translate_round
Append to backend/app/services/narrative/narrative_translator.py:
from app.services.narrative.story_store import StoryStore
from app.services.narrative.character_engine import apply_action_emotional_delta
def translate_round(sim_dir: str, platform: str, target_round: int, tone: str = "neutral") -> dict:
"""Translate a single round of simulation into a story beat."""
store = StoryStore(sim_dir)
actions_path = os.path.join(sim_dir, platform, "actions.jsonl")
start_offset = store.get_file_offset(platform)
actions, new_offset = read_actions_for_round(actions_path, start_offset, target_round)
characters = store.load_characters()
previous_beats = store.get_all_beats()
prose = generate_prose(actions, characters, tone, previous_beats)
involved = list({a.get("agent_name") for a in actions if a.get("agent_name")})
# Update emotional states
char_by_name = {c["name"]: c for c in characters}
for a in actions:
char = char_by_name.get(a.get("agent_name"))
if char:
apply_action_emotional_delta(char, a.get("action_type", ""))
store.save_characters(list(char_by_name.values()))
beat = {
"round": target_round,
"prose": prose,
"characters": involved,
"action_count": len(actions),
"platform": platform,
}
store.append_beat(beat)
store.set_file_offset(platform, new_offset)
return beat
- Step 4: Run tests to verify they pass
Run: cd backend && uv run pytest tests/test_narrative_translator.py -v
Expected: 6 passed.
- Step 5: Commit
git add backend/app/services/narrative/narrative_translator.py backend/tests/test_narrative_translator.py
git commit -m "feat(narrative): orchestrate round translation with state updates"
Task 7: Flask Blueprint — Narrative API
Files:
-
Create:
backend/app/api/narrative.py -
Modify:
backend/app/api/__init__.py -
Modify:
backend/app/__init__.py -
Step 1: Inspect existing API pattern
Read backend/app/api/__init__.py and backend/app/api/simulation.py to understand the existing blueprint registration pattern and response format.
- Step 2: Create narrative.py blueprint
Create backend/app/api/narrative.py:
"""Narrative Layer API endpoints."""
import os
from flask import Blueprint, jsonify, request
from app.services.narrative.story_store import StoryStore
from app.services.narrative.narrative_translator import translate_round
from app.services.narrative.character_engine import CharacterEngine
from app.config import Config
narrative_bp = Blueprint('narrative', __name__)
def _sim_dir(sim_id: str) -> str:
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):
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):
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():
data = request.get_json()
sim_id = data.get('sim_id')
round_num = data.get('round')
platform = data.get('platform', 'twitter')
tone = data.get('tone', 'neutral')
try:
beat = translate_round(_sim_dir(sim_id), platform, 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):
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 characters from existing OASIS profiles."""
import json
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"}), 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})
- Step 3: Register blueprint in init.py
Modify backend/app/api/__init__.py by adding (at the bottom of existing imports):
from . import narrative
Then modify backend/app/__init__.py to register:
from app.api.narrative import narrative_bp
app.register_blueprint(narrative_bp, url_prefix='/api/narrative')
(Find where other blueprints are registered and add this line alongside them.)
- Step 4: Manual smoke test
cd backend && uv run python run.py &
sleep 3
curl http://localhost:5001/api/narrative/story/nonexistent_sim
# Expected: {"sim_id": "nonexistent_sim", "beats": []}
kill %1
- Step 5: Commit
git add backend/app/api/narrative.py backend/app/api/__init__.py backend/app/__init__.py
git commit -m "feat(narrative): add narrative API blueprint"
Task 8: Frontend API Client
Files:
-
Create:
frontend/src/api/narrative.js -
Step 1: Inspect existing API client pattern
Read frontend/src/api/simulation.js and frontend/src/api/index.js to understand the existing axios usage.
- Step 2: Create narrative API client
Create frontend/src/api/narrative.js:
import api from './index.js'
export const narrativeAPI = {
getFullStory(simId) {
return api.get(`/narrative/story/${simId}`)
},
getRoundStory(simId, roundNum) {
return api.get(`/narrative/story/${simId}/round/${roundNum}`)
},
translate(simId, round, platform = 'twitter', tone = 'neutral') {
return api.post('/narrative/translate', { sim_id: simId, round, platform, tone })
},
getCharacters(simId) {
return api.get(`/narrative/characters/${simId}`)
},
initCharacters(simId) {
return api.post(`/narrative/characters/${simId}/init`)
},
}
- Step 3: Commit
git add frontend/src/api/narrative.js
git commit -m "feat(narrative): add frontend API client"
Task 9: StoryBeat Component
Files:
-
Create:
frontend/src/components/StoryBeat.vue -
Step 1: Create component
Create frontend/src/components/StoryBeat.vue:
<template>
<article class="story-beat">
<header class="beat-header">
<span class="round-badge">Round {{ beat.round }}</span>
<span v-if="beat.characters && beat.characters.length" class="characters">
{{ beat.characters.join(' · ') }}
</span>
</header>
<div class="prose">
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
</div>
</article>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
beat: { type: Object, required: true },
})
const paragraphs = computed(() =>
(props.beat.prose || '').split(/\n\n+/).filter(p => p.trim())
)
</script>
<style scoped>
.story-beat {
margin: 0 0 2.5rem;
padding: 1.5rem;
border-left: 3px solid #c9a45b;
background: #faf7f0;
}
.beat-header {
display: flex;
gap: 1rem;
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: #7d6b3f;
}
.round-badge {
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.characters {
font-style: italic;
}
.prose p {
line-height: 1.7;
margin: 0 0 1rem;
font-family: Georgia, serif;
color: #2a2416;
}
</style>
- Step 2: Commit
git add frontend/src/components/StoryBeat.vue
git commit -m "feat(narrative): add StoryBeat component"
Task 10: StoryTimelineView
Files:
-
Create:
frontend/src/views/StoryTimelineView.vue -
Step 1: Create view
Create frontend/src/views/StoryTimelineView.vue:
<template>
<div class="story-timeline">
<header class="page-header">
<h1>{{ title }}</h1>
<div class="controls">
<button @click="refresh" :disabled="loading">{{ loading ? 'Loading...' : 'Refresh' }}</button>
<button @click="translateNext" :disabled="translating">
{{ translating ? 'Generating...' : 'Translate Next Round' }}
</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="beats.length === 0 && !loading" class="empty">
No story yet. Run a simulation and translate rounds to see the narrative.
</div>
<StoryBeat v-for="beat in beats" :key="beat.round" :beat="beat" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { narrativeAPI } from '../api/narrative.js'
import StoryBeat from '../components/StoryBeat.vue'
const route = useRoute()
const simId = route.params.simId
const beats = ref([])
const loading = ref(false)
const translating = ref(false)
const error = ref('')
const title = ref(`Story: ${simId}`)
async function refresh() {
loading.value = true
error.value = ''
try {
const { data } = await narrativeAPI.getFullStory(simId)
beats.value = data.beats || []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function translateNext() {
translating.value = true
error.value = ''
try {
const nextRound = beats.value.length + 1
await narrativeAPI.translate(simId, nextRound)
await refresh()
} catch (e) {
error.value = e.response?.data?.error || e.message
} finally {
translating.value = false
}
}
onMounted(refresh)
</script>
<style scoped>
.story-timeline {
max-width: 740px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e5ddc4;
}
.page-header h1 {
margin: 0;
font-family: Georgia, serif;
color: #2a2416;
}
.controls {
display: flex;
gap: 0.5rem;
}
.controls button {
padding: 0.5rem 1rem;
background: #c9a45b;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.controls button:disabled {
opacity: 0.5;
cursor: wait;
}
.error {
background: #ffe5e5;
color: #8b0000;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.empty {
text-align: center;
color: #999;
padding: 3rem 1rem;
font-style: italic;
}
</style>
- Step 2: Add route
Read frontend/src/router/index.js. Then add to its routes array:
{
path: '/story/:simId',
name: 'story',
component: () => import('../views/StoryTimelineView.vue'),
},
- Step 3: Manual smoke test
cd frontend && npm run dev
# Open http://localhost:3000/story/any_sim_id
# Expected: "No story yet" message, Refresh/Translate buttons visible
- Step 4: Commit
git add frontend/src/views/StoryTimelineView.vue frontend/src/router/index.js
git commit -m "feat(narrative): add StoryTimelineView and route"
Task 11: CharacterCard Component + Integration
Files:
-
Create:
frontend/src/components/CharacterCard.vue -
Modify:
frontend/src/views/StoryTimelineView.vue -
Step 1: Create CharacterCard
Create frontend/src/components/CharacterCard.vue:
<template>
<div class="character-card">
<div class="name">{{ character.name }}</div>
<div class="emotions">
<span v-for="(val, emo) in topEmotions" :key="emo" class="emotion">
{{ emo }}: {{ val.toFixed(2) }}
</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
character: { type: Object, required: true },
})
const topEmotions = computed(() => {
const current = props.character.emotional_state?.current || {}
const sorted = Object.entries(current).sort((a, b) => b[1] - a[1]).slice(0, 3)
return Object.fromEntries(sorted)
})
</script>
<style scoped>
.character-card {
display: inline-block;
padding: 0.5rem 0.75rem;
margin: 0.25rem;
background: #2a2416;
color: #faf7f0;
border-radius: 4px;
font-size: 0.85rem;
}
.name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.emotions {
display: flex;
gap: 0.5rem;
font-family: monospace;
font-size: 0.75rem;
opacity: 0.85;
}
</style>
- Step 2: Integrate into StoryTimelineView
In frontend/src/views/StoryTimelineView.vue, add character loading and display. Add to imports:
import CharacterCard from '../components/CharacterCard.vue'
Add to script setup (after title):
const characters = ref([])
async function loadCharacters() {
try {
const { data } = await narrativeAPI.getCharacters(simId)
characters.value = data.characters || []
} catch (e) { /* ignore */ }
}
Change onMounted(refresh) to onMounted(() => { refresh(); loadCharacters() }).
Change translateNext to reload characters after: add await loadCharacters() before await refresh().
Add this block in template, right after <header class="page-header">:
<div v-if="characters.length" class="character-roster">
<CharacterCard v-for="c in characters" :key="c.id" :character="c" />
</div>
Add to styles:
.character-roster {
margin-bottom: 2rem;
padding: 1rem;
background: #f5efd9;
border-radius: 4px;
}
- Step 3: Commit
git add frontend/src/components/CharacterCard.vue frontend/src/views/StoryTimelineView.vue
git commit -m "feat(narrative): add CharacterCard and roster display"
Task 12: End-to-End Smoke Test
Files:
-
Create:
backend/tests/test_narrative_e2e.py -
Step 1: Write E2E test
Create backend/tests/test_narrative_e2e.py:
"""End-to-end test: create fake simulation dir → translate → verify story."""
import os
import json
import tempfile
from unittest.mock import patch
from app.services.narrative.story_store import StoryStore
from app.services.narrative.narrative_translator import translate_round
def test_full_pipeline_from_fake_simulation(tmp_path):
sim_dir = str(tmp_path / "sim_e2e")
os.makedirs(os.path.join(sim_dir, "twitter"))
actions_path = os.path.join(sim_dir, "twitter", "actions.jsonl")
actions = [
{"round": 1, "agent_id": 1, "agent_name": "Elena", "action_type": "CREATE_POST", "action_args": {"content": "The council must fall."}, "success": True, "timestamp": "t"},
{"round": 1, "agent_id": 2, "agent_name": "Marcus", "action_type": "DISLIKE_POST", "action_args": {"post_id": 1}, "success": True, "timestamp": "t"},
{"event_type": "round_end", "round": 1, "timestamp": "t"},
{"round": 2, "agent_id": 1, "agent_name": "Elena", "action_type": "REPOST", "action_args": {}, "success": True, "timestamp": "t"},
{"event_type": "round_end", "round": 2, "timestamp": "t"},
]
with open(actions_path, "w") as f:
for a in actions:
f.write(json.dumps(a) + "\n")
store = StoryStore(sim_dir)
store.save_characters([
{"id": "1", "name": "Elena", "emotional_state": {"current": {k: 0.0 for k in ["anger","fear","joy","sadness","trust","surprise"]}}},
{"id": "2", "name": "Marcus", "emotional_state": {"current": {k: 0.0 for k in ["anger","fear","joy","sadness","trust","surprise"]}}},
])
store.load_characters()[0]["emotional_state"]["current"]["trust"] = 0.5
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.side_effect = [
"Elena addressed the gathering. Marcus's face darkened.",
"Elena's message spread through the quarter like a spark.",
]
beat1 = translate_round(sim_dir, "twitter", 1, "dark fantasy")
beat2 = translate_round(sim_dir, "twitter", 2, "dark fantasy")
assert beat1["round"] == 1
assert beat2["round"] == 2
assert "Elena" in beat1["prose"]
all_beats = store.get_all_beats()
assert len(all_beats) == 2
# Characters should have evolved
chars = store.load_characters()
marcus = next(c for c in chars if c["name"] == "Marcus")
# If ACTION_EMOTIONAL_DELTAS has anger for DISLIKE_POST, anger should be > 0
# (this validates the user's Step 4 choices in Task 3)
assert marcus["emotional_state"]["current"]["anger"] >= 0.0 # at minimum, not negative
- Step 2: Run test
Run: cd backend && uv run pytest tests/test_narrative_e2e.py -v
Expected: 1 passed.
- Step 3: Run full test suite
Run: cd backend && uv run pytest tests/ -v
Expected: all tests pass (action_mapper: 4, character_engine: 5, story_store: 4, narrative_translator: 6, e2e: 1 = 20 total).
- Step 4: Commit
git add backend/tests/test_narrative_e2e.py
git commit -m "test(narrative): add end-to-end pipeline test"
Self-Review Checklist
Spec coverage:
- ✅ Section 3.0 Integration Point (async on-demand) → Task 6
translate_round+ Task 7 API - ✅ Section 3.1 Action Mapping → Task 1
action_mapper.py - ✅ Section 3.2 Translation Pipeline → Task 4 + 5 + 6
- ✅ Section 3.3 Resilience → partial (retry deferred, see below)
- ✅ Section 3.4 LLM Prompt Structure → Task 5 with user contribution point
- ✅ Section 4.1 Extended Character Schema → Task 3
- ✅ Section 4.2 Emotional State Model (v1 display-only) → Task 3 with user contribution point
- ⏸ Section 4.3 Arc Detection → deferred to follow-on plan (not needed for MVP)
- ⏸ Sections 5-9 (world state, god mode, branching, input, export) → separate plans
- ✅ New API routes → Task 7 (subset: story, translate, characters, init)
- ✅ Frontend routes → Task 10 (
/story/:simId) - ✅ StoryBeat.vue → Task 9
- ✅ CharacterCard.vue → Task 11
- ✅ StoryTimelineView → Task 10
Deferred to follow-on plans: God Mode, timeline branching, world state manager, arc detection, enhanced input pipeline, export studio, all other frontend views. These each warrant their own focused plan.
Deferred within this plan (acceptable gaps): Retry/resilience wrapper around call_llm — relies on whatever retry is already in utils/llm_client.py; can be added in a hardening task if the basic pipeline works.
Type consistency check: ✅ StoryStore, translate_round, generate_prose, apply_action_emotional_delta, create_initial_character all have consistent signatures across tasks.