diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml index e731ec78..37a91c0c 100644 --- a/CHANGELOG.yaml +++ b/CHANGELOG.yaml @@ -1,5 +1,6 @@ 0.38.0.dev: features: + - "Player Character Toggle: Added a Make Player Character / Unmark as Player action to the World Editor character editor. Promoting a non-player character makes them the player and automatically demotes the previous player (if any) to an AI actor — the previous player stays active in the scene. Unmarking the current player flips them to AI without requiring a replacement, leaving the scene with no explicit player. Promoting an inactive character also activates them." - "Message Revision History: Regenerated AI messages now show a paginator above the message body. Click the arrows to browse previous regenerations; the version you're viewing becomes the canonical one the AI continues from. Lives in the browser session only." improvements: - "Message Revision History: Continuing a character or narrator message now creates a navigable revision entry tagged 'Continued', alongside the existing regenerate entries. The pre-continuation text is reachable via the paginator arrows. Narrator messages also gain the Continue action on the hover toolbar to match the character-message flow." diff --git a/src/talemate/character.py b/src/talemate/character.py index dacfb3d3..f0743ef8 100644 --- a/src/talemate/character.py +++ b/src/talemate/character.py @@ -25,6 +25,7 @@ __all__ = [ "VoiceChangedEvent", "deactivate_character", "activate_character", + "set_character_is_player", "set_voice", ] @@ -724,6 +725,59 @@ async def activate_character(scene: "Scene", character: Union[str, "Character"]) scene.active_characters.append(character.name) +async def _demote_player_to_actor(scene: "Scene", character: "Character"): + """Flip is_player off and, if active, swap the Player actor for a plain Actor. + + Caller must ensure `character` is currently the player (or is being + unmarked); this helper does not re-check the flag. + """ + character.is_player = False + if scene.character_is_active(character): + new_actor = scene.Actor(character, instance.get_agent("conversation")) + await scene.add_actor(new_actor, commit_to_memory=False) + + +async def set_character_is_player( + scene: "Scene", character: Union[str, "Character"], is_player: bool +): + """ + Sets or clears a character's player status. At most one character may be + the player at any time, so promoting a non-player demotes the existing + player (if any) to an AI Actor. Active characters get their actor swapped + in place; inactive characters being promoted are auto-activated. + + Arguments: + scene: The scene the character lives in. + character: The character to update. Can be a name or a Character object. + is_player: True to mark as player, False to unmark. + """ + if isinstance(character, str): + character = scene.get_character(character) + + if not character: + log.error("character not found") + return + + if character.is_player == is_player: + return + + if is_player: + current_player = scene.get_explicit_player_character() + if current_player is not None and current_player is not character: + await _demote_player_to_actor(scene, current_player) + + if scene.character_is_active(character): + # add_actor sets is_player=True when the new actor is a Player + new_actor = scene.Player(character, None) + await scene.add_actor(new_actor, commit_to_memory=False) + else: + # activate_character reads is_player to pick the Player class + character.is_player = True + await activate_character(scene, character) + else: + await _demote_player_to_actor(scene, character) + + async def set_voice(character: "Character", voice: Voice | None, auto: bool = False): character.voice = voice emission: VoiceChangedEvent = VoiceChangedEvent( diff --git a/src/talemate/server/world_state_manager/character.py b/src/talemate/server/world_state_manager/character.py index 7d0266b7..b4cc6854 100644 --- a/src/talemate/server/world_state_manager/character.py +++ b/src/talemate/server/world_state_manager/character.py @@ -63,6 +63,13 @@ class RenameCharacterFolderPayload(pydantic.BaseModel): new_name: str +class SetCharacterIsPlayerPayload(pydantic.BaseModel): + """Payload for setting a character's player status.""" + + name: str + is_player: bool + + class CharacterMixin: """Mixin adding websocket handlers for character voice assignment.""" @@ -256,6 +263,39 @@ class CharacterMixin: await self.signal_operation_done() self.scene.emit_status() + async def handle_set_character_is_player(self, data: dict): + """Toggle a character's player status. + + Expected payload + ----------------- + { + "type": "world_state_manager", + "action": "set_character_is_player", + "name": "", + "is_player": true | false + } + """ + try: + payload = SetCharacterIsPlayerPayload(**data) + except pydantic.ValidationError as e: + log.error("Invalid payload for set_character_is_player", error=e) + await self.signal_operation_failed(str(e)) + return + + character = self.scene.get_character(payload.name) + if not character: + await self.signal_operation_failed("Character not found") + return + + await self.world_state_manager.set_character_is_player( + payload.name, payload.is_player + ) + + await self.handle_get_character_list({}) + await self.handle_get_character_details({"name": payload.name}) + await self.signal_operation_done() + self.scene.emit_status() + async def handle_rename_character_folder(self, data: dict): """Bulk-rename a folder across every character currently assigned to it. diff --git a/src/talemate/world_state/manager.py b/src/talemate/world_state/manager.py index c1ce0e0e..bbaac5f5 100644 --- a/src/talemate/world_state/manager.py +++ b/src/talemate/world_state/manager.py @@ -5,7 +5,12 @@ import structlog import talemate.world_state.templates as world_state_templates from talemate.agents.tts.util import get_voice -from talemate.character import activate_character, deactivate_character, set_voice +from talemate.character import ( + activate_character, + deactivate_character, + set_character_is_player, + set_voice, +) from talemate.instance import get_agent from talemate.emit import emit from talemate.world_state import ( @@ -991,6 +996,15 @@ class WorldStateManager: """ await deactivate_character(self.scene, character_name) + async def set_character_is_player(self, character_name: str, is_player: bool): + """ + Sets or clears a character's player status. + + See :func:`talemate.character.set_character_is_player` for the full + contract. + """ + await set_character_is_player(self.scene, character_name, is_player) + async def create_character( self, generate: bool = True, diff --git a/talemate_frontend/src/components/WorldStateManagerCharacter.vue b/talemate_frontend/src/components/WorldStateManagerCharacter.vue index 0c594481..9e1b9a3a 100644 --- a/talemate_frontend/src/components/WorldStateManagerCharacter.vue +++ b/talemate_frontend/src/components/WorldStateManagerCharacter.vue @@ -131,6 +131,27 @@ + +
+ + + + + + + + + + + +
+ + +
@@ -563,6 +584,14 @@ export default { name: this.character.name, })); }, + setIsPlayer(isPlayer) { + this.getWebsocket().send(JSON.stringify({ + type: 'world_state_manager', + action: 'set_character_is_player', + name: this.character.name, + is_player: isPlayer, + })); + }, visualizeCharacter() { this.coverImageBusy = true; this.getWebsocket().send(JSON.stringify({ diff --git a/tests/test_character_is_player.py b/tests/test_character_is_player.py new file mode 100644 index 00000000..23689cc4 --- /dev/null +++ b/tests/test_character_is_player.py @@ -0,0 +1,203 @@ +""" +Tests for ``WorldStateManager.set_character_is_player`` — toggling a character's +player status from the World State Manager. + +Design contract: +- At most one character may be the player at any time. +- Promoting a non-player demotes the existing player (if any) to a plain + AI Actor; the previous player stays active in the scene. +- Active characters get their actor swapped in place (Actor↔Player). +- Inactive characters being promoted are auto-activated. +- No-ops (same state, unknown name) must not raise. +""" + +import pytest + +from conftest import MockScene, bootstrap_scene +from talemate.character import Character +from talemate.tale_mate import Actor, Player +from talemate.world_state.manager import WorldStateManager + + +@pytest.fixture +def scene(): + mock_scene = MockScene() + bootstrap_scene(mock_scene) + return mock_scene + + +@pytest.fixture +def manager(scene): + return WorldStateManager(scene) + + +def _add_active_npc(scene, name: str) -> Character: + """Active AI character — wrapped in a plain Actor, registered in both + ``scene.actors`` and ``scene.active_characters`` to mirror production. + """ + character = Character(name=name, is_player=False) + actor = Actor(character=character, agent=None) + scene.actors.append(actor) + scene.character_data[character.name] = character + if character.name not in scene.active_characters: + scene.active_characters.append(character.name) + return character + + +def _add_active_player(scene, name: str) -> Character: + """Active player character — wrapped in a Player actor.""" + character = Character(name=name, is_player=True) + actor = Player(character=character, agent=None) + scene.actors.append(actor) + scene.character_data[character.name] = character + if character.name not in scene.active_characters: + scene.active_characters.append(character.name) + return character + + +def _add_inactive_character( + scene, name: str, is_player: bool = False +) -> Character: + """Inactive character — present in ``character_data`` but without an + actor and not in ``active_characters``. + """ + character = Character(name=name, is_player=is_player) + scene.character_data[character.name] = character + if character.name in scene.active_characters: + scene.active_characters.remove(character.name) + return character + + +def _actor_for(scene, name: str): + for actor in scene.actors: + if actor.character is not None and actor.character.name == name: + return actor + return None + + +class TestSetCharacterIsPlayer: + @pytest.mark.asyncio + async def test_promote_active_npc_when_no_player(self, scene, manager): + npc = _add_active_npc(scene, "Alice") + assert scene.get_explicit_player_character() is None + + await manager.set_character_is_player("Alice", True) + + assert npc.is_player is True + assert isinstance(_actor_for(scene, "Alice"), Player) + assert scene.get_explicit_player_character() is npc + + @pytest.mark.asyncio + async def test_promote_active_npc_demotes_existing_player(self, scene, manager): + prev = _add_active_player(scene, "Hero") + npc = _add_active_npc(scene, "Bob") + + await manager.set_character_is_player("Bob", True) + + # New player is correctly promoted + assert npc.is_player is True + assert isinstance(_actor_for(scene, "Bob"), Player) + # Previous player is demoted to plain Actor, still active + assert prev.is_player is False + prev_actor = _actor_for(scene, "Hero") + assert prev_actor is not None + assert isinstance(prev_actor, Actor) + assert not isinstance(prev_actor, Player) + assert "Hero" in scene.active_characters + # Only one explicit player remains + assert scene.get_explicit_player_character() is npc + + @pytest.mark.asyncio + async def test_promote_inactive_character_auto_activates(self, scene, manager): + ghost = _add_inactive_character(scene, "Ghost") + assert "Ghost" not in scene.active_characters + assert _actor_for(scene, "Ghost") is None + + await manager.set_character_is_player("Ghost", True) + + assert ghost.is_player is True + # Should have been activated as a Player + assert "Ghost" in scene.active_characters + assert isinstance(_actor_for(scene, "Ghost"), Player) + assert scene.get_explicit_player_character() is ghost + + @pytest.mark.asyncio + async def test_promote_inactive_demotes_existing_active_player( + self, scene, manager + ): + prev = _add_active_player(scene, "Hero") + ghost = _add_inactive_character(scene, "Ghost") + + await manager.set_character_is_player("Ghost", True) + + # Inactive target activated as Player + assert ghost.is_player is True + assert "Ghost" in scene.active_characters + assert isinstance(_actor_for(scene, "Ghost"), Player) + # Previous active player demoted but still active + assert prev.is_player is False + assert "Hero" in scene.active_characters + assert isinstance(_actor_for(scene, "Hero"), Actor) + assert not isinstance(_actor_for(scene, "Hero"), Player) + + @pytest.mark.asyncio + async def test_demote_active_player(self, scene, manager): + player = _add_active_player(scene, "Hero") + + await manager.set_character_is_player("Hero", False) + + assert player.is_player is False + actor = _actor_for(scene, "Hero") + assert actor is not None + assert isinstance(actor, Actor) + assert not isinstance(actor, Player) + # Still active in the scene + assert "Hero" in scene.active_characters + # No explicit player remains + assert scene.get_explicit_player_character() is None + + @pytest.mark.asyncio + async def test_set_same_state_is_noop_for_player(self, scene, manager): + player = _add_active_player(scene, "Hero") + original_actor = _actor_for(scene, "Hero") + + await manager.set_character_is_player("Hero", True) + + assert player.is_player is True + # Actor identity preserved — no unnecessary swap + assert _actor_for(scene, "Hero") is original_actor + + @pytest.mark.asyncio + async def test_set_same_state_is_noop_for_npc(self, scene, manager): + npc = _add_active_npc(scene, "Alice") + original_actor = _actor_for(scene, "Alice") + + await manager.set_character_is_player("Alice", False) + + assert npc.is_player is False + assert _actor_for(scene, "Alice") is original_actor + + @pytest.mark.asyncio + async def test_unknown_character_is_noop(self, scene, manager): + existing = _add_active_npc(scene, "Alice") + + # Must not raise + await manager.set_character_is_player("DoesNotExist", True) + + # Unrelated state is untouched + assert existing.is_player is False + assert scene.get_explicit_player_character() is None + + @pytest.mark.asyncio + async def test_demote_inactive_player_just_flips_flag(self, scene, manager): + """An inactive character that was marked as player (e.g. waiting to be + re-activated) can be unmarked without being touched in the scene. + """ + dormant = _add_inactive_character(scene, "Dormant", is_player=True) + + await manager.set_character_is_player("Dormant", False) + + assert dormant.is_player is False + # Still inactive, still no actor + assert "Dormant" not in scene.active_characters + assert _actor_for(scene, "Dormant") is None