diff --git a/docs/superpowers/plans/2026-04-20-godmode-worldstate.md b/docs/superpowers/plans/2026-04-20-godmode-worldstate.md new file mode 100644 index 00000000..cf9398c8 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-godmode-worldstate.md @@ -0,0 +1,1648 @@ +# God Mode + World State v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship 3 God Mode interventions (inject event, modify emotion, kill character) + 3 World State primitives (locations, rules, event log) layered on top of the existing Narrative Layer. + +**Architecture:** New `world_state.py` (CRUD) and `god_mode.py` (handlers) services. Extend `narrative_translator.py` prompt with 3 new fields. Extend `translate_round` to load world state and filter dead characters. 6 new API endpoints. 2 new Vue views with shared nav. + +**Tech Stack:** Python 3.12, Flask, Vue 3 + Vite, existing LLM client. Builds on committed feat/narrative-layer branch. + +--- + +## File Structure + +### New files + +| File | Responsibility | +|---|---| +| `backend/app/services/narrative/world_state.py` | WorldStateStore class + CRUD helpers for world_state.json | +| `backend/app/services/narrative/god_mode.py` | inject_event(), modify_emotion(), kill_character() handlers | +| `backend/tests/test_world_state.py` | Unit tests for world CRUD | +| `backend/tests/test_god_mode.py` | Unit tests for interventions | +| `frontend/src/views/GodModeView.vue` | 3 action forms | +| `frontend/src/views/WorldBuilderView.vue` | Rules + locations + event log UI | + +### Modified files + +| File | Change | +|---|---| +| `backend/app/services/narrative/character_engine.py` | `create_initial_character()` sets `status: "alive"` | +| `backend/app/services/narrative/narrative_translator.py` | Prompt adds 3 world fields, `translate_round` loads world, filters dead chars, resolves locations, brace-escapes user text | +| `backend/app/api/narrative.py` | +6 endpoints | +| `backend/tests/test_narrative_translator.py` | +prompt inclusion test | +| `backend/tests/test_narrative_e2e.py` | +event injection + kill integration scenarios | +| `frontend/src/api/narrative.js` | +6 named exports | +| `frontend/src/router/index.js` | +2 routes | +| `frontend/src/views/StoryTimelineView.vue` | Add shared SimNav strip | + +--- + +## Task 1: WorldStateStore — CRUD for world_state.json + +**Files:** +- Create: `backend/app/services/narrative/world_state.py` +- Create: `backend/tests/test_world_state.py` + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_world_state.py`: + +```python +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" + + # Update same id + 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" +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd backend && uv run pytest tests/test_world_state.py -v +``` +Expected: ImportError — module not created yet. + +- [ ] **Step 3: Implement world_state.py** + +Create `backend/app/services/narrative/world_state.py`: + +```python +"""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 +from typing import Optional + + +EMPTY_WORLD = {"rules": [], "locations": {}, "event_log": []} + + +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 dict(EMPTY_WORLD, 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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/test_world_state.py -v +``` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/world_state.py backend/tests/test_world_state.py +git commit -m "feat(narrative): add WorldStateStore for rules, locations, and event log" +``` + +--- + +## Task 2: CharacterEngine — set `status: alive` on new characters + +**Files:** +- Modify: `backend/app/services/narrative/character_engine.py` +- Modify: `backend/tests/test_character_engine.py` + +- [ ] **Step 1: Write failing test** + +Append to `backend/tests/test_character_engine.py`: + +```python +def test_create_initial_character_sets_status_alive(): + char = create_initial_character(char_id="x", name="X") + assert char["status"] == "alive" +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd backend && uv run pytest tests/test_character_engine.py::test_create_initial_character_sets_status_alive -v +``` +Expected: FAIL — `status` key missing. + +- [ ] **Step 3: Add status field** + +Modify `backend/app/services/narrative/character_engine.py` in `create_initial_character`: + +```python + return { + "id": char_id, + "name": name, + "backstory": backstory, + "motivations": motivations or [], + "personality_traits": personality or [], + "status": "alive", # NEW + "emotional_state": { + "current": dict(INITIAL_EMOTIONAL_STATE), + "history": [], + }, + "relationships": {}, + "arc": {"archetype": None, "stage": "beginning", "key_moments": []}, + } +``` + +- [ ] **Step 4: Run full test suite to verify no regressions** + +```bash +cd backend && uv run pytest tests/ -v +``` +Expected: 21 passed (20 previous + 1 new). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/character_engine.py backend/tests/test_character_engine.py +git commit -m "feat(narrative): set status=alive on new characters" +``` + +--- + +## Task 3: God Mode — inject_event handler + +**Files:** +- Create: `backend/app/services/narrative/god_mode.py` +- Create: `backend/tests/test_god_mode.py` + +- [ ] **Step 1: Write failing tests** + +Create `backend/tests/test_god_mode.py`: + +```python +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 +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd backend && uv run pytest tests/test_god_mode.py -v +``` +Expected: ImportError. + +- [ ] **Step 3: Implement god_mode.py (inject_event only)** + +Create `backend/app/services/narrative/god_mode.py`: + +```python +"""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, + }) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/test_god_mode.py -v +``` +Expected: 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/god_mode.py backend/tests/test_god_mode.py +git commit -m "feat(narrative): add god_mode.inject_event handler" +``` + +--- + +## Task 4: God Mode — modify_emotion handler + +**Files:** +- Modify: `backend/app/services/narrative/god_mode.py` +- Modify: `backend/tests/test_god_mode.py` + +- [ ] **Step 1: Write failing tests** + +Append to `backend/tests/test_god_mode.py`: + +```python +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 + # Unspecified emotion preserved + 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"] +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd backend && uv run pytest tests/test_god_mode.py -v +``` +Expected: ImportError for `modify_emotion`. + +- [ ] **Step 3: Implement modify_emotion** + +Append to `backend/app/services/narrative/god_mode.py`: + +```python +def modify_emotion(sim_dir: str, character_id: str, emotions: dict) -> dict: + """Overwrite specified emotion values for a character. Clamps to [0,1].""" + 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) + + # Audit log + 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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/test_god_mode.py -v +``` +Expected: 7 passed (3 + 4 new). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/god_mode.py backend/tests/test_god_mode.py +git commit -m "feat(narrative): add god_mode.modify_emotion with audit logging" +``` + +--- + +## Task 5: God Mode — kill_character handler + +**Files:** +- Modify: `backend/app/services/narrative/god_mode.py` +- Modify: `backend/tests/test_god_mode.py` + +- [ ] **Step 1: Write failing tests** + +Append to `backend/tests/test_god_mode.py`: + +```python +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" + + # Persisted + 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") +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd backend && uv run pytest tests/test_god_mode.py -v +``` +Expected: ImportError for `kill_character`. + +- [ ] **Step 3: Implement kill_character** + +Append to `backend/app/services/narrative/god_mode.py`: + +```python +def kill_character(sim_dir: str, character_id: str) -> dict: + """Mark a character as dead and append a death event to the world log.""" + 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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && uv run pytest tests/test_god_mode.py -v +``` +Expected: 10 passed (7 + 3 new). + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/narrative/god_mode.py backend/tests/test_god_mode.py +git commit -m "feat(narrative): add god_mode.kill_character with auto-death event" +``` + +--- + +## Task 6: Translator — load world_state, filter dead, extend prompt + +**Files:** +- Modify: `backend/app/services/narrative/narrative_translator.py` +- Modify: `backend/tests/test_narrative_translator.py` + +**🎯 Contains USER CONTRIBUTION POINT #1 (event enforcement strength) — paused in Step 5.** + +- [ ] **Step 1: Write prompt-inclusion test** + +Append to `backend/tests/test_narrative_translator.py`: + +```python +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 +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +cd backend && uv run pytest tests/test_narrative_translator.py::test_generate_prose_includes_world_context -v +``` +Expected: FAIL — `generate_prose` doesn't accept `world` kwarg. + +- [ ] **Step 3: Add brace-escape helper + world field formatters** + +In `backend/app/services/narrative/narrative_translator.py`, add near the top (after imports): + +```python +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)" + return "\n ".join( + f"{_escape_braces(l.get('name', ''))} — {_escape_braces(l.get('description', ''))}" + for l in locs + ) +``` + +- [ ] **Step 4: Update `_format_character_summary` to show location** + +Replace the existing `_format_character_summary` with: + +```python +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}" +``` + +- [ ] **Step 5: 🎯 USER CONTRIBUTION — event enforcement strength** + +Replace `PROSE_PROMPT_TEMPLATE` with the extended version. The CINEMATIC DETAIL section gets a new line for events that will reference `EVENT_ENFORCEMENT_STRENGTH`. + +First, add near the top of the file: + +```python +# ============================================================================ +# USER CONTRIBUTION POINT — how strongly to force world events into prose +# ============================================================================ +# Three supported values; change this constant to tune enforcement: +# "soft" — "consider referencing the most recent world event if it fits" +# "medium" — "weave the most recent event in OR acknowledge its aftermath" +# "hard" — "the opening line MUST reference the most recent world event" +# ============================================================================ +EVENT_ENFORCEMENT_STRENGTH = "medium" + +_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.", +} +``` + +Then update `PROSE_PROMPT_TEMPLATE` to include world sections (keep all existing instructions, add the new world block and a new CINEMATIC rule): + +```python +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.""" +``` + +- [ ] **Step 6: Update `generate_prose` signature + call** + +Replace `generate_prose` with: + +```python +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) +``` + +- [ ] **Step 7: Update `translate_round` to load world + filter dead** + +Replace `translate_round` with: + +```python +def translate_round(sim_dir: str, platform: str, target_round: int, tone: str = "neutral") -> dict: + """Translate one round into a story beat end-to-end.""" + 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) + + characters = store.load_characters() + alive_names = {c["name"] for c in characters if c.get("status", "alive") != "dead"} + characters = [c for c in 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, characters, tone, previous_beats, world) + + involved = sorted({a.get("agent_name") for a in actions if a.get("agent_name")}) + + # Apply emotional deltas only to living characters + all_chars = store.load_characters() + all_by_name = {c["name"]: c for c in all_chars} + for action in actions: + char = all_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(all_by_name.values())) + + beat = { + "round": target_round, + "prose": prose, + "characters": involved, + "action_count": len(actions), + "platform": platform, + } + store.append_beat(beat) + store.set_file_offset(platform, new_offset) + return beat +``` + +- [ ] **Step 8: Run full test suite** + +```bash +cd backend && uv run pytest tests/ -v +``` +Expected: all tests pass, including the new prompt-inclusion test. + +- [ ] **Step 9: Commit** + +```bash +git add backend/app/services/narrative/narrative_translator.py backend/tests/test_narrative_translator.py +git commit -m "feat(narrative): extend translator with world context + dead filter" +``` + +--- + +## Task 7: E2E — event injection and kill + +**Files:** +- Modify: `backend/tests/test_narrative_e2e.py` + +- [ ] **Step 1: Add integration test for event injection** + +Append to `backend/tests/test_narrative_e2e.py`: + +```python +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 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, "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 + kill_character(sim_dir, "2") + beat2 = translate_round(sim_dir, "twitter", 2, "noir") + + # Marcus acted in round 2 but is dead — should not appear + assert "Marcus" not in beat2["characters"] + + # Round-2 prompt should NOT include Marcus in the character summary + round2_prompt = mock_llm.call_args_list[1][0][0] + # Marcus may appear in the death event log though — assert he's not in the + # characters-in-scene section (by looking for "feeling:" adjacent to his name) + lines = round2_prompt.split("\n") + char_lines = [l for l in lines if "feeling:" in l] + assert not any("Marcus" in l for l in char_lines) +``` + +- [ ] **Step 2: Run full suite** + +```bash +cd backend && uv run pytest tests/ -v +``` +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add backend/tests/test_narrative_e2e.py +git commit -m "test(narrative): add e2e tests for event injection and kill" +``` + +--- + +## Task 8: API endpoints + +**Files:** +- Modify: `backend/app/api/narrative.py` + +- [ ] **Step 1: Add 6 new endpoints** + +Append to `backend/app/api/narrative.py`: + +```python +from ..services.narrative.world_state import WorldStateStore +from ..services.narrative.god_mode import inject_event, modify_emotion, kill_character + + +@narrative_bp.route('/world/', methods=['GET']) +def get_world(sim_id): + store = WorldStateStore(_sim_dir(sim_id)) + return jsonify(store.load()) + + +@narrative_bp.route('/world//rules', methods=['POST']) +def set_rules(sim_id): + 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//locations', methods=['POST']) +def upsert_location(sim_id): + 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) + + +@narrative_bp.route('/godmode//inject-event', methods=['POST']) +def godmode_inject_event(sim_id): + 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 = inject_event(_sim_dir(sim_id), description=str(description), round_num=round_num) + return jsonify(evt) + + +@narrative_bp.route('/godmode//modify-emotion', methods=['POST']) +def godmode_modify_emotion(sim_id): + 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 = 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//kill', methods=['POST']) +def godmode_kill(sim_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 = kill_character(_sim_dir(sim_id), str(char_id)) + except ValueError as e: + return jsonify({"error": str(e)}), 404 + return jsonify(char) +``` + +- [ ] **Step 2: Smoke test endpoints** + +```bash +cd backend && LLM_API_KEY=fake ZEP_API_KEY=fake FLASK_DEBUG=false uv run python -c " +from app import create_app +app = create_app() +c = app.test_client() +print('GET world:', c.get('/api/narrative/world/x').status_code, c.get('/api/narrative/world/x').get_json()) +print('POST rules empty:', c.post('/api/narrative/world/x/rules', json={}).status_code) +print('POST rules valid:', c.post('/api/narrative/world/x/rules', json={'rules': ['r1']}).status_code) +print('POST inject no desc:', c.post('/api/narrative/godmode/x/inject-event', json={}).status_code) +print('POST inject valid:', c.post('/api/narrative/godmode/x/inject-event', json={'description': 'test'}).status_code) +print('POST inject bad round:', c.post('/api/narrative/godmode/x/inject-event', json={'description': 'x', 'round': -1}).status_code) +" +``` +Expected: 200, 400, 200, 400, 200, 400. + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/narrative.py +git commit -m "feat(narrative): add world + god mode API endpoints" +``` + +--- + +## Task 9: Frontend API client + +**Files:** +- Modify: `frontend/src/api/narrative.js` + +- [ ] **Step 1: Add 6 named exports** + +Append to `frontend/src/api/narrative.js`: + +```javascript +export const getWorld = (simId) => + service.get(`/api/narrative/world/${simId}`) + +export const setWorldRules = (simId, rules) => + service.post(`/api/narrative/world/${simId}/rules`, { rules }) + +export const upsertLocation = (simId, location) => + service.post(`/api/narrative/world/${simId}/locations`, location) + +export const injectEvent = (simId, description, round = null) => + requestWithRetry( + () => service.post(`/api/narrative/godmode/${simId}/inject-event`, { description, round }), + 3, 2000 + ) + +export const modifyEmotion = (simId, characterId, emotions) => + service.post(`/api/narrative/godmode/${simId}/modify-emotion`, { + character_id: characterId, + emotions, + }) + +export const killCharacter = (simId, characterId) => + service.post(`/api/narrative/godmode/${simId}/kill`, { character_id: characterId }) +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/api/narrative.js +git commit -m "feat(narrative): add frontend API client for world + god mode" +``` + +--- + +## Task 10: SimNav + routes + views + +**Files:** +- Create: `frontend/src/views/GodModeView.vue` +- Create: `frontend/src/views/WorldBuilderView.vue` +- Modify: `frontend/src/views/StoryTimelineView.vue` +- Modify: `frontend/src/router/index.js` + +**🎯 Contains USER CONTRIBUTION POINT #2 (location schema fields) — paused in Step 3.** + +- [ ] **Step 1: Add routes** + +Modify `frontend/src/router/index.js`: + +```javascript +import GodModeView from '../views/GodModeView.vue' +import WorldBuilderView from '../views/WorldBuilderView.vue' +``` + +And in the routes array, before the closing `]`: + +```javascript + { + path: '/godmode/:simulationId', + name: 'GodMode', + component: GodModeView, + props: true + }, + { + path: '/world/:simulationId', + name: 'World', + component: WorldBuilderView, + props: true + } +``` + +- [ ] **Step 2: Add shared nav strip to StoryTimelineView** + +In `frontend/src/views/StoryTimelineView.vue`, inside the top-level `
`, add as the first child before `