Merge 484689735f into 96096ea0ff
This commit is contained in:
commit
8382f1ed7a
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Narrative Layer — translates OASIS simulation output into story prose."""
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
|
@ -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 (10–50 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.0–1.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
|
||||
|
|
@ -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 5–6)
|
||||
**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, 10–50 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).
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue