This commit is contained in:
anadoris007 2026-05-28 17:40:45 -04:00 committed by GitHub
commit 8382f1ed7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 6550 additions and 5 deletions

View File

@ -63,10 +63,11 @@ def create_app(config_class=Config):
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')

View File

@ -7,8 +7,10 @@ from flask import Blueprint
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

View File

@ -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)

View File

@ -0,0 +1 @@
"""Narrative Layer — translates OASIS simulation output into story prose."""

View File

@ -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")

View File

@ -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

View File

@ -0,0 +1,86 @@
"""God Mode intervention handlers.
All handlers mutate on-disk JSON under narrative/ and return the result.
Each intervention is logged to world_state.event_log for auditability.
"""
from typing import Optional
from app.services.narrative.story_store import StoryStore
from app.services.narrative.world_state import WorldStateStore
def _current_round(sim_dir: str) -> int:
"""Return the 'current' round: last translated beat's round + 1, or 1."""
beats = StoryStore(sim_dir).get_all_beats()
if not beats:
return 1
return beats[-1].get("round", 0) + 1
def inject_event(sim_dir: str, description: str, round_num: Optional[int] = None) -> dict:
"""Append a user-described event to the world event log."""
world = WorldStateStore(sim_dir)
round_val = round_num if round_num is not None else _current_round(sim_dir)
return world.append_event({
"type": "god_mode_injection",
"description": description,
"round": round_val,
})
def modify_emotion(sim_dir: str, character_id: str, emotions: dict) -> dict:
"""Overwrite specified emotion values for a character. Clamps to [0, 1].
Raises ValueError if character_id is not found. Unknown emotion keys are
silently ignored (they don't corrupt state; they just don't apply).
Logs the intervention to world_state.event_log for auditability.
"""
store = StoryStore(sim_dir)
characters = store.load_characters()
target = next((c for c in characters if str(c.get("id")) == str(character_id)), None)
if target is None:
raise ValueError(f"character not found: {character_id}")
current = target["emotional_state"]["current"]
changed = {}
for emo, val in emotions.items():
if emo in current:
new_val = max(0.0, min(1.0, float(val)))
changed[emo] = {"from": current[emo], "to": new_val}
current[emo] = new_val
store.save_characters(characters)
WorldStateStore(sim_dir).append_event({
"type": "god_mode_emotion_change",
"description": f"{target['name']} emotional state modified: {changed}",
"round": _current_round(sim_dir),
})
return target
def kill_character(sim_dir: str, character_id: str) -> dict:
"""Mark a character as dead and append a death event to the world log.
Death events are auto-appended so the LLM knows the character is gone
rather than silently omitting them from prose.
"""
store = StoryStore(sim_dir)
characters = store.load_characters()
target = next((c for c in characters if str(c.get("id")) == str(character_id)), None)
if target is None:
raise ValueError(f"character not found: {character_id}")
target["status"] = "dead"
store.save_characters(characters)
WorldStateStore(sim_dir).append_event({
"type": "god_mode_death",
"description": f"{target['name']} has died.",
"round": _current_round(sim_dir),
})
return target

View File

@ -0,0 +1,334 @@
"""Reads OASIS actions.jsonl and translates rounds into story prose.
The translator is stateless about which round comes next callers maintain
the file offset via StoryStore. Each call to `read_actions_for_round` reads
from the saved offset until a matching `round_end` event is seen (or EOF).
"""
import os
import json
from typing import List, Tuple
from app.services.narrative.action_mapper import get_narrative_context
def _escape_braces(text: str) -> str:
"""Escape { and } in user-supplied strings before str.format()."""
return text.replace("{", "{{").replace("}", "}}")
def _format_world_rules(world: dict) -> str:
rules = world.get("rules", [])
if not rules:
return "(none)"
return "; ".join(_escape_braces(r) for r in rules)
def _format_world_events(world: dict) -> str:
events = world.get("event_log", [])[-3:]
if not events:
return "(none)"
return "\n ".join(
f"(Round {e.get('round', '?')}) {_escape_braces(e.get('description', ''))}"
for e in events
)
def _format_world_locations(world: dict) -> str:
locs = list(world.get("locations", {}).values())[:5]
if not locs:
return "(none)"
lines = []
for l in locs:
name = _escape_braces(l.get("name", ""))
desc = _escape_braces(l.get("description", ""))
line = f"{name}{desc}"
# Cinematic schema: if atmosphere is present, surface it to the LLM as
# a mood anchor for any scene set here.
atmosphere = l.get("atmosphere")
if atmosphere:
line += f" [atmosphere: {_escape_braces(atmosphere)}]"
lines.append(line)
return "\n ".join(lines)
def read_actions_for_round(
jsonl_path: str, start_offset: int, target_round: int
) -> Tuple[List[dict], int]:
"""Read all agent actions for `target_round` starting at `start_offset`.
Returns:
(actions, new_offset): list of action dicts and the file position to
resume from on the next read. If the target round hasn't completed yet
(no matching `round_end` event), new_offset is advanced to EOF so the
next call picks up where we left off.
"""
if not os.path.exists(jsonl_path):
return [], start_offset
actions: List[dict] = []
new_offset = start_offset
with open(jsonl_path, "r", encoding="utf-8") as f:
f.seek(start_offset)
while True:
line = f.readline()
if not line:
break
try:
entry = json.loads(line)
except json.JSONDecodeError:
# Skip malformed lines but keep advancing
new_offset = f.tell()
continue
# Round-end event for our target marks the boundary
if entry.get("event_type") == "round_end" and entry.get("round") == target_round:
new_offset = f.tell()
break
# Skip other event types (simulation_start, simulation_end, etc.)
if "event_type" in entry:
new_offset = f.tell()
continue
# Skip actions from other rounds
if entry.get("round") != target_round:
new_offset = f.tell()
continue
actions.append(entry)
new_offset = f.tell()
return actions, new_offset
# ---------------------------------------------------------------------------
# Prose generation
# ---------------------------------------------------------------------------
def call_llm(prompt: str) -> str:
"""Send a single-turn prompt to the configured LLM and return the response.
Wrapped as a module-level function (not a class method) so tests can patch
it trivially: `patch("app.services.narrative.narrative_translator.call_llm")`.
"""
# Lazy import — avoids forcing LLM config to be set during unit tests that
# never actually call the real LLM.
from app.utils.llm_client import LLMClient
client = LLMClient()
return client.chat(
messages=[{"role": "user", "content": prompt}],
temperature=0.7,
max_tokens=1024,
)
def _format_character_summary(character: dict, locations: dict | None = None) -> str:
emotions = character.get("emotional_state", {}).get("current", {})
top = sorted(emotions.items(), key=lambda kv: -kv[1])[:2]
emo_str = ", ".join(f"{e[0]}={e[1]:.1f}" for e in top) or "neutral"
loc_id = character.get("location")
loc_str = ""
if loc_id and locations and loc_id in locations:
loc_str = f" at {_escape_braces(locations[loc_id].get('name', loc_id))}"
name = _escape_braces(character.get("name", "Unknown"))
return f"{name} (feeling: {emo_str}){loc_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", {}) or {}
content = args.get("content", "")
ctx = get_narrative_context(act)
if content:
return f'- {name}: {ctx}. Content: "{content}"'
return f"- {name}: {ctx}"
# ============================================================================
# USER CONTRIBUTION POINT — Prose generation prompt
# ============================================================================
#
# This template is rendered with four substitutions and sent to the LLM to
# generate each story beat. It is the single biggest lever for story quality.
#
# Substitution fields available:
# {tone} — user-selected tone, e.g. "dark fantasy" or "romantic comedy"
# {characters} — per-character one-liner with top emotions
# {actions} — bullet-point list of what each character did this round
# {previous} — prose from the last 1-2 beats (or "(first scene)")
#
# Design choices to consider:
# - Tense/POV (third-person past is most versatile)
# - Paragraph count (2-4 is a good range)
# - Show vs tell (instruct the model to show emotions through action/dialogue)
# - Continuity (tell it to continue naturally from {previous})
# - How strictly to enforce tone
#
# A temporary minimal template is used below to make tests pass — REPLACE
# WITH YOUR OWN DESIGN to dial in story quality.
# ============================================================================
# ============================================================================
# USER CONTRIBUTION POINT — how strongly to force world events into prose
# ============================================================================
# Three supported values:
# "soft" — "consider referencing the most recent world event if it fits"
# "medium" — "weave it in OR acknowledge its aftermath. Do not ignore it."
# "hard" — "the opening line MUST reference the most recent world event"
# Temporary default is "medium" — change after reviewing sample output.
# ============================================================================
EVENT_ENFORCEMENT_STRENGTH = "hard"
_ENFORCEMENT_PHRASES = {
"soft": "- Consider referencing the most recent world event if it fits naturally.",
"medium": "- Weave the most recent world event in, OR acknowledge its aftermath. Do not ignore it.",
"hard": "- The OPENING LINE of this passage MUST reference the most recent world event.",
}
PROSE_PROMPT_TEMPLATE = """You are a screenwriter-turned-novelist. Your voice is PUNCHY, CINEMATIC, and DIALOGUE-DRIVEN.
Tone: {tone}
World grounding:
Rules: {world_rules}
Recent events:
{world_events}
Known locations:
{world_locations}
Previous scene:
{previous}
Characters in this scene:
{characters}
Events this round (translate these into a scene do NOT list them):
{actions}
Write a story passage following these rules:
STRUCTURE
- 2 to 3 short paragraphs. No more.
- Stay under 180 words total. Economy over explanation.
- Third-person past tense.
DIALOGUE
- Include at least 2 lines of spoken dialogue whenever characters interact.
- Dialogue does the heavy lifting let characters reveal themselves through what they say (and don't say).
- Mix clipped lines with one longer beat. Rhythm matters.
- Use "said" sparingly. Trust the reader.
CINEMATIC DETAIL
- Open on a concrete visual: a hand, a face, an object, the weather.
- One sharp sensory detail per paragraph. Not five.
- Cut hard between beats no connective "meanwhile" or "then."
- If a character has a known location, root the scene there.
{event_enforcement}
EMOTION
- Show it in bodies and voice clenched jaw, dropped eyes, half-smile, a pause too long.
- Never name the emotion directly. No "she felt angry." No "he was sad."
CONTINUITY
- If previous scene exists, echo one detail from it a word, an image, a beat. The story should feel continuous.
Write the prose only. No headings, no preamble, no meta commentary."""
# ============================================================================
def generate_prose(actions: list, characters: list, tone: str,
previous_beats: list, world: dict | None = None) -> str:
"""Generate a narrative passage from a round's actions via the LLM."""
if not actions:
return "A quiet pause settles over the scene. No one acts; no one speaks."
world = world or {"rules": [], "locations": {}, "event_log": []}
locations = world.get("locations", {})
char_summaries = "\n".join(_format_character_summary(c, locations) for c in characters)
action_lines = "\n".join(_format_action_line(a) for a in actions)
prev_prose = "\n\n".join(_escape_braces(b.get("prose", "")) for b in previous_beats[-2:])
enforcement = _ENFORCEMENT_PHRASES.get(
EVENT_ENFORCEMENT_STRENGTH, _ENFORCEMENT_PHRASES["medium"]
)
prompt = PROSE_PROMPT_TEMPLATE.format(
tone=_escape_braces(tone),
world_rules=_format_world_rules(world),
world_events=_format_world_events(world),
world_locations=_format_world_locations(world),
characters=char_summaries or "(none)",
actions=action_lines,
previous=prev_prose or "(this is the first scene)",
event_enforcement=enforcement,
)
return call_llm(prompt)
# ---------------------------------------------------------------------------
# Round orchestration — the entry point most callers use
# ---------------------------------------------------------------------------
def translate_round(sim_dir: str, platform: str, target_round: int, tone: str = "neutral") -> dict:
"""Translate a single simulation round into a story beat end-to-end.
Steps:
1. Read the round's actions from `{sim_dir}/{platform}/actions.jsonl`
(starting from the saved file offset).
2. Load world state (rules, locations, event log) empty if not present.
3. Filter dead characters and their actions out before prose generation.
4. Generate prose via the LLM using current character + world state.
5. Apply emotional-state deltas for every action to living characters.
6. Persist the new beat, updated character state, and new file offset.
Returns the newly created story beat dict.
"""
# Lazy imports keep module import lightweight for unit tests of helpers
from app.services.narrative.story_store import StoryStore
from app.services.narrative.character_engine import apply_action_emotional_delta
from app.services.narrative.world_state import WorldStateStore
store = StoryStore(sim_dir)
world = WorldStateStore(sim_dir).load()
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)
# Filter dead characters and their actions
all_characters = store.load_characters()
alive_names = {c["name"] for c in all_characters if c.get("status", "alive") != "dead"}
living_characters = [c for c in all_characters if c["name"] in alive_names]
actions = [a for a in actions if a.get("agent_name") in alive_names]
previous_beats = store.get_all_beats()
prose = generate_prose(actions, living_characters, tone, previous_beats, world)
# De-duplicated list of character names that acted this round (living only)
involved = sorted({a.get("agent_name") for a in actions if a.get("agent_name")})
# Apply emotional deltas only to living characters
char_by_name = {c["name"]: c for c in all_characters}
for action in actions:
char = char_by_name.get(action.get("agent_name"))
if char and char.get("status", "alive") != "dead":
apply_action_emotional_delta(char, action.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

View File

@ -0,0 +1,74 @@
"""File-based persistence for narrative state.
Each simulation gets a `narrative/` subdirectory inside its data dir,
containing three files:
- story_beats.json: chronological list of generated story passages
- translator_state.json: tracks file offset per platform's actions.jsonl
- characters.json: extended character profiles with emotional 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) -> None:
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)

View File

@ -0,0 +1,59 @@
"""World state CRUD: rules, locations, event log.
Stored in narrative/world_state.json. Missing file is treated as an empty
world so existing simulations (from pre-God-Mode versions) continue to work
without migration.
"""
import os
import json
class WorldStateStore:
"""Manages narrative/world_state.json 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.path = os.path.join(self.narrative_dir, "world_state.json")
def _ensure_dir(self) -> None:
os.makedirs(self.narrative_dir, exist_ok=True)
def load(self) -> dict:
if not os.path.exists(self.path):
return {"rules": [], "locations": {}, "event_log": []}
with open(self.path, "r", encoding="utf-8") as f:
world = json.load(f)
# Fill in missing keys for forward compatibility
world.setdefault("rules", [])
world.setdefault("locations", {})
world.setdefault("event_log", [])
return world
def save(self, world: dict) -> None:
self._ensure_dir()
with open(self.path, "w", encoding="utf-8") as f:
json.dump(world, f, ensure_ascii=False, indent=2)
def set_rules(self, rules: list[str]) -> None:
world = self.load()
world["rules"] = list(rules)
self.save(world)
def upsert_location(self, location: dict) -> dict:
"""Insert or update a location by id. Returns the stored entry."""
if "id" not in location:
raise ValueError("location requires 'id'")
world = self.load()
world["locations"][location["id"]] = location
self.save(world)
return location
def append_event(self, event: dict) -> dict:
"""Append an event to event_log, assigning evt_N id automatically."""
world = self.load()
event = dict(event)
event["id"] = f"evt_{len(world['event_log']) + 1}"
world["event_log"].append(event)
self.save(world)
return event

View File

View File

@ -0,0 +1,21 @@
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()

View File

@ -0,0 +1,47 @@
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
def test_create_initial_character_sets_status_alive():
char = create_initial_character(char_id="x", name="X")
assert char["status"] == "alive"

View File

@ -0,0 +1,113 @@
import os
import tempfile
import pytest
from app.services.narrative.god_mode import inject_event
from app.services.narrative.story_store import StoryStore
from app.services.narrative.world_state import WorldStateStore
@pytest.fixture
def temp_sim_dir():
with tempfile.TemporaryDirectory() as d:
sim_dir = os.path.join(d, "sim_test")
os.makedirs(sim_dir)
yield sim_dir
def test_inject_event_appends_to_log(temp_sim_dir):
evt = inject_event(temp_sim_dir, description="A storm arrives.", round_num=5)
assert evt["description"] == "A storm arrives."
assert evt["round"] == 5
assert evt["id"] == "evt_1"
assert evt["type"] == "god_mode_injection"
log = WorldStateStore(temp_sim_dir).load()["event_log"]
assert len(log) == 1
def test_inject_event_defaults_round_to_beats_plus_one(temp_sim_dir):
store = StoryStore(temp_sim_dir)
store.append_beat({"round": 3, "prose": "beat 3"})
evt = inject_event(temp_sim_dir, description="auto round")
assert evt["round"] == 4
def test_inject_event_defaults_round_to_one_when_no_beats(temp_sim_dir):
evt = inject_event(temp_sim_dir, description="first event")
assert evt["round"] == 1
# ---- modify_emotion ----
from app.services.narrative.god_mode import modify_emotion
def _seed_character(sim_dir, char_id="1", name="Elena"):
store = StoryStore(sim_dir)
neutral = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]}
store.save_characters([{
"id": char_id, "name": name, "status": "alive",
"emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []},
}])
return store
def test_modify_emotion_overwrites_specified_emotions(temp_sim_dir):
_seed_character(temp_sim_dir)
result = modify_emotion(temp_sim_dir, "1", {"anger": 0.8, "joy": 0.2})
assert result["emotional_state"]["current"]["anger"] == 0.8
assert result["emotional_state"]["current"]["joy"] == 0.2
assert result["emotional_state"]["current"]["trust"] == 0.5
def test_modify_emotion_clamps(temp_sim_dir):
_seed_character(temp_sim_dir)
result = modify_emotion(temp_sim_dir, "1", {"anger": 1.5, "fear": -0.3})
assert result["emotional_state"]["current"]["anger"] == 1.0
assert result["emotional_state"]["current"]["fear"] == 0.0
def test_modify_emotion_character_not_found_raises(temp_sim_dir):
_seed_character(temp_sim_dir)
with pytest.raises(ValueError, match="not found"):
modify_emotion(temp_sim_dir, "nonexistent", {"anger": 0.5})
def test_modify_emotion_audit_logs_to_event_log(temp_sim_dir):
_seed_character(temp_sim_dir)
modify_emotion(temp_sim_dir, "1", {"anger": 0.8})
log = WorldStateStore(temp_sim_dir).load()["event_log"]
assert len(log) == 1
assert log[0]["type"] == "god_mode_emotion_change"
assert "Elena" in log[0]["description"]
# ---- kill_character ----
from app.services.narrative.god_mode import kill_character
def test_kill_character_sets_status_dead(temp_sim_dir):
_seed_character(temp_sim_dir)
result = kill_character(temp_sim_dir, "1")
assert result["status"] == "dead"
chars = StoryStore(temp_sim_dir).load_characters()
assert chars[0]["status"] == "dead"
def test_kill_character_auto_appends_death_event(temp_sim_dir):
_seed_character(temp_sim_dir)
kill_character(temp_sim_dir, "1")
log = WorldStateStore(temp_sim_dir).load()["event_log"]
death_events = [e for e in log if e["type"] == "god_mode_death"]
assert len(death_events) == 1
assert "Elena" in death_events[0]["description"]
def test_kill_character_not_found_raises(temp_sim_dir):
_seed_character(temp_sim_dir)
with pytest.raises(ValueError, match="not found"):
kill_character(temp_sim_dir, "nonexistent")

View File

@ -0,0 +1,165 @@
"""End-to-end test: simulated actions.jsonl → translate → verify story + state."""
import os
import json
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"},
{"round": 2, "agent_id": 2, "agent_name": "Marcus", "action_type": "FOLLOW",
"action_args": {"target": 3}, "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)
neutral_emotions = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]}
store.save_characters([
{"id": "1", "name": "Elena",
"emotional_state": {"current": {**neutral_emotions, "trust": 0.5}, "history": []}},
{"id": "2", "name": "Marcus",
"emotional_state": {"current": {**neutral_emotions, "trust": 0.5}, "history": []}},
])
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")
# Beats are correctly attributed and sequenced
assert beat1["round"] == 1
assert beat2["round"] == 2
assert "Elena" in beat1["characters"]
assert "Marcus" in beat1["characters"]
assert beat1["action_count"] == 2
# Both beats persisted
all_beats = store.get_all_beats()
assert len(all_beats) == 2
assert all_beats[0]["prose"] == "Elena addressed the gathering. Marcus's face darkened."
# Characters evolved per the emotional delta rules
chars = {c["name"]: c for c in store.load_characters()}
# Marcus DISLIKE_POST'd in round 1 → anger should be > 0 (delta 0.08)
assert chars["Marcus"]["emotional_state"]["current"]["anger"] > 0.0
# Marcus FOLLOW'd in round 2 → trust should have climbed above baseline (delta 0.08)
assert chars["Marcus"]["emotional_state"]["current"]["trust"] > 0.5
# Elena CREATE_POST'd then REPOST'd → joy and surprise both bumped
assert chars["Elena"]["emotional_state"]["current"]["joy"] > 0.0
assert chars["Elena"]["emotional_state"]["current"]["surprise"] > 0.0
# Translator state advanced — next call for round 3 would resume, not re-read
assert store.get_file_offset("twitter") > 0
# ---------------------------------------------------------------------------
# God Mode integration tests
# ---------------------------------------------------------------------------
from app.services.narrative.god_mode import inject_event, kill_character
def test_injected_event_appears_in_next_round_prompt(tmp_path):
sim_dir = str(tmp_path / "sim_evt")
os.makedirs(os.path.join(sim_dir, "twitter"))
actions_path = os.path.join(sim_dir, "twitter", "actions.jsonl")
lines = [
{"round": 1, "agent_id": 1, "agent_name": "Elena", "action_type": "CREATE_POST",
"action_args": {"content": "x"}, "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 lines:
f.write(json.dumps(a) + "\n")
store = StoryStore(sim_dir)
neutral = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]}
store.save_characters([{
"id": "1", "name": "Elena", "status": "alive",
"emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []},
}])
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "beat"
translate_round(sim_dir, "twitter", 1, "noir")
# Inject between rounds
inject_event(sim_dir, description="A mysterious letter arrives.")
translate_round(sim_dir, "twitter", 2, "noir")
# Round-2 prompt should contain the injected event
round2_prompt = mock_llm.call_args_list[1][0][0]
assert "mysterious letter" in round2_prompt
def test_killed_character_filtered_from_next_round(tmp_path):
sim_dir = str(tmp_path / "sim_kill")
os.makedirs(os.path.join(sim_dir, "twitter"))
actions_path = os.path.join(sim_dir, "twitter", "actions.jsonl")
lines = [
{"round": 1, "agent_id": 1, "agent_name": "Elena", "action_type": "CREATE_POST",
"action_args": {"content": "x"}, "success": True, "timestamp": "t"},
{"round": 1, "agent_id": 2, "agent_name": "Marcus", "action_type": "DISLIKE_POST",
"action_args": {}, "success": True, "timestamp": "t"},
{"event_type": "round_end", "round": 1, "timestamp": "t"},
# Round 2: Elena (alive) and Marcus (will be killed) both act — filter
# should keep Elena and drop Marcus
{"round": 2, "agent_id": 1, "agent_name": "Elena", "action_type": "QUOTE_POST",
"action_args": {}, "success": True, "timestamp": "t"},
{"round": 2, "agent_id": 2, "agent_name": "Marcus", "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 lines:
f.write(json.dumps(a) + "\n")
store = StoryStore(sim_dir)
neutral = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]}
store.save_characters([
{"id": "1", "name": "Elena", "status": "alive",
"emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []}},
{"id": "2", "name": "Marcus", "status": "alive",
"emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []}},
])
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "beat"
translate_round(sim_dir, "twitter", 1, "noir")
# Kill Marcus between rounds
kill_character(sim_dir, "2")
beat2 = translate_round(sim_dir, "twitter", 2, "noir")
# Marcus acted in round 2 but is dead — his name should not be in
# the beat's 'characters' list (which reflects who participated)
assert "Marcus" not in beat2["characters"]
# Round-2 prompt's characters-in-scene section must not include Marcus.
# Marcus may appear in the event log as a death event; we exclude him
# from the "feeling:" lines which describe living characters.
round2_prompt = mock_llm.call_args_list[1][0][0]
char_lines = [l for l in round2_prompt.split("\n") if "feeling:" in l]
assert not any("Marcus" in l for l in char_lines)

View File

@ -0,0 +1,174 @@
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):
_, offset_after_round_1 = read_actions_for_round(actions_file, start_offset=0, target_round=1)
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
# ---- Prose generation tests ----
from unittest.mock import patch
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 passed to the LLM mentioned both characters
sent_prompt = mock_llm.call_args[0][0]
assert "Elena" in sent_prompt
assert "Marcus" in sent_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()
# ---- Round orchestration tests ----
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 line in lines:
f.write(json.dumps(line) + "\n")
store = StoryStore(sim_dir)
store.save_characters([{
"id": "1", "name": "Alice",
"emotional_state": {"current": {k: 0.0 for k in ["anger","fear","joy","sadness","trust","surprise"]}},
}])
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
def test_generate_prose_includes_world_context():
actions = [{"agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {}}]
characters = [
{"id": "1", "name": "Alice", "status": "alive", "location": "tower",
"emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0,
"sadness": 0, "trust": 0.5, "surprise": 0}}},
]
world = {
"rules": ["Magic is forbidden", "Winter is near"],
"locations": {"tower": {"id": "tower", "name": "The Tower", "description": "tall"}},
"event_log": [{"id": "evt_1", "round": 1, "type": "god_mode_injection",
"description": "A stranger arrived."}],
}
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "prose"
generate_prose(actions, characters, tone="noir", previous_beats=[], world=world)
prompt = mock_llm.call_args[0][0]
assert "Magic is forbidden" in prompt
assert "A stranger arrived" in prompt
assert "The Tower" in prompt
def test_location_atmosphere_surfaces_in_prompt():
"""Cinematic location schema — atmosphere should reach the LLM."""
actions = [{"agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {}}]
characters = [
{"id": "1", "name": "Alice", "status": "alive",
"emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0,
"sadness": 0, "trust": 0.5, "surprise": 0}}},
]
world = {
"rules": [],
"locations": {
"tower": {
"id": "tower",
"name": "The Iron Tower",
"description": "dark spire",
"atmosphere": "oppressive silence, dust in shafts of cold light",
}
},
"event_log": [],
}
with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm:
mock_llm.return_value = "prose"
generate_prose(actions, characters, tone="noir", previous_beats=[], world=world)
prompt = mock_llm.call_args[0][0]
assert "atmosphere" in prompt.lower()
assert "oppressive silence" in prompt

View File

@ -0,0 +1,47 @@
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)

View File

@ -0,0 +1,45 @@
import os
import tempfile
import pytest
from app.services.narrative.world_state import WorldStateStore
@pytest.fixture
def temp_sim_dir():
with tempfile.TemporaryDirectory() as d:
sim_dir = os.path.join(d, "sim_test")
os.makedirs(sim_dir)
yield sim_dir
def test_load_returns_empty_world_when_missing(temp_sim_dir):
store = WorldStateStore(temp_sim_dir)
world = store.load()
assert world == {"rules": [], "locations": {}, "event_log": []}
def test_set_rules_replaces_previous(temp_sim_dir):
store = WorldStateStore(temp_sim_dir)
store.set_rules(["rule 1", "rule 2"])
assert store.load()["rules"] == ["rule 1", "rule 2"]
store.set_rules(["only rule"])
assert store.load()["rules"] == ["only rule"]
def test_upsert_location_adds_and_updates(temp_sim_dir):
store = WorldStateStore(temp_sim_dir)
store.upsert_location({"id": "tower", "name": "The Tower", "description": "tall"})
assert store.load()["locations"]["tower"]["name"] == "The Tower"
store.upsert_location({"id": "tower", "name": "The Iron Tower", "description": "dark"})
assert store.load()["locations"]["tower"]["name"] == "The Iron Tower"
def test_append_event_auto_ids_sequentially(temp_sim_dir):
store = WorldStateStore(temp_sim_dir)
e1 = store.append_event({"type": "custom", "description": "one", "round": 1})
e2 = store.append_event({"type": "custom", "description": "two", "round": 2})
assert e1["id"] == "evt_1"
assert e2["id"] == "evt_2"
assert store.load()["event_log"][-1]["description"] == "two"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,532 @@
# MiroFish Narrative Layer — Design Specification
**Date:** 2026-03-26
**Approach:** A — Narrative Layer on top of existing OASIS simulation
**Target:** Creative simulation engine (novel continuation, world-building, interactive fiction)
**Scale:** Small community (1050 users), basic auth, moderate concurrency
---
## 1. Problem Statement
MiroFish is a multi-agent swarm intelligence engine built for prediction — it simulates social media interactions (Twitter/Reddit) to forecast outcomes. The simulation infrastructure (OASIS engine, Zep graph memory, LLM-driven agents) is powerful but locked into a social-media metaphor.
**Goal:** Extend MiroFish into a creative storytelling platform where users can upload fiction, define worlds, and watch AI agents generate emergent narratives — without replacing the proven OASIS simulation backbone.
**Key insight:** Social media actions map naturally to narrative actions. A "post" is speech. A "like" is agreement. A "repost" is rumor-spreading. By adding a translation layer, we reinterpret simulation output as story prose.
---
## 2. Architecture
### 2.1 System Diagram
```
┌──────────────────────────────────────────────────────┐
│ Frontend (Vue 3) │
│ ┌────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ Story │ │ Character │ │ God Mode │ │
│ │ Timeline │ │ Workshop │ │ Control Panel │ │
│ └────────────┘ └──────────────┘ └────────────────┘ │
│ ┌────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ World │ │ Branching │ │ Export │ │
│ │ Map View │ │ Timeline │ │ Studio │ │
│ └────────────┘ └──────────────┘ └────────────────┘ │
├──────────────────────────────────────────────────────┤
│ Narrative Engine (NEW) │
│ ┌──────────────────────────────────────────────┐ │
│ │ Narrative Translator │ │
│ │ - Action → Story Event mapping │ │
│ │ - Emotional state tracking │ │
│ │ - Plot arc detection │ │
│ └──────────────────────────────────────────────┘ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Character │ │ World State │ │ Timeline │ │
│ │ Engine │ │ Manager │ │ Brancher │ │
│ └─────────────┘ └──────────────┘ └─────────────┘ │
├──────────────────────────────────────────────────────┤
│ Existing MiroFish Backend │
│ Graph Builder → OASIS Simulation → Report Agent │
│ Zep Memory → Profile Generator → LLM Client │
└──────────────────────────────────────────────────────┘
```
### 2.2 New Backend Services
All new services live in `backend/app/services/narrative/` to keep the existing codebase untouched.
| Service | File | Purpose |
|---|---|---|
| Narrative Translator | `narrative_translator.py` | Converts OASIS actions into story prose via LLM |
| Character Engine | `character_engine.py` | Manages extended character profiles (backstory, emotions, arcs) |
| World State Manager | `world_state.py` | Tracks locations, factions, resources, world rules |
| Timeline Brancher | `timeline_brancher.py` | Snapshots and forks simulation state |
| Seed Enhancer | `seed_enhancer.py` | Processes EPUB/DOCX/templates into MiroFish seeds |
| Export Studio | `export_studio.py` | Generates EPUB, screenplay, chapter formats |
### 2.3 New API Routes
New route blueprint: `narrative_bp = Blueprint('narrative', __name__)` in `backend/app/api/narrative.py`. Register in `backend/app/__init__.py` via `app.register_blueprint(narrative_bp, url_prefix='/api/narrative')`. Import added to `backend/app/api/__init__.py`.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | `/api/narrative/translate` | Translate a simulation round into story prose |
| GET | `/api/narrative/story/{sim_id}` | Get full story for a simulation |
| GET | `/api/narrative/story/{sim_id}/round/{round}` | Get story for a specific round |
| POST | `/api/narrative/characters` | Create/update extended character profiles |
| GET | `/api/narrative/characters/{sim_id}` | Get all characters with emotional states |
| GET | `/api/narrative/characters/{sim_id}/{char_id}` | Get single character detail |
| POST | `/api/narrative/world` | Create/update world state |
| GET | `/api/narrative/world/{sim_id}` | Get current world state |
| POST | `/api/narrative/godmode/inject` | Inject an event into a running simulation |
| POST | `/api/narrative/godmode/modify-character` | Modify character state mid-simulation |
| POST | `/api/narrative/godmode/change-rules` | Change world rules mid-simulation |
| POST | `/api/narrative/branch/{sim_id}/{round}` | Fork timeline at a specific round |
| GET | `/api/narrative/branches/{sim_id}` | List all branches for a simulation |
| POST | `/api/narrative/export/{sim_id}` | Export story in specified format |
| GET | `/api/narrative/export/{sim_id}/status` | Check export generation status |
### 2.4 New Frontend Views
| View | File | Purpose |
|---|---|---|
| Story Timeline | `views/StoryTimelineView.vue` | Read the generated narrative chapter by chapter |
| Character Workshop | `views/CharacterWorkshopView.vue` | Create, edit, and browse characters |
| World Builder | `views/WorldBuilderView.vue` | Define locations, factions, rules |
| God Mode | `views/GodModeView.vue` | Control panel for mid-simulation intervention |
| Branch Explorer | `views/BranchExplorerView.vue` | Visual timeline tree, compare branches |
| Export Studio | `views/ExportStudioView.vue` | Choose format, preview, download |
### 2.6 Frontend Routes
| Route | View | Description |
|---|---|---|
| `/story/:simId` | StoryTimelineView | Read generated narrative |
| `/characters/:simId` | CharacterWorkshopView | Create and browse characters |
| `/world/:simId` | WorldBuilderView | Define world settings |
| `/godmode/:simId` | GodModeView | Mid-simulation controls |
| `/branches/:simId` | BranchExplorerView | Timeline tree and comparison |
| `/export/:simId` | ExportStudioView | Format and download stories |
Routes are added to `frontend/src/router/index.js` following the existing pattern of `/:simulationId` parameterized routes.
### 2.5 New Frontend Components
| Component | Purpose |
|---|---|
| `StoryBeat.vue` | Single story event/paragraph with character attribution |
| `CharacterCard.vue` | Character portrait with stats, emotions, relationships |
| `RelationshipGraph.vue` | Visual graph of character relationships (D3.js) |
| `EmotionTracker.vue` | Per-character emotional state over time (sparkline) |
| `WorldMap.vue` | Visual map of locations and character positions |
| `TimelineTree.vue` | Branching timeline visualization |
| `GodModeToolbar.vue` | Floating toolbar for injecting events |
| `ExportPreview.vue` | Preview formatted output before download |
---
## 3. Narrative Translator — Core Logic
### 3.0 Integration Point — How the Narrative Layer Connects to OASIS
The existing OASIS simulation runs as a subprocess that writes actions to `actions.jsonl` (one JSON line per agent action per round). The existing `_monitor_simulation` method in `SimulationRunner` already polls this file every 2 seconds, tracking file position to read only new lines.
**Narrative translation is asynchronous and on-demand.** The Narrative Translator does NOT hook into the simulation loop. Instead:
1. **During simulation:** The existing `_monitor_simulation` continues reading `actions.jsonl` as-is. No changes to the simulation runner.
2. **After each round completes:** The frontend polls a new endpoint `GET /api/narrative/story/{sim_id}/latest` which triggers translation of any untranslated rounds.
3. **Translation reads `actions.jsonl`** directly, maintaining its own file offset in `narrative/translator_state.json`. It reads actions for the next untranslated round, generates prose, and stores the result.
4. **Batch translation** is also available via `POST /api/narrative/translate` for retroactively translating a completed simulation.
**Why on-demand (not in-loop):** LLM prose generation takes 2-5 seconds per scene. Injecting this into the simulation monitor would slow round processing by 10-20x. By decoupling translation, the simulation runs at full speed and narrative is generated as users view it.
**IPC for God Mode:** The existing `SimulationIPCClient` supports file-based IPC with `INTERVIEW`, `BATCH_INTERVIEW`, and `CLOSE_ENV` commands. God Mode leverages `INTERVIEW` to inject custom prompts into running agents (see Section 6). New IPC command types (`INJECT_EVENT`, `MODIFY_AGENT_PROMPT`) will be added to `SimulationIPCServer` and the OASIS runner scripts.
### 3.1 Action Mapping
| OASIS Action | Narrative Interpretation |
|---|---|
| `CREATE_POST` | Character speaks, declares, announces |
| `LIKE_POST` | Character agrees, supports, nods |
| `REPOST` | Character spreads rumor, amplifies, gossips |
| `QUOTE_POST` | Character responds, debates, challenges |
| `FOLLOW` | Character allies with, shows loyalty to |
| `DO_NOTHING` | Character reflects, observes, waits *(inferred — see note below)* |
| `CREATE_COMMENT` | Character engages in dialogue |
| `DISLIKE_POST` | Character opposes, confronts, disapproves |
| `LIKE_COMMENT` | Character validates someone's response |
| `DISLIKE_COMMENT` | Character dismisses, mocks |
| `SEARCH_POSTS` | Character investigates, seeks information |
| `SEARCH_USER` | Character seeks out a specific person |
| `MUTE` | Character avoids, ignores, shuns |
**Note on `DO_NOTHING`:** This action may not appear in `actions.jsonl` since the logger only records actions agents take. The translator infers inaction: any agent present in the simulation but absent from a round's action log is treated as "observing/waiting." This is narratively valuable.
### 3.2 Translation Pipeline
For each simulation round:
1. **Collect actions** — read `actions.jsonl` from the translator's saved file offset, gather all actions until the next `round_end` event
2. **Infer inaction** — compare active agents against the full character roster; absent agents are marked as "observing"
3. **Enrich with context** — pull character emotional states, relationships, world state from `narrative/` files
4. **Group into scenes** — cluster related actions (same thread / interacting agents)
5. **Generate prose** — LLM call per scene with action data + context → narrative paragraph
6. **Detect plot events** — classify the round as rising action / climax / falling action / resolution
7. **Update states** — update character emotions and relationships based on what happened
8. **Store** — persist story beat to `narrative/story_beats.json`, update translator file offset in `narrative/translator_state.json`
### 3.3 Resilience
- **LLM retry:** 3 retries with exponential backoff (1s, 2s, 4s) using the existing `retry` utility in `backend/app/utils/retry.py`
- **Partial failure:** If a scene fails after retries, store a placeholder beat with raw action data. User can retry individual beats via API.
- **Graceful degradation:** If the LLM is completely unavailable, fall back to template-based summary: `"{agent_name} {action_verb} {target}"`
- **Cost estimate:** ~3-5 LLM calls per round (one per scene). A 20-round simulation ≈ 60-100 calls ≈ 30k-50k tokens ≈ $0.15-$0.25 with GPT-4o-mini.
### 3.4 LLM Prompt Structure
The translator uses a structured prompt:
```
You are a narrative writer. Convert these simulation events into a story passage.
World: {world_description}
Setting: {current_location_context}
Characters involved: {character_summaries_with_emotions}
Events this round:
{structured_action_list}
Previous story context (last 2 beats):
{previous_prose}
Write a story passage (2-4 paragraphs) that:
- Uses third-person past tense
- Shows character emotions through action and dialogue
- Maintains consistency with the world rules
- Advances the narrative naturally from the previous context
Tone: {user_defined_tone — e.g., "dark fantasy", "lighthearted comedy", "political thriller"}
```
---
## 4. Character Engine
### 4.1 Extended Character Schema
```json
{
"id": "char_abc123",
"name": "Elena Voss",
"oasis_agent_id": "agent_xyz",
"backstory": "Former diplomat turned rogue negotiator...",
"motivations": ["power", "redemption"],
"personality_traits": ["cunning", "loyal_to_few", "distrustful"],
"speech_style": "Formal, precise, with occasional dark humor",
"emotional_state": {
"current": {"anger": 0.3, "fear": 0.1, "joy": 0.0, "sadness": 0.4, "trust": 0.2},
"history": [{"round": 1, "state": {...}}, ...]
},
"relationships": {
"char_def456": {"type": "rival", "intensity": 0.8, "history": ["betrayal in round 3"]},
"char_ghi789": {"type": "ally", "intensity": 0.6, "history": ["formed alliance in round 1"]}
},
"arc": {
"archetype": "fall_from_grace",
"stage": "descent",
"key_moments": [{"round": 3, "event": "Betrayed longtime ally"}]
},
"location": "The Iron Tower",
"faction": "The Council"
}
```
### 4.2 Emotional State Model
Six basic emotions tracked as 0.01.0 floats: `anger`, `fear`, `joy`, `sadness`, `trust`, `surprise`.
Updated each round based on:
- Actions taken by the character
- Actions taken *toward* the character
- World events
- Relationship changes
**v1: Display-only.** Emotional states are derived from actions and displayed in the UI but do NOT feed back into OASIS agent prompts. OASIS profiles are generated once at simulation setup time by `OasisProfileGenerator` and are not modified mid-simulation.
**v2 (future):** Use the new `INJECT_CONTEXT` IPC command (see Section 6.2) to prepend emotional context to agent prompts each round. E.g., `"You are currently feeling angry and distrustful after the betrayal."` This creates a feedback loop where emotions influence behavior, but adds an IPC call per agent per round.
### 4.3 Character Arc Detection
After each round, the Character Engine evaluates arc progression using pattern matching:
| Arc Type | Pattern |
|---|---|
| Hero's Journey | Comfort → Crisis → Growth → Triumph |
| Fall from Grace | Status → Temptation → Descent → Consequences |
| Redemption | Flaw → Suffering → Realization → Atonement |
| Coming of Age | Innocence → Challenge → Learning → Maturity |
| Tragedy | Hope → Hubris → Downfall → Loss |
**Detection mechanism:** Rule-based classification using emotional state deltas:
- Trust drops > 0.3 in one round → "crisis" stage
- Joy sustained > 0.6 for 3+ rounds → "comfort" or "triumph" stage
- Anger + fear both > 0.5 → "descent" stage
- Transition from high-negative to high-positive emotions → "redemption"
If rule-based detection is ambiguous, a single LLM classification call is made with the character's action history summary (~200 tokens). Detected arcs are stored in `characters.json` and surfaced in the UI and export.
---
## 5. World State Manager
### 5.1 World State Schema
```json
{
"sim_id": "sim_abc123",
"name": "The Shattered Kingdoms",
"genre": "dark_fantasy",
"tone": "grim, political",
"era": "medieval",
"rules": [
"Magic exists but is feared and regulated",
"The monarchy has fallen; power is contested by three factions",
"Winter is approaching and resources are scarce"
],
"locations": {
"iron_tower": {"name": "The Iron Tower", "type": "fortress", "faction": "council", "characters": ["char_abc"]},
"market_district": {"name": "Market District", "type": "city", "faction": "neutral", "characters": []}
},
"factions": {
"council": {"name": "The Council", "power": 0.6, "members": ["char_abc"], "goals": ["restore order"]},
"rebels": {"name": "The Free Folk", "power": 0.3, "members": ["char_def"], "goals": ["overthrow council"]}
},
"resources": {
"food": {"abundance": 0.3, "controlled_by": "council"},
"weapons": {"abundance": 0.5, "controlled_by": "rebels"}
},
"timeline_position": {"round": 5, "branch": "main"}
}
```
### 5.2 World State Updates
Each simulation round, the World State Manager:
1. Processes narrative events for world-level changes (faction power shifts, resource changes)
2. Updates character locations based on their actions
3. Checks world rules for constraint violations
4. Triggers world events (e.g., "winter arrives" at round 10)
---
## 6. God Mode
### 6.1 Intervention Types
| Intervention | Effect |
|---|---|
| **Inject Event** | Add a world event ("earthquake strikes", "a stranger arrives") that agents must react to |
| **Modify Character** | Change a character's emotional state, motivation, or relationships |
| **Change Rules** | Add or remove world rules ("magic is now forbidden") |
| **Force Action** | Make a specific character take a specific action next round |
| **Kill Character** | Remove an agent from the simulation permanently |
| **Resurrect Character** | Re-introduce a removed character |
| **Time Skip** | Jump forward N rounds with summarized events |
| **Rewind** | Go back to a previous round (creates a branch) |
### 6.2 Implementation — IPC Dispatch Mechanism
God Mode leverages the existing **file-based IPC** system (`SimulationIPCClient` / `SimulationIPCServer`). The IPC currently supports `INTERVIEW`, `BATCH_INTERVIEW`, and `CLOSE_ENV` commands.
**Mapping God Mode interventions to IPC:**
| Intervention | IPC Mechanism |
|---|---|
| **Inject Event** | `BATCH_INTERVIEW` — send all agents a prompt describing the event, forcing them to react in their next action. E.g., `"An earthquake just struck. How do you respond?"` |
| **Modify Character** | `INTERVIEW` — send the target agent a prompt that reframes their emotional/motivational state. E.g., `"You have just learned that your ally betrayed you. Your trust is shattered."` The Character Engine also updates its local emotional state JSON. |
| **Change Rules** | `BATCH_INTERVIEW` — inform all agents of the rule change. E.g., `"Magic has been outlawed. Anyone caught using it faces death."` Also update `world_state.json`. |
| **Force Action** | `INTERVIEW` — send the target agent a directive prompt. E.g., `"You decide to confront Marcus in front of the council. Describe your accusation."` The response is logged as the agent's action. |
| **Kill Character** | Stop sending actions for this agent in subsequent rounds. Mark as "dead" in `characters.json`. OASIS agent continues to exist but receives no prompts. |
| **Resurrect Character** | Reverse of kill — resume prompting the agent with a resurrection context prompt. |
| **Time Skip** | Set simulation to run N rounds without narrative translation, then batch-translate with a "summary" prompt instead of detailed prose. |
| **Rewind** | Creates a new simulation from scratch using the same config but with a modified starting prompt that includes "the story so far up to round N." See Section 7 for details. |
**New IPC command type needed:** `INJECT_CONTEXT` — a variant of `BATCH_INTERVIEW` that prepends context to all agents' next-round system prompts rather than triggering an immediate interview response. This requires a small addition to the OASIS runner scripts (`run_twitter_simulation.py`, `run_reddit_simulation.py`).
Each intervention is logged to `narrative/god_mode_log.json` for story coherence and auditability.
---
## 7. Timeline Branching
### 7.1 Branch Model
```json
{
"sim_id": "sim_abc123",
"branches": {
"main": {"parent": null, "fork_round": 0, "status": "running", "rounds_completed": 10},
"what_if_elena_dies": {"parent": "main", "fork_round": 5, "status": "running", "rounds_completed": 3},
"peaceful_ending": {"parent": "main", "fork_round": 7, "status": "paused", "rounds_completed": 1}
}
}
```
### 7.2 Branching Mechanics — Replay-Based Approach
**OASIS does not support checkpointing.** The simulation database is deleted on startup and there is no state serialization for mid-simulation resumption. Therefore, branching uses a **replay + diverge** strategy:
1. User selects a round N to branch from
2. System creates a new simulation directory under `uploads/simulations/{sim_id}/branches/{branch_name}/`
3. System copies the original `simulation_config.json` and `profiles.json`
4. System generates a **"story so far" summary** — an LLM-generated condensation of all narrative beats from rounds 1 to N
5. This summary is injected into every agent's starting prompt as context: `"The following events have already occurred: {summary}. You are now at this point in the story."`
6. A new OASIS subprocess launches with modified agent prompts that include this prior context
7. User can optionally inject a God Mode event to differentiate the branch (e.g., "In this timeline, Elena survives the betrayal")
8. Both branches run as independent OASIS processes — user can switch between them in the UI
**Trade-offs of replay approach:**
- **Pro:** No OASIS modifications needed. Uses existing subprocess launch mechanism.
- **Con:** Agents don't have exact memory of previous rounds — they have an LLM-summarized version. This means branches may drift from the original timeline's "feel." For creative fiction, this is acceptable (stories branch naturally).
- **Con:** Starting a branch requires an LLM call for summary generation (~5-10 seconds). Not a major bottleneck.
- **Performance:** Branch launch takes the same time as a fresh simulation start (~10-30 seconds), regardless of which round you branch from.
Storage: Each branch gets its own subdirectory under `uploads/simulations/{sim_id}/branches/{branch_name}/` with its own `simulation_config.json`, `actions.jsonl`, and `narrative/` folder.
---
## 8. Enhanced Input Pipeline
### 8.1 New Seed Formats
| Format | Processing |
|---|---|
| **EPUB** | Extract text → identify characters (NER) → extract settings → generate world state |
| **DOCX** | Same as EPUB but with python-docx parsing |
| **YAML template** | Structured world definition — characters, locations, rules — no LLM needed |
| **Image** | Send to multimodal LLM → extract character description / setting description |
| **URL** | Scrape page content → process as text seed |
### 8.2 YAML World Template Format
```yaml
world:
name: "The Shattered Kingdoms"
genre: "dark_fantasy"
tone: "grim, political"
rules:
- "Magic exists but is feared"
- "Winter is approaching"
characters:
- name: "Elena Voss"
role: "protagonist"
backstory: "Former diplomat..."
motivations: ["power", "redemption"]
personality: ["cunning", "loyal"]
- name: "Marcus Iron"
role: "antagonist"
backstory: "Military commander..."
motivations: ["control", "legacy"]
locations:
- name: "The Iron Tower"
type: "fortress"
description: "A dark spire..."
factions:
- name: "The Council"
goals: ["restore order"]
members: ["Elena Voss"]
simulation:
max_rounds: 20
tone: "dark_fantasy"
platform: "twitter" # underlying OASIS platform
```
---
## 9. Export Studio
### 9.1 Output Formats
| Format | Structure |
|---|---|
| **Chapters** | Story beats grouped into chapters (every 3-5 rounds), with chapter titles |
| **Screenplay** | Character names in caps, dialogue, (parenthetical action), scene headings |
| **EPUB** | Full e-book with cover, table of contents, chapters, character appendix |
| **Character Dossiers** | Per-character PDF with backstory, arc summary, key moments, relationships |
| **Raw Timeline** | JSON export of all events for programmatic use |
### 9.2 Export Pipeline
1. User selects simulation + branch + format
2. Backend collects all story beats for the selected scope
3. LLM pass for cohesion — smooth transitions between beats, add chapter breaks
4. Format-specific rendering (EPUB via `ebooklib`, screenplay via template)
5. Return download URL
**Async handling:** Export runs in a background thread (consistent with existing `SimulationRunner._monitor_simulation` pattern). The `POST /api/narrative/export/{sim_id}` endpoint returns immediately with an export job ID. Frontend polls `GET /api/narrative/export/{sim_id}/status` until complete. Note: server restarts will lose in-progress exports — acceptable for the target scale.
---
## 10. Data Model Changes
### 10.1 New Files per Simulation
```
uploads/simulations/{sim_id}/
├── state.json # existing
├── profiles.json # existing
├── entities.json # existing
├── simulation_config.json # existing
├── narrative/ # NEW
│ ├── world_state.json # world definition
│ ├── characters.json # extended character profiles
│ ├── story_beats.json # generated narrative per round
├── branches/ # Timeline branches (see Section 7)
│ ├── what_if_branch/
│ │ ├── simulation_config.json
│ │ ├── actions.jsonl
│ │ └── narrative/
│ │ ├── story_beats.json
│ │ └── characters.json
│ └── exports/
│ ├── story_chapters.epub
│ └── screenplay.txt
```
### 10.2 Database Additions
No new database — continues using file-based storage consistent with existing MiroFish patterns. For the 10-50 user scale, this is sufficient. If we need to scale later, we migrate to SQLite or PostgreSQL.
---
## 11. Authentication & Multi-User
**Deferred to a separate spec.** The existing MiroFish codebase has zero authentication — no middleware, no user model, no token checks. Adding JWT auth is a cross-cutting concern that touches every existing endpoint and deserves its own design. For v1 of the Narrative Layer, we operate without auth (same as existing MiroFish). Multi-user isolation is handled by simulation IDs — each simulation is self-contained in its own directory.
**When auth is added (separate spec):** Simple JWT tokens, user model in SQLite, middleware on all `/api/` routes, project isolation by user directory, optional "public" flag for sharing.
---
## 12. Non-Goals (Explicitly Out of Scope)
- Real-time collaborative editing (too complex for v1)
- Voice/audio output of stories
- Image generation for scenes (future enhancement)
- Mobile app
- Horizontal scaling / microservices
- Payment / billing

View File

@ -0,0 +1,373 @@
# God Mode + World State — Minimal v1 Design Specification
**Date:** 2026-04-20
**Builds on:** `2026-03-26-narrative-layer-design.md` (sections 56)
**Target:** Author-controlled story intervention + world grounding
**Scope:** 3 God Mode actions + 3 World State features, file-based only
---
## 1. Problem
The Narrative Layer generates prose from OASIS simulation output, but the author has no way to intervene mid-story or ground scenes in a coherent world. This is a creative platform, not a passive spectator tool — authors need levers.
**v1 goal:** Ship the three highest-leverage intervention levers and the three highest-leverage world grounding primitives, without modifying the OASIS simulation engine.
---
## 2. Scope
### In scope
**God Mode interventions (3):**
1. **Inject world event** — append a user-described event to the world event log; subsequent prose generations reference it
2. **Modify character emotional state** — directly set emotion values in `characters.json`
3. **Kill character** — mark a character as `status: "dead"`; filtered from future translations
**World State (3):**
1. **Locations** — named places with descriptions; characters can have a current `location`
2. **World rules** — list of background rules / constraints (genre, laws, era)
3. **Event log** — chronological record of world-level events, populated automatically by God Mode event injections
### Explicitly out of scope (deferred to v2+)
- Agent-side event propagation (the OASIS simulation doesn't "see" injected events — only the prose layer does)
- World rule changes mid-simulation via OASIS prompt injection
- Factions, resources, timeline branching
- Force action, resurrect character, time skip
### Why file-based only
The existing OASIS subprocess has no `INJECT_CONTEXT` IPC command. Adding one requires touching the OASIS runner scripts, which introduces simulation coupling we don't need for creative storytelling. v1 God Mode is a **story-layer intervention system** — it shapes what the author reads, not what agents do. The author injects an earthquake; the next prose paragraph starts "A tremor ran through the city…" The agents keep posting about whatever they were already posting about. This is a feature, not a bug: it means authors can introduce "unreliable narrator" events that exist only in the prose.
---
## 3. Architecture
### 3.1 New files
**Backend services:**
| File | Responsibility |
|---|---|
| `backend/app/services/narrative/world_state.py` | CRUD for world_state.json (locations, rules, event log) |
| `backend/app/services/narrative/god_mode.py` | 3 intervention handlers; writes to world_state and characters |
| `backend/tests/test_world_state.py` | Unit tests for world CRUD |
| `backend/tests/test_god_mode.py` | Unit tests for interventions |
**Backend API (modifies existing):**
| File | Change |
|---|---|
| `backend/app/api/narrative.py` | +6 endpoints (3 world, 3 god mode) |
**Backend translator (modifies existing):**
| File | Change |
|---|---|
| `backend/app/services/narrative/narrative_translator.py` | Extend prompt to surface world rules, recent events, character locations; filter dead characters |
**Frontend:**
| File | Responsibility |
|---|---|
| `frontend/src/api/narrative.js` | +6 named exports for new endpoints |
| `frontend/src/views/WorldBuilderView.vue` | Locations + rules + event log UI |
| `frontend/src/views/GodModeView.vue` | 3 action forms |
| `frontend/src/router/index.js` | +2 routes |
### 3.2 Data model
New file per simulation: `uploads/simulations/{sim_id}/narrative/world_state.json`
```json
{
"sim_id": "sim_abc123",
"rules": [
"The kingdom is in civil war",
"Magic is feared but not forbidden",
"Winter will arrive in 10 rounds"
],
"locations": {
"iron_tower": {
"id": "iron_tower",
"name": "The Iron Tower",
"description": "A brutal spire of black stone at the city's heart."
},
"market": {
"id": "market",
"name": "The Old Market",
"description": "Narrow stalls under fraying canvas. Always crowded."
}
},
"event_log": [
{
"id": "evt_001",
"round": 3,
"type": "god_mode_injection",
"description": "A stranger arrived at the market, carrying a sealed letter.",
"injected_at": "2026-04-20T14:22:00Z"
}
]
}
```
**Character schema extension** (`characters.json`) — adds two optional fields:
```json
{
"id": "1",
"name": "Elena",
"emotional_state": {...},
"status": "alive", // NEW — "alive" | "dead"
"location": "iron_tower" // NEW — optional, references world_state.locations
}
```
Existing characters without these fields default to `status: "alive"` and no location — backward compatible.
### 3.3 API endpoints
All under `/api/narrative/*` prefix (reusing existing `narrative_bp`).
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/world/<sim_id>` | Return full world_state.json |
| `POST` | `/world/<sim_id>/rules` | Set world rules (body: `{rules: [string]}`) |
| `POST` | `/world/<sim_id>/locations` | Upsert a location (body: `{id, name, description}`) |
| `POST` | `/godmode/<sim_id>/inject-event` | Inject a world event (body: `{description, round?}`) |
| `POST` | `/godmode/<sim_id>/modify-emotion` | Set emotion values (body: `{character_id, emotions: {anger: 0.8, ...}}`) |
| `POST` | `/godmode/<sim_id>/kill` | Mark character dead (body: `{character_id}`) |
---
## 4. God Mode handlers
### 4.1 Inject event
```python
def inject_event(sim_dir: str, description: str, round_num: int | None = None) -> dict:
"""Append a new event to world_state.event_log.
If round_num is None, uses current round (last translated beat's round + 1,
or 1 if no beats yet).
"""
```
- Generates unique `id = evt_{n}` where n is `len(event_log) + 1`
- Returns the event dict
### 4.2 Modify emotion
```python
def modify_emotion(sim_dir: str, character_id: str, emotions: dict[str, float]) -> dict:
"""Overwrite specified emotion values for a character.
Clamps to [0.0, 1.0]. Only modifies emotions named in the input dict;
unspecified emotions keep their current values. Unknown emotion keys
are silently ignored.
"""
```
- Reads `characters.json`, finds character by `id`, applies overwrites
- Raises `ValueError("character not found")` if `character_id` doesn't match
- **Audit logging:** appends a `{"type": "god_mode_emotion_change", ...}` entry to `world_state.event_log` so the intervention is visible in the world event log UI
### 4.3 Kill character
```python
def kill_character(sim_dir: str, character_id: str) -> dict:
"""Mark character as dead. Future translations ignore their actions."""
```
- Sets `status = "dead"` on the target character
- Translation pipeline filters dead characters from the characters list passed to LLM prompt, and ignores any actions with matching agent names
- **Auto-appends a death event** to `world_state.event_log`: `{"type": "god_mode_death", "description": "{name} has died.", "round": current_round}` where `current_round` is defined as "last translated beat's round + 1, or 1 if no beats yet" (same rule `inject_event` uses). This guarantees the LLM knows the character is gone rather than silently omitting them — preventing "character vanishes mid-story" narrative bugs.
- Raises `ValueError("character not found")` if `character_id` doesn't match
### 4.4 Concurrency note
God Mode writes to `characters.json` while background translation may also be reading/writing it. This is a classic read-modify-write race: if `translate_round` is mid-LLM-call when the user POSTs `/kill`, the subsequent save may overwrite the kill. **For v1 (single-user, 1050 user scale), this is an accepted limitation, not a bug.** Authors are expected to intervene between rounds, not during. This constraint is documented in §10 non-goals. File-locking can be added in a follow-up if needed.
---
## 5. Translator prompt extension
### 5.0 Integration — how the translator loads world state
`translate_round` is extended to load `world_state.json` internally via a new `WorldStateStore.load(sim_dir)` (parallel to how it uses `StoryStore.load_characters()` today). If the file doesn't exist, load returns an empty world (`{"rules": [], "locations": {}, "event_log": []}`) — existing simulations without a world state continue to work unchanged.
**Event round semantics:** every event in `event_log` has a `round` field, but this is **metadata only** (used for UI display and prompt formatting as `"(Round N)"`). `event_log` is append-only — the last element in the list is always the newest. The translator always surfaces the *last 3 events by insertion order*, regardless of their round. `event.round` is never used as a filter.
**Lookup key discipline:** characters are keyed by both `id` (stable string like `"1"`) and `name` (display string like `"Elena"`). The canonical conventions are:
- God Mode API endpoints always take `character_id` (the `id` field) in request bodies.
- Internal handlers resolve `character_id → name` once and use `name` downstream for alive/dead filtering and action-log matching (since `actions.jsonl` stores `agent_name`, not id).
- Location `id` (`"iron_tower"`) is stored on characters as `character.location`. The translator resolves `id → location.name` once when building the prompt — characters in the prose prompt read as "Elena (at The Iron Tower, feeling: anger=0.3, trust=0.1)".
Current prompt has substitution fields: `{tone}`, `{characters}`, `{actions}`, `{previous}`.
Add 3 new fields: `{world_rules}`, `{world_events}`, `{world_locations}`.
**Prompt injection safety:** user-supplied strings (rules, event descriptions, location names/descriptions) are free-text and may contain `{` or `}`. Before rendering with `str.format()`, these strings are escaped: `{``{{`, `}``}}`. This prevents `KeyError` from stray braces in author content.
**New prompt sections inserted before "Events this round":**
```
World grounding:
Rules: {world_rules}
Recent events: {world_events}
Known locations: {world_locations}
```
- `{world_rules}` is a bulleted list joined with `; `
- `{world_events}` is the last 3 events from `event_log`, each as `"(Round N) description"`
- `{world_locations}` is a `name — description` list, capped at 5
The prompt also gets one new instruction in the CINEMATIC DETAIL section:
```
- If a character has a known location, root the scene there.
- If a recent world event is listed, weave it in OR acknowledge its aftermath — do not ignore it.
```
### 5.1 Filtering dead characters
In `translate_round`, before building `characters` and `actions` lists:
```python
alive = {c["name"] for c in characters if c.get("status", "alive") != "dead"}
characters = [c for c in characters if c["name"] in alive]
actions = [a for a in actions if a.get("agent_name") in alive]
```
Dead characters are invisible to the LLM — no prose is generated about them unless the event log contains their death (which is auto-populated by `kill_character` — see §4.3). The alive-filter applies only to the roster and action list passed into the prompt; there is no separate "observing" inference in the committed code today, so no other code paths need adjustment.
**Backward compatibility:** `create_initial_character()` is updated to set `status: "alive"` on every new character. Existing saved characters without a `status` field are treated as alive via the `c.get("status", "alive")` default.
---
## 6. Frontend
### 6.1 GodModeView.vue
Three card-style forms, visually distinct:
```
┌──────────────────────────────────┐
│ ⚡ Inject World Event │
│ [textarea: event description] │
│ [input: round (optional)] │
│ [Inject button] │
│ Last 3 events shown below │
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ 💭 Modify Character Emotions │
│ [select: character dropdown] │
│ [6 sliders: anger/fear/joy/...] │
│ [Apply button] │
└──────────────────────────────────┘
┌──────────────────────────────────┐
│ ☠ Kill Character │
│ [select: character dropdown] │
│ [Confirm checkbox] [Kill button]│
│ Warning: removes from future │
│ story. Cannot be undone in v1. │
└──────────────────────────────────┘
```
Uses existing `CharacterCard.vue` for selection preview.
### 6.2 WorldBuilderView.vue
Three sections:
1. **Rules** — editable textarea, one rule per line, Save button
2. **Locations** — list of existing locations + "Add location" form
3. **Event log** — read-only scrollable list (for now; events are added only via God Mode)
### 6.3 Routes
Added to `frontend/src/router/index.js`:
```javascript
{ path: '/godmode/:simulationId', name: 'GodMode', component: GodModeView, props: true },
{ path: '/world/:simulationId', name: 'World', component: WorldBuilderView, props: true }
```
### 6.4 Navigation
A small cross-view nav is added to **all three views** (`StoryTimelineView`, `GodModeView`, `WorldBuilderView`) as a shared header strip — the links show the current route as active:
```
Story | God Mode | World
```
Implementation: extracted into a small `<SimNav :simId="simId" />` component (not a separate file unless it grows — inline in each view is fine for v1) to avoid app-level layout changes.
### 6.5 Kill confirmation UX
The spec upgrades the Kill Character form beyond a checkbox: the user must **type the character's name** to enable the Kill button. This matches the GitHub-style pattern for irreversible destructive actions and eliminates misclicks. The input is case-insensitive and whitespace-trimmed.
---
## 7. Error handling
- **Missing character**: handler raises `ValueError("character not found")`; API returns 404 with `{"error": "character not found"}`
- **Missing simulation**: API returns 404 (reuses existing pattern from Task 7)
- **Invalid emotion name**: silently ignored (extra emotion keys don't break anything; they just don't affect state)
- **Inject event before translation starts**: event is stored with `round: 0` and included in the first beat's prompt
- **Invalid round number on inject_event**: API validates `round` is a non-negative integer (or null); otherwise returns 400
---
## 8. Testing
**Unit tests** (`tests/test_world_state.py`):
- `test_world_state_empty_on_fresh_sim`
- `test_add_location_persists`
- `test_set_rules_replaces_previous`
- `test_append_event_auto_ids`
**Unit tests** (`tests/test_god_mode.py`):
- `test_inject_event_appends_to_log`
- `test_modify_emotion_clamps_and_preserves_others`
- `test_kill_character_sets_status`
- `test_kill_character_not_found_raises`
**Integration** (extend `test_narrative_e2e.py`):
- Inject an event between round 1 and round 2, verify the event appears in the round-2 prompt context (assert via `mock_llm.call_args[0][0]`)
- Kill a character between rounds, verify their actions are skipped in the next translation
- Set world rules + locations, verify they appear in the prompt
**Prompt-inclusion unit test** (in `test_narrative_translator.py`):
- Patch `call_llm`, call `translate_round` with a populated world_state, assert the captured prompt contains each rule, each event description, and each location name. This prevents silent regressions in §5 formatting.
---
## 9. User contribution points
Same learning-mode pattern as before — two places the author's creative judgment matters more than an LLM's guess.
### 9.1 Event injection prompt enforcement
When God Mode injects an event, the prose prompt will include it in "Recent events." The prompt currently says "weave it in OR acknowledge its aftermath — do not ignore it." User decides **how strong** this instruction should be:
- **Soft**: "consider referencing the event if it fits"
- **Medium**: current default — "weave it in OR acknowledge its aftermath"
- **Hard**: "the opening line of this passage MUST reference the most recent world event"
Marked as `EVENT_ENFORCEMENT_STRENGTH` module-level constant in `narrative_translator.py`. Note: this is a **deployment-global** setting in v1, not per-simulation. Per-sim overrides are a v2 concern (would move this to `world_state.json`).
### 9.2 Location schema
Whether locations need fields beyond `name + description`. Options:
- **Minimal** (default): just `name + description`
- **Cinematic**: add `atmosphere` (a mood phrase like "oppressive silence, dust motes in shafts of light")
- **Temporal**: add `time_of_day` ("dusk", "midnight")
- **Full**: all of the above
Marked clearly in `world_state.py` as scaffolded default, with a TODO comment pointing to user contribution.
---
## 10. Non-goals
- Mid-simulation OASIS prompt injection (deferred to v2)
- Resurrection, time skip, forced action
- Faction system, resource system
- Location transitions (characters moving between locations during a round) — v1 locations are static per-character
- Bulk intervention (applying to multiple characters at once)
- Undo / history — God Mode actions are one-way in v1
- **No delete endpoints** for locations, rules (they're replaced in bulk via the POST endpoint), or events. An author who mis-types must manually edit `world_state.json`.
- **Concurrent write safety** — authors should intervene between rounds, not during (see §4.4). No file locking in v1.
- **Event log rotation**`event_log` grows unbounded. Fine at expected v1 scale (tens of events per sim); pruning can be added in v2.
- **Per-simulation event enforcement strength**`EVENT_ENFORCEMENT_STRENGTH` is deployment-global in v1 (§9.1).

View File

@ -1435,7 +1435,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -1913,7 +1912,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2053,7 +2051,6 @@
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -2128,7 +2125,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",

View File

@ -0,0 +1,93 @@
import service, { requestWithRetry } from './index'
/**
* Get the full generated story for a simulation.
* @param {string} simId
*/
export const getFullStory = (simId) => {
return service.get(`/api/narrative/story/${simId}`)
}
/**
* Get a single round's story beat.
* @param {string} simId
* @param {number} roundNum
*/
export const getRoundStory = (simId, roundNum) => {
return service.get(`/api/narrative/story/${simId}/round/${roundNum}`)
}
/**
* Translate a round on demand generates prose via LLM and stores the beat.
* @param {Object} data - { sim_id, round, platform?, tone? }
*/
export const translateRound = (data) => {
return requestWithRetry(() => service.post('/api/narrative/translate', data), 3, 2000)
}
/**
* Get extended character roster with emotional state.
* @param {string} simId
*/
export const getCharacters = (simId) => {
return service.get(`/api/narrative/characters/${simId}`)
}
/**
* Bootstrap narrative character profiles from existing OASIS profiles.
* @param {string} simId
*/
export const initCharacters = (simId) => {
return requestWithRetry(() => service.post(`/api/narrative/characters/${simId}/init`), 3, 1000)
}
// ---- World State ----
/**
* Get the full world state (rules, locations, event log).
*/
export const getWorld = (simId) =>
service.get(`/api/narrative/world/${simId}`)
/**
* Replace world rules with the given array of strings.
*/
export const setWorldRules = (simId, rules) =>
service.post(`/api/narrative/world/${simId}/rules`, { rules })
/**
* Insert or update a location.
* @param {Object} location - { id, name, description }
*/
export const upsertLocation = (simId, location) =>
service.post(`/api/narrative/world/${simId}/locations`, location)
// ---- God Mode ----
/**
* Inject a world event. The next translate_round will surface it to the LLM.
* @param {string} description
* @param {number|null} round - optional; defaults to (last beat round + 1)
*/
export const injectEvent = (simId, description, round = null) =>
requestWithRetry(
() => service.post(`/api/narrative/godmode/${simId}/inject-event`, { description, round }),
3, 2000
)
/**
* Overwrite specified emotion values for a character.
* @param {string} characterId
* @param {Object} emotions - { anger: 0.8, joy: 0.1, ... }
*/
export const modifyEmotion = (simId, characterId, emotions) =>
service.post(`/api/narrative/godmode/${simId}/modify-emotion`, {
character_id: characterId,
emotions,
})
/**
* Mark a character as dead. Auto-appends a death event to the world log.
*/
export const killCharacter = (simId, characterId) =>
service.post(`/api/narrative/godmode/${simId}/kill`, { character_id: characterId })

View File

@ -0,0 +1,91 @@
<template>
<div class="character-card" :class="{ 'has-action': hasRecentAction }">
<div class="name">{{ character.name }}</div>
<div class="emotions">
<span v-for="(val, emo) in topEmotions" :key="emo" class="emotion">
<span class="emo-label">{{ emo }}</span>
<span class="emo-bar">
<span class="emo-bar-fill" :style="{ width: (val * 100) + '%' }"></span>
</span>
<span class="emo-val">{{ val.toFixed(2) }}</span>
</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
character: { type: Object, required: true },
hasRecentAction: { type: Boolean, default: false },
})
// Surface the top 3 emotions by magnitude; hide anything at exactly 0
const topEmotions = computed(() => {
const current = props.character.emotional_state?.current || {}
const sorted = Object.entries(current)
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
return Object.fromEntries(sorted)
})
</script>
<style scoped>
.character-card {
display: inline-block;
padding: 0.75rem 1rem;
margin: 0.3rem;
background: #2a2416;
color: #faf7f0;
border-radius: 6px;
font-size: 0.85rem;
min-width: 180px;
vertical-align: top;
border: 1px solid transparent;
transition: border-color 120ms ease;
}
.character-card.has-action {
border-color: #c9a45b;
}
.name {
font-weight: 600;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.emotions {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-family: 'SF Mono', Menlo, monospace;
font-size: 0.7rem;
opacity: 0.92;
}
.emotion {
display: grid;
grid-template-columns: 55px 1fr 35px;
gap: 0.5rem;
align-items: center;
}
.emo-label {
text-transform: uppercase;
letter-spacing: 0.05em;
color: #c9a45b;
}
.emo-bar {
height: 4px;
background: #3d3520;
border-radius: 2px;
overflow: hidden;
}
.emo-bar-fill {
display: block;
height: 100%;
background: #c9a45b;
}
.emo-val {
text-align: right;
color: #e5ddc4;
}
</style>

View File

@ -0,0 +1,72 @@
<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>
<span v-if="beat.platform" class="platform-tag">{{ beat.platform }}</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 1.75rem;
border-left: 3px solid #c9a45b;
background: #faf7f0;
border-radius: 0 4px 4px 0;
}
.beat-header {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 0.75rem;
font-size: 0.8rem;
color: #7d6b3f;
flex-wrap: wrap;
}
.round-badge {
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.characters {
font-style: italic;
}
.platform-tag {
margin-left: auto;
padding: 0.15rem 0.5rem;
background: #eadfb8;
border-radius: 999px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.prose p {
line-height: 1.75;
margin: 0 0 1rem;
font-family: Georgia, 'Times New Roman', serif;
color: #2a2416;
font-size: 1.05rem;
}
.prose p:last-child {
margin-bottom: 0;
}
</style>

View File

@ -5,6 +5,9 @@ import SimulationView from '../views/SimulationView.vue'
import SimulationRunView from '../views/SimulationRunView.vue'
import ReportView from '../views/ReportView.vue'
import InteractionView from '../views/InteractionView.vue'
import StoryTimelineView from '../views/StoryTimelineView.vue'
import GodModeView from '../views/GodModeView.vue'
import WorldBuilderView from '../views/WorldBuilderView.vue'
const routes = [
{
@ -41,6 +44,24 @@ const routes = [
name: 'Interaction',
component: InteractionView,
props: true
},
{
path: '/story/:simulationId',
name: 'Story',
component: StoryTimelineView,
props: true
},
{
path: '/godmode/:simulationId',
name: 'GodMode',
component: GodModeView,
props: true
},
{
path: '/world/:simulationId',
name: 'World',
component: WorldBuilderView,
props: true
}
]

View File

@ -0,0 +1,225 @@
<template>
<div class="godmode">
<nav class="sim-nav">
<router-link :to="`/story/${simId}`">Story</router-link>
<router-link :to="`/godmode/${simId}`" class="active">God Mode</router-link>
<router-link :to="`/world/${simId}`">World</router-link>
</nav>
<h1>God Mode</h1>
<p class="subtitle">Author-controlled interventions. Changes take effect on the next translated round.</p>
<section class="card inject">
<h2> Inject World Event</h2>
<p class="hint">A new world event the narrator will weave into the next scene. (Current enforcement: <strong>hard</strong> opening line MUST reference it.)</p>
<textarea v-model="eventDesc" rows="3"
placeholder="A stranger arrives at the market, carrying a sealed letter."></textarea>
<div class="row">
<input v-model.number="eventRound" type="number" min="0"
placeholder="Round (optional — defaults to next round)" />
<button @click="doInject" :disabled="busy || !eventDesc">Inject</button>
</div>
</section>
<section class="card emotion">
<h2>💭 Modify Character Emotions</h2>
<p class="hint">Overwrite any emotion directly. Unchanged emotions keep their current values.</p>
<select v-model="emoCharId" @change="onEmoCharChange">
<option value="">Select character</option>
<option v-for="c in aliveChars" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<div v-if="emoCharId" class="sliders">
<div v-for="emo in emotions" :key="emo" class="slider-row">
<label>{{ emo }}</label>
<input type="range" min="0" max="1" step="0.05" v-model.number="emoValues[emo]" />
<span>{{ emoValues[emo].toFixed(2) }}</span>
</div>
<button @click="doModifyEmotion" :disabled="busy">Apply</button>
</div>
</section>
<section class="card kill">
<h2> Kill Character</h2>
<p class="warning">
Irreversible in v1. Auto-appends a death event to the world log so
the narrator knows they're gone.
</p>
<select v-model="killCharId" @change="killConfirm = ''">
<option value="">Select character</option>
<option v-for="c in aliveChars" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<input v-if="killCharId" v-model="killConfirm"
:placeholder="`Type '${selectedKillName}' to confirm`" />
<button @click="doKill" :disabled="busy || !canKill" class="danger">
Kill {{ selectedKillName }}
</button>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getCharacters, injectEvent, modifyEmotion, killCharacter } from '../api/narrative'
const route = useRoute()
const simId = route.params.simulationId
const emotions = ['anger', 'fear', 'joy', 'sadness', 'trust', 'surprise']
const characters = ref([])
const busy = ref(false)
const error = ref('')
const success = ref('')
const eventDesc = ref('')
const eventRound = ref(null)
const emoCharId = ref('')
const emoValues = ref(Object.fromEntries(emotions.map(e => [e, 0])))
const killCharId = ref('')
const killConfirm = ref('')
const aliveChars = computed(() =>
characters.value.filter(c => (c.status || 'alive') !== 'dead')
)
const selectedKillName = computed(() => {
const c = characters.value.find(c => c.id === killCharId.value)
return c?.name || ''
})
// Typed-name confirmation case-insensitive, whitespace-trimmed
const canKill = computed(() =>
killCharId.value &&
selectedKillName.value &&
killConfirm.value.trim().toLowerCase() === selectedKillName.value.toLowerCase()
)
async function loadCharacters() {
try {
const res = await getCharacters(simId)
characters.value = res.characters || []
} catch (e) { /* non-fatal */ }
}
// When the user selects a character for emotion editing, preload current values
function onEmoCharChange() {
const c = characters.value.find(c => c.id === emoCharId.value)
if (c) {
const current = c.emotional_state?.current || {}
emoValues.value = Object.fromEntries(emotions.map(e => [e, current[e] ?? 0]))
}
}
function flash(msg) {
success.value = msg
setTimeout(() => { success.value = '' }, 2500)
}
async function doInject() {
busy.value = true
error.value = ''
try {
await injectEvent(simId, eventDesc.value, eventRound.value || null)
flash('Event injected — appears in the next translated round.')
eventDesc.value = ''
eventRound.value = null
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
async function doModifyEmotion() {
busy.value = true
error.value = ''
try {
await modifyEmotion(simId, emoCharId.value, emoValues.value)
flash('Emotions updated.')
await loadCharacters()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
async function doKill() {
busy.value = true
error.value = ''
try {
const name = selectedKillName.value
await killCharacter(simId, killCharId.value)
flash(`${name} has been killed.`)
killCharId.value = ''
killConfirm.value = ''
await loadCharacters()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
onMounted(loadCharacters)
</script>
<style scoped>
.godmode {
max-width: 840px;
margin: 0 auto;
padding: 2rem 1.5rem 6rem;
}
.sim-nav {
display: flex; gap: 1.25rem;
padding: 0.75rem 0; margin-bottom: 1.5rem;
border-bottom: 1px solid #e5ddc4; font-size: 0.9rem;
}
.sim-nav a { color: #7d6b3f; text-decoration: none; font-weight: 500; }
.sim-nav a.active { color: #c9a45b; border-bottom: 2px solid #c9a45b; padding-bottom: 0.15rem; }
h1 {
font-family: Georgia, serif; color: #2a2416;
margin: 0 0 0.5rem; font-size: 1.8rem;
}
.subtitle {
color: #7d6b3f; font-size: 0.9rem; margin: 0 0 1.5rem;
}
.card {
background: #faf7f0; border: 1px solid #e5ddc4; border-radius: 6px;
padding: 1.25rem 1.5rem; margin-bottom: 1.5rem;
}
.card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; color: #2a2416; }
.hint { color: #7d6b3f; font-size: 0.85rem; margin: 0 0 0.75rem; }
.warning {
color: #8b0000; font-size: 0.85rem; margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem; background: #ffe5e5; border-radius: 4px;
}
textarea, input, select {
width: 100%; padding: 0.5rem 0.65rem; border: 1px solid #d4c893;
border-radius: 4px; font-size: 0.9rem; background: white; margin-bottom: 0.5rem;
font-family: inherit; box-sizing: border-box;
}
.row { display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; }
button {
padding: 0.5rem 1rem; background: #c9a45b; color: white;
border: none; border-radius: 4px; cursor: pointer; font-weight: 500;
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.danger { background: #8b0000; }
button.danger:disabled { background: #8b0000; opacity: 0.3; }
.sliders { margin-top: 0.75rem; }
.slider-row {
display: grid; grid-template-columns: 80px 1fr 50px;
gap: 0.5rem; align-items: center; font-size: 0.85rem;
margin-bottom: 0.35rem;
}
.slider-row label { text-transform: uppercase; letter-spacing: 0.05em; color: #7d6b3f; font-size: 0.75rem; }
.error { background: #ffe5e5; color: #8b0000; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
.success { background: #e5f7e5; color: #2a6b2a; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
</style>

View File

@ -0,0 +1,266 @@
<template>
<div class="story-timeline">
<nav class="sim-nav">
<router-link :to="`/story/${simId}`" active-class="active">Story</router-link>
<router-link :to="`/godmode/${simId}`" active-class="active">God Mode</router-link>
<router-link :to="`/world/${simId}`" active-class="active">World</router-link>
</nav>
<header class="page-header">
<div class="header-left">
<h1>Story Timeline</h1>
<p class="sim-id">Simulation: <code>{{ simId }}</code></p>
</div>
<div class="controls">
<label class="tone-field">
Tone
<input v-model="tone" placeholder="e.g. dark political thriller" />
</label>
<button @click="initChars" :disabled="busy" class="secondary">
Init Characters
</button>
<button @click="refresh" :disabled="busy" class="secondary">
{{ loading ? 'Loading…' : 'Refresh' }}
</button>
<button @click="translateNext" :disabled="busy" class="primary">
{{ translating ? 'Generating…' : 'Translate Next Round' }}
</button>
</div>
</header>
<div v-if="error" class="error">{{ error }}</div>
<section v-if="characters.length" class="character-roster">
<h2>Characters</h2>
<div class="cards">
<CharacterCard
v-for="c in characters"
:key="c.id"
:character="c"
:has-recent-action="recentCharacters.has(c.name)"
/>
</div>
</section>
<div v-if="beats.length === 0 && !loading" class="empty">
No story yet. Initialize characters, then translate rounds to generate the narrative.
</div>
<StoryBeat v-for="beat in beats" :key="beat.round" :beat="beat" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {
getFullStory,
translateRound,
getCharacters,
initCharacters,
} from '../api/narrative'
import StoryBeat from '../components/StoryBeat.vue'
import CharacterCard from '../components/CharacterCard.vue'
const route = useRoute()
const simId = route.params.simulationId
const beats = ref([])
const characters = ref([])
const loading = ref(false)
const translating = ref(false)
const initting = ref(false)
const error = ref('')
const tone = ref('dark political thriller')
const busy = computed(() => loading.value || translating.value || initting.value)
// Characters that acted in the most recent beat highlighted in the roster
const recentCharacters = computed(() => {
const last = beats.value[beats.value.length - 1]
return new Set(last?.characters || [])
})
async function refresh() {
loading.value = true
error.value = ''
try {
const res = await getFullStory(simId)
beats.value = res.beats || []
await loadCharacters()
} catch (e) {
error.value = e?.response?.data?.error || e.message || 'Failed to load story'
} finally {
loading.value = false
}
}
async function loadCharacters() {
try {
const res = await getCharacters(simId)
characters.value = res.characters || []
} catch (e) {
// non-fatal characters may not be initialized yet
}
}
async function initChars() {
initting.value = true
error.value = ''
try {
await initCharacters(simId)
await loadCharacters()
} catch (e) {
error.value = e?.response?.data?.error || e.message || 'Failed to initialize characters'
} finally {
initting.value = false
}
}
async function translateNext() {
translating.value = true
error.value = ''
try {
const nextRound = beats.value.length + 1
await translateRound({ sim_id: simId, round: nextRound, tone: tone.value })
await refresh()
} catch (e) {
error.value = e?.response?.data?.error || e.message || 'Translation failed'
} finally {
translating.value = false
}
}
onMounted(refresh)
</script>
<style scoped>
.story-timeline {
max-width: 840px;
margin: 0 auto;
padding: 2rem 1.5rem 6rem;
}
.sim-nav {
display: flex;
gap: 1.25rem;
padding: 0.75rem 0;
margin-bottom: 1rem;
border-bottom: 1px solid #e5ddc4;
font-size: 0.9rem;
}
.sim-nav a {
color: #7d6b3f;
text-decoration: none;
font-weight: 500;
}
.sim-nav a.active {
color: #c9a45b;
border-bottom: 2px solid #c9a45b;
padding-bottom: 0.15rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 2rem;
margin-bottom: 2rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #e5ddc4;
flex-wrap: wrap;
}
.header-left h1 {
margin: 0 0 0.25rem;
font-family: Georgia, serif;
color: #2a2416;
font-size: 1.8rem;
}
.sim-id {
margin: 0;
font-size: 0.85rem;
color: #7d6b3f;
}
.sim-id code {
background: #eadfb8;
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.controls {
display: flex;
gap: 0.5rem;
align-items: flex-end;
flex-wrap: wrap;
}
.tone-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.75rem;
color: #7d6b3f;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tone-field input {
padding: 0.45rem 0.6rem;
border: 1px solid #d4c893;
border-radius: 4px;
font-size: 0.9rem;
min-width: 220px;
background: #faf7f0;
}
button {
padding: 0.55rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
button.primary {
background: #c9a45b;
color: white;
}
button.secondary {
background: transparent;
color: #7d6b3f;
border: 1px solid #d4c893;
}
button:disabled {
opacity: 0.5;
cursor: wait;
}
.error {
background: #ffe5e5;
color: #8b0000;
padding: 0.85rem 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.character-roster {
margin-bottom: 2.5rem;
padding: 1.25rem;
background: #f5efd9;
border-radius: 6px;
}
.character-roster h2 {
margin: 0 0 0.75rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #7d6b3f;
font-weight: 600;
}
.cards {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.empty {
text-align: center;
color: #999;
padding: 3rem 1rem;
font-style: italic;
border: 1px dashed #d4c893;
border-radius: 6px;
}
</style>

View File

@ -0,0 +1,235 @@
<template>
<div class="world-builder">
<nav class="sim-nav">
<router-link :to="`/story/${simId}`">Story</router-link>
<router-link :to="`/godmode/${simId}`">God Mode</router-link>
<router-link :to="`/world/${simId}`" class="active">World</router-link>
</nav>
<h1>World Builder</h1>
<p class="subtitle">Ground your story. Rules shape the world; locations shape the scenes.</p>
<section class="card">
<h2>World Rules</h2>
<p class="hint">One rule per line. These appear in every translation prompt as background context.</p>
<textarea v-model="rulesText" rows="6" placeholder="Magic is forbidden
Winter is near
The kingdom is divided"></textarea>
<button @click="saveRules" :disabled="busy">Save Rules</button>
</section>
<section class="card">
<h2>Locations</h2>
<p class="hint">
Each location's <strong>atmosphere</strong> is a short mood phrase it anchors
the opening visual of every scene set there.
</p>
<div v-if="locations.length" class="location-list">
<div v-for="loc in locations" :key="loc.id" class="location-item">
<div class="loc-header">
<strong>{{ loc.name }}</strong>
<span class="loc-id">{{ loc.id }}</span>
</div>
<p v-if="loc.description" class="loc-desc">{{ loc.description }}</p>
<p v-if="loc.atmosphere" class="loc-atmosphere">"{{ loc.atmosphere }}"</p>
</div>
</div>
<p v-else class="muted">No locations yet.</p>
<form @submit.prevent="addLocation" class="location-form">
<div class="form-row">
<input v-model="newLoc.id" placeholder="id (e.g. iron_tower)" required />
<input v-model="newLoc.name" placeholder="Name (The Iron Tower)" required />
</div>
<input v-model="newLoc.description" placeholder="Description (what it looks like)" />
<input v-model="newLoc.atmosphere"
placeholder="Atmosphere — short mood phrase (e.g. oppressive silence, dust in shafts of cold light)" />
<button type="submit" :disabled="busy">Add / Update Location</button>
</form>
</section>
<section class="card">
<h2>Event Log</h2>
<p class="hint">World events, newest first. Populated by God Mode interventions.</p>
<ol v-if="events.length" class="event-log">
<li v-for="e in events" :key="e.id" :class="`evt-${e.type}`">
<span class="event-round">Round {{ e.round }}</span>
<span class="event-type">{{ e.type }}</span>
<span class="event-desc">{{ e.description }}</span>
</li>
</ol>
<p v-else class="muted">No events yet.</p>
</section>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="success" class="success">{{ success }}</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getWorld, setWorldRules, upsertLocation } from '../api/narrative'
const route = useRoute()
const simId = route.params.simulationId
const world = ref({ rules: [], locations: {}, event_log: [] })
const rulesText = ref('')
const newLoc = ref({ id: '', name: '', description: '', atmosphere: '' })
const busy = ref(false)
const error = ref('')
const success = ref('')
const locations = computed(() => Object.values(world.value.locations || {}))
// Event log: show newest first for readability
const events = computed(() => (world.value.event_log || []).slice().reverse())
async function load() {
try {
const res = await getWorld(simId)
world.value = res
rulesText.value = (res.rules || []).join('\n')
} catch (e) {
error.value = e?.response?.data?.error || e.message
}
}
function flash(msg) {
success.value = msg
setTimeout(() => { success.value = '' }, 2500)
}
async function saveRules() {
busy.value = true
error.value = ''
try {
const rules = rulesText.value.split('\n').map(r => r.trim()).filter(Boolean)
await setWorldRules(simId, rules)
flash('Rules saved.')
await load()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
async function addLocation() {
busy.value = true
error.value = ''
try {
// Only send non-empty optional fields so backend stores a clean record
const payload = { id: newLoc.value.id, name: newLoc.value.name }
if (newLoc.value.description) payload.description = newLoc.value.description
if (newLoc.value.atmosphere) payload.atmosphere = newLoc.value.atmosphere
await upsertLocation(simId, payload)
flash(`Location ${newLoc.value.name} saved.`)
newLoc.value = { id: '', name: '', description: '', atmosphere: '' }
await load()
} catch (e) {
error.value = e?.response?.data?.error || e.message
} finally {
busy.value = false
}
}
onMounted(load)
</script>
<style scoped>
.world-builder {
max-width: 840px;
margin: 0 auto;
padding: 2rem 1.5rem 6rem;
}
.sim-nav {
display: flex; gap: 1.25rem;
padding: 0.75rem 0; margin-bottom: 1.5rem;
border-bottom: 1px solid #e5ddc4; font-size: 0.9rem;
}
.sim-nav a { color: #7d6b3f; text-decoration: none; font-weight: 500; }
.sim-nav a.active { color: #c9a45b; border-bottom: 2px solid #c9a45b; padding-bottom: 0.15rem; }
h1 {
font-family: Georgia, serif; color: #2a2416;
margin: 0 0 0.5rem; font-size: 1.8rem;
}
.subtitle {
color: #7d6b3f; font-size: 0.9rem; margin: 0 0 1.5rem;
}
.card {
background: #faf7f0; border: 1px solid #e5ddc4; border-radius: 6px;
padding: 1.25rem 1.5rem; margin-bottom: 1.5rem;
}
.card h2 { margin: 0 0 0.5rem; font-size: 1.1rem; color: #2a2416; }
.hint, .muted {
color: #7d6b3f; font-size: 0.85rem; margin: 0 0 0.75rem;
}
.muted { font-style: italic; }
textarea, input {
width: 100%; padding: 0.5rem 0.65rem; border: 1px solid #d4c893;
border-radius: 4px; font-size: 0.9rem; background: white; margin-bottom: 0.5rem;
font-family: inherit; box-sizing: border-box;
}
textarea { resize: vertical; font-family: Georgia, serif; }
button {
padding: 0.5rem 1rem; background: #c9a45b; color: white;
border: none; border-radius: 4px; cursor: pointer; font-weight: 500;
}
button:disabled { opacity: 0.5; cursor: wait; }
.location-list {
margin-bottom: 1rem;
border: 1px solid #e5ddc4;
border-radius: 4px;
overflow: hidden;
}
.location-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5ddc4;
background: white;
}
.location-item:last-child { border-bottom: none; }
.loc-header {
display: flex; justify-content: space-between; align-items: baseline;
}
.loc-header strong { color: #2a2416; font-size: 0.95rem; }
.loc-id {
font-family: 'SF Mono', Menlo, monospace; font-size: 0.75rem;
color: #7d6b3f; background: #f5efd9; padding: 0.1rem 0.4rem; border-radius: 3px;
}
.loc-desc { margin: 0.35rem 0 0; color: #5a4f2f; font-size: 0.88rem; }
.loc-atmosphere {
margin: 0.35rem 0 0; color: #8b7a40; font-size: 0.85rem;
font-style: italic; font-family: Georgia, serif;
}
.location-form {
margin-top: 0.75rem; padding-top: 0.75rem;
border-top: 1px dashed #d4c893;
}
.form-row {
display: grid; grid-template-columns: 1fr 2fr; gap: 0.5rem;
}
.event-log { list-style: none; padding: 0; margin: 0; }
.event-log li {
display: grid;
grid-template-columns: 80px 160px 1fr;
gap: 0.75rem;
padding: 0.55rem 0;
border-bottom: 1px dashed #e5ddc4;
font-size: 0.88rem;
align-items: baseline;
}
.event-log li:last-child { border-bottom: none; }
.event-round { color: #c9a45b; font-weight: 600; }
.event-type {
font-family: 'SF Mono', Menlo, monospace;
font-size: 0.72rem; color: #7d6b3f;
text-transform: uppercase; letter-spacing: 0.03em;
}
.evt-god_mode_death .event-type { color: #8b0000; }
.evt-god_mode_injection .event-type { color: #a0571a; }
.error { background: #ffe5e5; color: #8b0000; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
.success { background: #e5f7e5; color: #2a6b2a; padding: 0.85rem 1rem; border-radius: 4px; margin-top: 1rem; }
</style>