diff --git a/backend/app/services/narrative/story_store.py b/backend/app/services/narrative/story_store.py new file mode 100644 index 00000000..5fb2fb5f --- /dev/null +++ b/backend/app/services/narrative/story_store.py @@ -0,0 +1,74 @@ +"""File-based persistence for narrative state. + +Each simulation gets a `narrative/` subdirectory inside its data dir, +containing three files: + - story_beats.json: chronological list of generated story passages + - translator_state.json: tracks file offset per platform's actions.jsonl + - characters.json: extended character profiles with emotional state +""" +import os +import json +from typing import Optional + + +class StoryStore: + """Manages narrative/*.json files for a single simulation.""" + + def __init__(self, sim_dir: str): + self.sim_dir = sim_dir + self.narrative_dir = os.path.join(sim_dir, "narrative") + self.beats_path = os.path.join(self.narrative_dir, "story_beats.json") + self.translator_state_path = os.path.join( + self.narrative_dir, "translator_state.json" + ) + self.characters_path = os.path.join(self.narrative_dir, "characters.json") + + def _ensure_dir(self) -> None: + os.makedirs(self.narrative_dir, exist_ok=True) + + def append_beat(self, beat: dict) -> None: + self._ensure_dir() + beats = self.get_all_beats() + beats.append(beat) + with open(self.beats_path, "w", encoding="utf-8") as f: + json.dump(beats, f, ensure_ascii=False, indent=2) + + def get_all_beats(self) -> list: + if not os.path.exists(self.beats_path): + return [] + with open(self.beats_path, "r", encoding="utf-8") as f: + return json.load(f) + + def get_beat_by_round(self, round_num: int) -> Optional[dict]: + for beat in self.get_all_beats(): + if beat.get("round") == round_num: + return beat + return None + + def get_file_offset(self, platform: str) -> int: + if not os.path.exists(self.translator_state_path): + return 0 + with open(self.translator_state_path, "r", encoding="utf-8") as f: + state = json.load(f) + return state.get(f"{platform}_offset", 0) + + def set_file_offset(self, platform: str, offset: int) -> None: + self._ensure_dir() + state = {} + if os.path.exists(self.translator_state_path): + with open(self.translator_state_path, "r", encoding="utf-8") as f: + state = json.load(f) + state[f"{platform}_offset"] = offset + with open(self.translator_state_path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + + def save_characters(self, characters: list) -> None: + self._ensure_dir() + with open(self.characters_path, "w", encoding="utf-8") as f: + json.dump(characters, f, ensure_ascii=False, indent=2) + + def load_characters(self) -> list: + if not os.path.exists(self.characters_path): + return [] + with open(self.characters_path, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/backend/tests/test_story_store.py b/backend/tests/test_story_store.py new file mode 100644 index 00000000..6d9f5ccc --- /dev/null +++ b/backend/tests/test_story_store.py @@ -0,0 +1,47 @@ +import os +import tempfile +import pytest +from app.services.narrative.story_store import StoryStore + + +@pytest.fixture +def temp_sim_dir(): + with tempfile.TemporaryDirectory() as d: + sim_dir = os.path.join(d, "sim_test123") + os.makedirs(sim_dir) + yield sim_dir + + +def test_save_and_load_story_beats(temp_sim_dir): + store = StoryStore(temp_sim_dir) + beat = {"round": 1, "prose": "Elena spoke.", "characters": ["elena"]} + store.append_beat(beat) + + beats = store.get_all_beats() + assert len(beats) == 1 + assert beats[0]["prose"] == "Elena spoke." + + +def test_translator_state_tracks_offset(temp_sim_dir): + store = StoryStore(temp_sim_dir) + assert store.get_file_offset("twitter") == 0 + + store.set_file_offset("twitter", 1024) + assert store.get_file_offset("twitter") == 1024 + + +def test_get_beat_by_round(temp_sim_dir): + store = StoryStore(temp_sim_dir) + store.append_beat({"round": 1, "prose": "First"}) + store.append_beat({"round": 2, "prose": "Second"}) + + beat = store.get_beat_by_round(2) + assert beat["prose"] == "Second" + + +def test_narrative_dir_created_on_first_write(temp_sim_dir): + store = StoryStore(temp_sim_dir) + store.append_beat({"round": 1, "prose": "test"}) + + narrative_dir = os.path.join(temp_sim_dir, "narrative") + assert os.path.isdir(narrative_dir)