diff --git a/backend/app/services/narrative/narrative_translator.py b/backend/app/services/narrative/narrative_translator.py index 773fe5d2..607c563d 100644 --- a/backend/app/services/narrative/narrative_translator.py +++ b/backend/app/services/narrative/narrative_translator.py @@ -11,6 +11,38 @@ from typing import List, Tuple from app.services.narrative.action_mapper import get_narrative_context +def _escape_braces(text: str) -> str: + """Escape { and } in user-supplied strings before str.format().""" + return text.replace("{", "{{").replace("}", "}}") + + +def _format_world_rules(world: dict) -> str: + rules = world.get("rules", []) + if not rules: + return "(none)" + return "; ".join(_escape_braces(r) for r in rules) + + +def _format_world_events(world: dict) -> str: + events = world.get("event_log", [])[-3:] + if not events: + return "(none)" + return "\n ".join( + f"(Round {e.get('round', '?')}) {_escape_braces(e.get('description', ''))}" + for e in events + ) + + +def _format_world_locations(world: dict) -> str: + locs = list(world.get("locations", {}).values())[:5] + if not locs: + return "(none)" + return "\n ".join( + f"{_escape_braces(l.get('name', ''))} — {_escape_braces(l.get('description', ''))}" + for l in locs + ) + + def read_actions_for_round( jsonl_path: str, start_offset: int, target_round: int ) -> Tuple[List[dict], int]: @@ -84,11 +116,18 @@ def call_llm(prompt: str) -> str: ) -def _format_character_summary(character: dict) -> str: +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" - return f"{character.get('name', 'Unknown')} (feeling: {emo_str})" + + loc_id = character.get("location") + loc_str = "" + if loc_id and locations and loc_id in locations: + loc_str = f" at {_escape_braces(locations[loc_id].get('name', loc_id))}" + + name = _escape_braces(character.get("name", "Unknown")) + return f"{name} (feeling: {emo_str}){loc_str}" def _format_action_line(action: dict) -> str: @@ -125,10 +164,35 @@ def _format_action_line(action: dict) -> str: # A temporary minimal template is used below to make tests pass — REPLACE # WITH YOUR OWN DESIGN to dial in story quality. # ============================================================================ +# ============================================================================ +# USER CONTRIBUTION POINT — how strongly to force world events into prose +# ============================================================================ +# Three supported values: +# "soft" — "consider referencing the most recent world event if it fits" +# "medium" — "weave it in OR acknowledge its aftermath. Do not ignore it." +# "hard" — "the opening line MUST reference the most recent world event" +# Temporary default is "medium" — change after reviewing sample output. +# ============================================================================ +EVENT_ENFORCEMENT_STRENGTH = "hard" + +_ENFORCEMENT_PHRASES = { + "soft": "- Consider referencing the most recent world event if it fits naturally.", + "medium": "- Weave the most recent world event in, OR acknowledge its aftermath. Do not ignore it.", + "hard": "- The OPENING LINE of this passage MUST reference the most recent world event.", +} + + PROSE_PROMPT_TEMPLATE = """You are a screenwriter-turned-novelist. Your voice is PUNCHY, CINEMATIC, and DIALOGUE-DRIVEN. Tone: {tone} +World grounding: + Rules: {world_rules} + Recent events: + {world_events} + Known locations: + {world_locations} + Previous scene: {previous} @@ -155,6 +219,8 @@ 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. @@ -167,20 +233,32 @@ Write the prose only. No headings, no preamble, no meta commentary.""" # ============================================================================ -def generate_prose(actions: list, characters: list, tone: str, previous_beats: list) -> str: +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." - char_summaries = "\n".join(_format_character_summary(c) for c in characters) + 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(b.get("prose", "") for b in previous_beats[-2:]) + 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=tone, + 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) @@ -195,35 +273,44 @@ def translate_round(sim_dir: str, platform: str, target_round: int, tone: str = Steps: 1. Read the round's actions from `{sim_dir}/{platform}/actions.jsonl` (starting from the saved file offset). - 2. Generate prose via the LLM using current character state. - 3. Apply emotional-state deltas for every action. - 4. Persist the new beat, updated character state, and new file offset. + 2. Load world state (rules, locations, event log) — empty if not present. + 3. Filter dead characters and their actions out before prose generation. + 4. Generate prose via the LLM using current character + world state. + 5. Apply emotional-state deltas for every action to living characters. + 6. Persist the new beat, updated character state, and new file offset. Returns the newly created story beat dict. """ # Lazy imports keep module import lightweight for unit tests of helpers from app.services.narrative.story_store import StoryStore from app.services.narrative.character_engine import apply_action_emotional_delta + from app.services.narrative.world_state import WorldStateStore store = StoryStore(sim_dir) + world = WorldStateStore(sim_dir).load() + actions_path = os.path.join(sim_dir, platform, "actions.jsonl") start_offset = store.get_file_offset(platform) actions, new_offset = read_actions_for_round(actions_path, start_offset, target_round) - characters = store.load_characters() + # Filter dead characters and their actions + all_characters = store.load_characters() + alive_names = {c["name"] for c in all_characters if c.get("status", "alive") != "dead"} + living_characters = [c for c in all_characters if c["name"] in alive_names] + actions = [a for a in actions if a.get("agent_name") in alive_names] + previous_beats = store.get_all_beats() + prose = generate_prose(actions, living_characters, tone, previous_beats, world) - prose = generate_prose(actions, characters, tone, previous_beats) - - # De-duplicated list of character names that acted this round + # De-duplicated list of character names that acted this round (living only) involved = sorted({a.get("agent_name") for a in actions if a.get("agent_name")}) - # Mutate character emotional state in-place, then persist - char_by_name = {c["name"]: c for c in characters} + # Apply emotional deltas only to living characters + char_by_name = {c["name"]: c for c in all_characters} for action in actions: char = char_by_name.get(action.get("agent_name")) - if char: + if char and char.get("status", "alive") != "dead": apply_action_emotional_delta(char, action.get("action_type", "")) store.save_characters(list(char_by_name.values())) diff --git a/backend/tests/test_narrative_translator.py b/backend/tests/test_narrative_translator.py index 71c19b50..ea361bd9 100644 --- a/backend/tests/test_narrative_translator.py +++ b/backend/tests/test_narrative_translator.py @@ -118,3 +118,27 @@ def test_translate_round_produces_beat(tmp_path): stored_beats = store.get_all_beats() assert len(stored_beats) == 1 + + +def test_generate_prose_includes_world_context(): + actions = [{"agent_name": "Alice", "action_type": "CREATE_POST", "action_args": {}}] + characters = [ + {"id": "1", "name": "Alice", "status": "alive", "location": "tower", + "emotional_state": {"current": {"anger": 0, "fear": 0, "joy": 0, + "sadness": 0, "trust": 0.5, "surprise": 0}}}, + ] + world = { + "rules": ["Magic is forbidden", "Winter is near"], + "locations": {"tower": {"id": "tower", "name": "The Tower", "description": "tall"}}, + "event_log": [{"id": "evt_1", "round": 1, "type": "god_mode_injection", + "description": "A stranger arrived."}], + } + + with patch("app.services.narrative.narrative_translator.call_llm") as mock_llm: + mock_llm.return_value = "prose" + generate_prose(actions, characters, tone="noir", previous_beats=[], world=world) + + prompt = mock_llm.call_args[0][0] + assert "Magic is forbidden" in prompt + assert "A stranger arrived" in prompt + assert "The Tower" in prompt