feat(narrative): add StoryStore for file-based narrative persistence

Three JSON files per simulation under narrative/ subdir:
- story_beats.json: chronological story passages
- translator_state.json: per-platform file offsets
- characters.json: extended character profiles

Tests: 4/4 passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
anadoris007 2026-04-20 20:50:55 +05:30
parent 2d782f04c1
commit 05aa9f149e
2 changed files with 121 additions and 0 deletions

View File

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

View File

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