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:
parent
2d782f04c1
commit
05aa9f149e
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue