mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-01 19:48:52 +02:00
Implement Player Character Toggle: Add functionality to promote or demote characters as player characters in the World Editor, including websocket handling and associated tests.
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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": "<character 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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -131,6 +131,27 @@
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- TOGGLE PLAYER CHARACTER -->
|
||||
<div>
|
||||
<v-list-item v-if="!character.is_player">
|
||||
<v-tooltip max-width="300" :text="`Mark ${character.name} as the player character. The current player character (if any) will be demoted to an AI actor.`">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn @click.stop="setIsPlayer(true)" v-bind="props" variant="tonal" block color="info" prepend-icon="mdi-account-star">Make Player Character</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-else>
|
||||
<v-tooltip max-width="300" :text="`Unmark ${character.name} as the player character. They will become an AI actor and remain in the scene.`">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn @click.stop="setIsPlayer(false)" v-bind="props" variant="tonal" block color="info" prepend-icon="mdi-account-off-outline">Unmark as Player</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-list-item>
|
||||
</div>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<!-- DEACTIVATE CHARACTER -->
|
||||
<div>
|
||||
<v-list-item v-if="character.active">
|
||||
@@ -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({
|
||||
|
||||
203
tests/test_character_is_player.py
Normal file
203
tests/test_character_is_player.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user