diff --git a/backend/app/services/narrative/god_mode.py b/backend/app/services/narrative/god_mode.py index d1409855..ed395b7f 100644 --- a/backend/app/services/narrative/god_mode.py +++ b/backend/app/services/narrative/god_mode.py @@ -26,3 +26,36 @@ def inject_event(sim_dir: str, description: str, round_num: Optional[int] = None "description": description, "round": round_val, }) + + +def modify_emotion(sim_dir: str, character_id: str, emotions: dict) -> dict: + """Overwrite specified emotion values for a character. Clamps to [0, 1]. + + Raises ValueError if character_id is not found. Unknown emotion keys are + silently ignored (they don't corrupt state; they just don't apply). + Logs the intervention to world_state.event_log for auditability. + """ + store = StoryStore(sim_dir) + characters = store.load_characters() + + target = next((c for c in characters if str(c.get("id")) == str(character_id)), None) + if target is None: + raise ValueError(f"character not found: {character_id}") + + current = target["emotional_state"]["current"] + changed = {} + for emo, val in emotions.items(): + if emo in current: + new_val = max(0.0, min(1.0, float(val))) + changed[emo] = {"from": current[emo], "to": new_val} + current[emo] = new_val + + store.save_characters(characters) + + WorldStateStore(sim_dir).append_event({ + "type": "god_mode_emotion_change", + "description": f"{target['name']} emotional state modified: {changed}", + "round": _current_round(sim_dir), + }) + + return target diff --git a/backend/tests/test_god_mode.py b/backend/tests/test_god_mode.py index 5c2fd367..8b071e94 100644 --- a/backend/tests/test_god_mode.py +++ b/backend/tests/test_god_mode.py @@ -36,3 +36,49 @@ def test_inject_event_defaults_round_to_beats_plus_one(temp_sim_dir): def test_inject_event_defaults_round_to_one_when_no_beats(temp_sim_dir): evt = inject_event(temp_sim_dir, description="first event") assert evt["round"] == 1 + + +# ---- modify_emotion ---- +from app.services.narrative.god_mode import modify_emotion + + +def _seed_character(sim_dir, char_id="1", name="Elena"): + store = StoryStore(sim_dir) + neutral = {k: 0.0 for k in ["anger", "fear", "joy", "sadness", "surprise"]} + store.save_characters([{ + "id": char_id, "name": name, "status": "alive", + "emotional_state": {"current": {**neutral, "trust": 0.5}, "history": []}, + }]) + return store + + +def test_modify_emotion_overwrites_specified_emotions(temp_sim_dir): + _seed_character(temp_sim_dir) + result = modify_emotion(temp_sim_dir, "1", {"anger": 0.8, "joy": 0.2}) + + assert result["emotional_state"]["current"]["anger"] == 0.8 + assert result["emotional_state"]["current"]["joy"] == 0.2 + assert result["emotional_state"]["current"]["trust"] == 0.5 + + +def test_modify_emotion_clamps(temp_sim_dir): + _seed_character(temp_sim_dir) + result = modify_emotion(temp_sim_dir, "1", {"anger": 1.5, "fear": -0.3}) + assert result["emotional_state"]["current"]["anger"] == 1.0 + assert result["emotional_state"]["current"]["fear"] == 0.0 + + +def test_modify_emotion_character_not_found_raises(temp_sim_dir): + _seed_character(temp_sim_dir) + with pytest.raises(ValueError, match="not found"): + modify_emotion(temp_sim_dir, "nonexistent", {"anger": 0.5}) + + +def test_modify_emotion_audit_logs_to_event_log(temp_sim_dir): + _seed_character(temp_sim_dir) + modify_emotion(temp_sim_dir, "1", {"anger": 0.8}) + + log = WorldStateStore(temp_sim_dir).load()["event_log"] + assert len(log) == 1 + assert log[0]["type"] == "god_mode_emotion_change" + assert "Elena" in log[0]["description"]