# 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 `