MicroFish/docs/superpowers/plans/2026-04-20-godmode-worldsta...

53 KiB

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:

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

"""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
cd backend && uv run pytest tests/test_world_state.py -v

Expected: 4 passed.

  • Step 5: Commit
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:

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

    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
cd backend && uv run pytest tests/ -v

Expected: 21 passed (20 previous + 1 new).

  • Step 5: Commit
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:

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

"""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
cd backend && uv run pytest tests/test_god_mode.py -v

Expected: 3 passed.

  • Step 5: Commit
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:

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

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
cd backend && uv run pytest tests/test_god_mode.py -v

Expected: 7 passed (3 + 4 new).

  • Step 5: Commit
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:

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

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
cd backend && uv run pytest tests/test_god_mode.py -v

Expected: 10 passed (7 + 3 new).

  • Step 5: Commit
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:

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

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:

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:

# ============================================================================
# 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):

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:

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:

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
cd backend && uv run pytest tests/ -v

Expected: all tests pass, including the new prompt-inclusion test.

  • Step 9: Commit
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:

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
cd backend && uv run pytest tests/ -v

Expected: all tests pass.

  • Step 3: Commit
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:

from ..services.narrative.world_state import WorldStateStore
from ..services.narrative.god_mode import inject_event, modify_emotion, kill_character


@narrative_bp.route('/world/<sim_id>', methods=['GET'])
def get_world(sim_id):
    store = WorldStateStore(_sim_dir(sim_id))
    return jsonify(store.load())


@narrative_bp.route('/world/<sim_id>/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/<sim_id>/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/<sim_id>/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/<sim_id>/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/<sim_id>/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
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
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:

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

import GodModeView from '../views/GodModeView.vue'
import WorldBuilderView from '../views/WorldBuilderView.vue'

And in the routes array, before the closing ]:

  {
    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 <div class="story-timeline">, add as the first child before <header class="page-header">:

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

And add to the scoped styles:

.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;
}
  • Step 3: 🎯 USER CONTRIBUTION — location schema fields

Create frontend/src/views/WorldBuilderView.vue.

The form has a user-contribution point: how many fields to accept for a location. Default is {name, description} but the user may want more. The comment block in Step 4 describes the options.

  • Step 4: Create WorldBuilderView.vue
<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>

    <section class="card">
      <h2>World Rules</h2>
      <p class="hint">One rule per line. These ground the story's world in every scene.</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>
      <!--
        USER CONTRIBUTION POINT — Location schema fields
        Default: id, name, description.
        Optional additions (uncomment in the form and in the addLocation() body):
          - atmosphere: short mood phrase ("dust motes in shafts of light")
          - time_of_day: "dawn" | "noon" | "dusk" | "midnight"
          - occupied_by: free-text notes about who usually is there
      -->
      <div v-for="loc in locations" :key="loc.id" class="location-item">
        <strong>{{ loc.name }}</strong> <span class="muted">({{ loc.id }})</span>
        <p>{{ loc.description }}</p>
      </div>

      <form @submit.prevent="addLocation" class="location-form">
        <input v-model="newLoc.id" placeholder="id (e.g. iron_tower)" required />
        <input v-model="newLoc.name" placeholder="Name (The Iron Tower)" required />
        <input v-model="newLoc.description" placeholder="Description" />
        <!-- Uncomment below to enable additional fields (per user contribution choices) -->
        <!-- <input v-model="newLoc.atmosphere" placeholder="Atmosphere (mood phrase)" /> -->
        <!-- <select v-model="newLoc.time_of_day"><option value="">time of day</option>
               <option>dawn</option><option>noon</option><option>dusk</option><option>midnight</option>
             </select> -->
        <button type="submit" :disabled="busy">Add / Update</button>
      </form>
    </section>

    <section class="card">
      <h2>Event Log</h2>
      <p class="hint">World events (injected via God Mode or logged automatically).</p>
      <ol class="event-log">
        <li v-for="e in events" :key="e.id">
          <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-if="!events.length" class="muted">No events yet.</p>
    </section>

    <div v-if="error" class="error">{{ error }}</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: '' })
const busy = ref(false)
const error = ref('')

const locations = computed(() => Object.values(world.value.locations || {}))
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
  }
}

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)
    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 {
    await upsertLocation(simId, { ...newLoc.value })
    newLoc.value = { id: '', name: '', description: '' }
    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 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;
}
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;
}
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-item {
  padding: 0.6rem 0;
  border-bottom: 1px dashed #e5ddc4;
}
.location-item p { margin: 0.25rem 0 0; color: #5a4f2f; font-size: 0.9rem; }
.location-form { display: grid; grid-template-columns: 1fr 1fr 2fr auto; gap: 0.5rem; align-items: start; margin-top: 0.75rem; }
.event-log { list-style: none; padding: 0; margin: 0; }
.event-log li {
  display: grid;
  grid-template-columns: 80px 140px 1fr;
  gap: 0.75rem;
  padding: 0.5rem 0;
  border-bottom: 1px dashed #e5ddc4;
  font-size: 0.9rem;
}
.event-round { color: #c9a45b; font-weight: 600; }
.event-type { font-family: 'SF Mono', Menlo, monospace; font-size: 0.75rem; color: #7d6b3f; }
.error { background: #ffe5e5; color: #8b0000; padding: 0.85rem 1rem; border-radius: 4px; }
</style>
  • Step 5: Create GodModeView.vue
<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>

    <section class="card inject">
      <h2> Inject World Event</h2>
      <p class="hint">A new world event the narrator will weave into the next scene.</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)" />
        <button @click="doInject" :disabled="busy || !eventDesc">Inject</button>
      </div>
    </section>

    <section class="card emotion">
      <h2>💭 Modify Character Emotions</h2>
      <select v-model="emoCharId">
        <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. Type the character's name to confirm.</p>
      <select v-model="killCharId">
        <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</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 || ''
})

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 */ }
}

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.')
    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 {
    await killCharacter(simId, killCharId.value)
    flash(`${selectedKillName.value} 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 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; }
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;
}
.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.5; cursor: wait; }
button.danger { background: #8b0000; }
.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; }
.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>
  • Step 6: Build frontend
cd frontend && npm run build

Expected: clean build, no errors.

  • Step 7: Commit
git add frontend/
git commit -m "feat(narrative): add GodModeView, WorldBuilderView, and shared nav"

Self-review checklist

Spec coverage:

  • §4.1 inject_event → Task 3 ✓
  • §4.2 modify_emotion + audit log → Task 4 ✓
  • §4.3 kill_character + auto-death event → Task 5 ✓
  • §5.0 WorldStateStore + load semantics → Task 1 ✓
  • §5.0 id/name/location resolution → Task 6 (translator) ✓
  • §5 prompt extension with 3 world fields → Task 6 ✓
  • §5 brace escape → Task 6 ✓
  • §5.1 dead character filter → Task 6 ✓
  • §3.3 all 6 API endpoints → Task 8 ✓
  • §6.4 shared nav → Tasks 10.2 (Story) + 10.4 + 10.5
  • §6.5 typed-name kill confirmation → Task 10.5 (canKill computed) ✓
  • §7 round validation → Task 8 inject_event handler ✓
  • §8 prompt inclusion test → Task 6 Step 1 ✓
  • §8 e2e tests → Task 7 ✓
  • §9.1 user contribution EVENT_ENFORCEMENT_STRENGTH → Task 6 Step 5 ✓
  • §9.2 user contribution location schema → Task 10 Step 3 ✓

Non-placeholder scan: Every step shows actual code or exact commands. No "TODO later" or "fill in details" outside the two explicit USER CONTRIBUTION POINTs.

Type consistency: translate_round, generate_prose, inject_event, modify_emotion, kill_character, WorldStateStore, _current_round, _escape_braces — all signatures stable across task boundaries.

Deferred (per spec §10): Mid-sim OASIS injection, resurrection, force action, time skip, factions, resources, location transitions, bulk ops, undo/history, delete endpoints, file locking, event log rotation, per-sim enforcement strength.