"""apply_directives is the only mutator — pin its behaviour: scene change, new character, relationship clamp, exit, and ending.""" from __future__ import annotations from visualnovel import state from visualnovel.schemas import ( Character, Directives, DirectorOutput, Ending, GameState, NewCharacter, Scene, SceneChange, ) def _state() -> GameState: return GameState( seed=1, vibe="v", style_guide="s", scene=Scene(id="a", place="A", present=["moth"]), characters={"moth": Character(id="moth", name="Moth", relationship=95)}, ) def test_scene_change_and_relationship_clamp(): s = _state() out = DirectorOutput( speaker="moth", dialogue="hi", emotion="joy", directives=Directives( scene_change=SceneChange(place="The Hollow Stair", description="d", mood="eerie"), relationship_delta=50, # 95 + 50 -> clamped to 100 ), ) effects = state.apply_directives(s, out) assert "scene_changed" in effects assert s.scene.place == "The Hollow Stair" assert s.scene.present == ["moth"] # people carry across a move assert s.characters["moth"].relationship == 100 # clamped assert s.characters["moth"].mood == "joy" def test_newcomer_speaker_gets_mood_applied(): # new_character is processed before the speaker block, so an arriving newcomer # who speaks this turn gets their emotion applied (drives their first sprite) s = _state() out = DirectorOutput( speaker="fox", dialogue="Oh! You noticed me?", emotion="shy", directives=Directives( new_character=NewCharacter( id="fox", name="Fen", one_line="a sly fox", appearance="russet" ) ), ) effects = state.apply_directives(s, out) assert "new_character:fox" in effects assert s.characters["fox"].mood == "shy" def test_new_character_joins_stage(): s = _state() out = DirectorOutput( speaker="moth", dialogue="look", directives=Directives( new_character=NewCharacter( id="fox", name="Fen", one_line="a sly fox", appearance="russet" ) ), ) effects = state.apply_directives(s, out) assert "new_character:fox" in effects assert "fox" in s.characters and "fox" in s.scene.present assert s.characters["fox"].sprite_seed != 0 # pinned seed assigned def test_remember_fact_appends_dedupes_and_caps(): s = _state() for i in range(9): # 9 distinct facts -> capped at 8, oldest dropped out = DirectorOutput( speaker="moth", dialogue="x", directives=Directives(remember_fact=f"fact {i}") ) effects = state.apply_directives(s, out) assert "fact:moth" in effects # duplicate is ignored out = DirectorOutput( speaker="moth", dialogue="x", directives=Directives(remember_fact="fact 8") ) effects = state.apply_directives(s, out) assert "fact:moth" not in effects facts = s.characters["moth"].known_facts assert len(facts) == 8 assert "fact 0" not in facts and "fact 8" in facts def test_milestone50_lifecycle(): from visualnovel import memory s = _state() s.characters["moth"].relationship = 45 # crossing 50 arms the milestone out = DirectorOutput(speaker="moth", dialogue="x", directives=Directives(relationship_delta=10)) effects = state.apply_directives(s, out) assert "milestone50:moth" in effects assert s.flags["milestone50_moth"] == "pending" # the next context assembly injects the hint exactly while pending ctx = memory.assemble_context(s, "hello") assert "GROWING CLOSE" in ctx # the next apply retires it, and a further delta does not re-arm out2 = DirectorOutput(speaker="moth", dialogue="y", directives=Directives(relationship_delta=5)) effects2 = state.apply_directives(s, out2) assert s.flags["milestone50_moth"] == "done" assert "milestone50:moth" not in effects2 assert "GROWING CLOSE" not in memory.assemble_context(s, "hello") def test_advance_beat_rate_limited(): s = _state() # too early: beat_started_turn defaults to 0 and turn_index is 0 out = DirectorOutput(speaker="moth", dialogue="x", directives=Directives(advance_beat=True)) effects = state.apply_directives(s, out) assert "beat" not in effects and s.beat == "opening" # after 4 turns in the beat, the advance is honoured s.turn_index = 4 effects = state.apply_directives(s, out) assert "beat" in effects and s.beat == "rising" # and immediately advancing again is blocked s.turn_index = 5 effects = state.apply_directives(s, out) assert "beat" not in effects and s.beat == "rising" def test_exit_and_ending(): s = _state() s.scene.present.append("fox") s.characters["fox"] = Character(id="fox", name="Fen") out = DirectorOutput( speaker="moth", dialogue="farewell", directives=Directives( exit_character="fox", ending=Ending(kind="warm", text="And the wood let you go, gently."), ), ) effects = state.apply_directives(s, out) assert "exit_character:fox" in effects and "fox" not in s.scene.present assert s.beat == "ended" and s.flags["ending_kind"] == "warm"