diff --git a/backend/app/services/narrative/world_state.py b/backend/app/services/narrative/world_state.py new file mode 100644 index 00000000..510dc279 --- /dev/null +++ b/backend/app/services/narrative/world_state.py @@ -0,0 +1,59 @@ +"""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 + + +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 {"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 diff --git a/backend/tests/test_world_state.py b/backend/tests/test_world_state.py new file mode 100644 index 00000000..d5ae49d7 --- /dev/null +++ b/backend/tests/test_world_state.py @@ -0,0 +1,45 @@ +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" + + 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"