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:
vegu-ai-tools
2026-05-15 21:52:38 +03:00
parent b677379a94
commit a0314616dc
6 changed files with 342 additions and 1 deletions

View File

@@ -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."

View File

@@ -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(

View File

@@ -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.

View File

@@ -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,

View File

@@ -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({

View 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