mirror of
https://github.com/vegu-ai/talemate.git
synced 2026-09-01 19:48:52 +02:00
Example dialogue guidance during character creation (#71)
* feat: example dialogue guidance during character creation (#69) * docs: document example dialogue generation option; update stale test docstring * docs: refresh character creation screenshots for example dialogue option
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
0.39.0.dev:
|
||||
features:
|
||||
- "Character Creation Example Dialogue: AI-assisted character creation (World Editor → Characters → Create and the scene tools creative menu) gained a 'Generate example dialogue' option that generates a few example dialogue lines for the new character, with an optional guidance field to steer how the examples are written (tone, speech patterns, quirks). Off by default."
|
||||
- "Scene Visual Manager: World Editor → Scene gained a Visuals tab mirroring the character visual manager, with Background Illustration and Scene Illustration sub-tabs to browse, upload, generate (new or as a variation of a reference image, single or batch), view, and delete scene images — plus per-image actions to set the scene cover image and set or unset the scene backdrop. A Prompt Finalization sub-tab edits the Visualizer agent's per-scene Prompt Finalization overrides directly from the world editor, creating the scene's agent-settings file with the default name when none is linked yet. The scene backdrop can now also be fully unset (not just toggled off), keeping the image in the scene assets."
|
||||
- "Help Agent: A new Help agent provides an interactive help chat that answers questions about Talemate itself — settings, agents, clients, the world editor and more — grounded in the bundled documentation, which it can search and read before answering. Opened via the help icon in the app bar, it works with or without a loaded scene, supports multiple persistent multi-turn chats, and runs in the background so it never blocks the main Talemate loop. A per-chat Scene Aware toggle lets a conversation see (or stay unaware of) the currently loaded scene, and each question carries a small snapshot of what you are looking at in the interface so contextual questions like 'what does this setting do?' can be answered. The help chat cannot change anything — scene changes remain the director chat's job."
|
||||
- "Scene Backdrop: Any scene illustration can now be set as the scene backdrop — an image that fills the whole scene view behind the messages instead of rendering inline. The backdrop belongs to the scene and is saved with it, so it survives reloads and history edits. Set it via 'Set as scene backdrop' on any illustration's image menu, the 'Set backdrop' button in the Visual Library, or enable 'Auto Backdrop' per visual type (Settings → Appearance → Message Visuals) to have newly generated Scene Backgrounds ('Visualize Scene (Background)') and/or Scene Illustrations ('Visualize Moment') promoted automatically. An 'Immersive' quick-toggle chip in the scene tools turns the backdrop on and off without forgetting the chosen image. Message text sits on translucent panels with a drop shadow for legibility — panel opacity and the text shadow are configurable — and a small marker icon shows which message's image is the current backdrop (click it for the image menu, which stays reachable via an Illustration chip on the message hover toolbar)."
|
||||
|
||||
BIN
docs/img/0.39.0/scene-tool-intro-char-1.png
Normal file
BIN
docs/img/0.39.0/scene-tool-intro-char-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/img/0.39.0/scene-tool-intro-char-example-dialogue.png
Normal file
BIN
docs/img/0.39.0/scene-tool-intro-char-example-dialogue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
docs/img/0.39.0/world-editor-create-player-character-1.png
Normal file
BIN
docs/img/0.39.0/world-editor-create-player-character-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/img/0.39.0/world-editor-create-player-character-2.png
Normal file
BIN
docs/img/0.39.0/world-editor-create-player-character-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -12,7 +12,7 @@ Find the :material-account-plus: **Create Character** button and click on it.
|
||||
|
||||
A `New character` entry will appear in the list.
|
||||
|
||||

|
||||

|
||||
|
||||
## Generate the player character
|
||||
|
||||
@@ -28,7 +28,7 @@ You can leave the `Description` field empty, as the AI will generate a descripti
|
||||
|
||||
Toggle on the **Controlled by Player** switch.
|
||||
|
||||

|
||||

|
||||
|
||||
Finally click the **Create Character** button.
|
||||
|
||||
|
||||
@@ -271,7 +271,7 @@ If the current narration and scene progress has not yet indicated the character
|
||||
|
||||
Allows you to quickly generate a new, interactive character to add to the scene.
|
||||
|
||||

|
||||

|
||||
|
||||
##### Determine Name
|
||||
|
||||
@@ -298,6 +298,16 @@ Write direction on how you want the character to enter the scene.
|
||||
!!! note "A character already interacting in the scene will not have their entrance narrated"
|
||||
If the narrator has already established the character interacting with your characters this "entry narration" will be skipped. The check for this 100% based on an AI prompt so may not always work correctly.
|
||||
|
||||
##### Generate example dialogue
|
||||
|
||||
If selected, the AI will generate a few example dialogue lines for the character, showcasing how they speak and act.
|
||||
|
||||
When checked, an **Example dialogue guidance** field appears where you can optionally steer how the examples are written — tone, speech patterns, quirks (e.g. "Speaks in short sentences, dry humor"). Leave it blank to let the AI infer the voice from the character's description and attributes.
|
||||
|
||||

|
||||
|
||||
The generated examples can be reviewed and edited later under the character's [Actor management](/talemate/user-guide/world-editor/characters/actor) tab in the world editor.
|
||||
|
||||
##### Templates
|
||||
|
||||
Control attribute generation using your existing [World State Templates](/talemate/user-guide/templates).
|
||||
|
||||
@@ -6,7 +6,7 @@ Then click on the :material-account-plus: **Create Character** button on the lef
|
||||
|
||||
A `New character` entry will appear in the list.
|
||||
|
||||

|
||||

|
||||
|
||||
### Enable AI Generation
|
||||
|
||||
@@ -33,6 +33,16 @@ The short to medium length description of the character. This will be generated
|
||||
|
||||
If this is toggled on, the AI will generate some attributes for the character based on the instructions you provide.
|
||||
|
||||
### Generate Example Dialogue
|
||||
|
||||
If this is toggled on, the AI will generate a few example dialogue lines for the character, showcasing how they speak and act.
|
||||
|
||||
When enabled, an **Example dialogue guidance** field appears where you can optionally steer how the examples are written — tone, speech patterns, quirks (e.g. "Speaks in short sentences, dry humor"). Leave it blank to let the AI infer the voice from the character's description and attributes.
|
||||
|
||||

|
||||
|
||||
The generated examples can be reviewed and edited after creation under the character's [Actor management](/talemate/user-guide/world-editor/characters/actor) tab.
|
||||
|
||||
### Controlled by Player
|
||||
|
||||
If this is toggled on, the character will be flagged as the main player character. This is used to determine who the player is controlling in the scene.
|
||||
|
||||
@@ -166,6 +166,7 @@ class CharacterCreatorMixin:
|
||||
self,
|
||||
character: Character,
|
||||
text: str = "",
|
||||
instructions: str = "",
|
||||
dynamic_instructions: list = None,
|
||||
max_examples: int = 5,
|
||||
) -> list[str]:
|
||||
@@ -174,6 +175,7 @@ class CharacterCreatorMixin:
|
||||
Args:
|
||||
character: The character to extract dialogue examples for
|
||||
text: Text containing dialogue examples and relevant character information
|
||||
instructions: Optional user-provided guidance for the dialogue examples
|
||||
dynamic_instructions: Optional dynamic instructions for context
|
||||
max_examples: Maximum number of dialogue examples to generate (default: 5)
|
||||
|
||||
@@ -211,6 +213,7 @@ class CharacterCreatorMixin:
|
||||
character=character,
|
||||
scene=self.scene,
|
||||
text=text,
|
||||
instructions=instructions,
|
||||
max_examples=max_examples,
|
||||
existing_examples=character.example_dialogue[:3]
|
||||
if character.example_dialogue
|
||||
|
||||
@@ -29,6 +29,8 @@ __all__ = [
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
PERSIST_CHARACTER_EXAMPLE_DIALOGUE_COUNT = 3
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from talemate import Character, Scene
|
||||
from talemate.agents.tts import TTSAgent
|
||||
@@ -152,6 +154,8 @@ class CharacterManagementMixin:
|
||||
description: str = "",
|
||||
assign_voice: bool = True,
|
||||
is_player: bool = False,
|
||||
generate_example_dialogue: bool = False,
|
||||
example_dialogue_instructions: str = "",
|
||||
) -> "Character":
|
||||
world_state = instance.get_agent("world_state")
|
||||
creator = instance.get_agent("creator")
|
||||
@@ -298,6 +302,18 @@ class CharacterManagementMixin:
|
||||
character.dialogue_instructions = dialogue_instructions
|
||||
log.debug("persist_character", dialogue_instructions=dialogue_instructions)
|
||||
|
||||
# Generate example dialogue for the character if the option is selected
|
||||
if generate_example_dialogue:
|
||||
loading_status("Generating example dialogue")
|
||||
example_dialogue = await creator.determine_character_dialogue_examples(
|
||||
character,
|
||||
text=content or "",
|
||||
instructions=example_dialogue_instructions,
|
||||
max_examples=PERSIST_CHARACTER_EXAMPLE_DIALOGUE_COUNT,
|
||||
)
|
||||
character.example_dialogue = example_dialogue
|
||||
log.debug("persist_character", example_dialogue=example_dialogue)
|
||||
|
||||
# Narrate the character's entry if the option is selected
|
||||
if active and narrate_entry:
|
||||
loading_status("Narrating character entry")
|
||||
|
||||
@@ -55,6 +55,9 @@ class PersistCharacterPayload(pydantic.BaseModel):
|
||||
|
||||
is_player: bool = False
|
||||
|
||||
generate_example_dialogue: bool = False
|
||||
example_dialogue_instructions: str = ""
|
||||
|
||||
|
||||
class AssignVoiceToCharacterPayload(pydantic.BaseModel):
|
||||
character_name: str
|
||||
|
||||
@@ -725,8 +725,8 @@
|
||||
- path: user-guide/scenario-tools.md
|
||||
title: Scene Tools
|
||||
summary: The scene tools toolbar and agent activity bar. Covers actor actions, director actions, narrator actions, advancing
|
||||
time, world state actions, creative tools (e.g. taking characters in/out of the scene), the visualizer menu, and saving,
|
||||
with Ctrl/Cmd modifier click behavior.
|
||||
time, world state actions, creative tools (e.g. taking characters in/out of the scene, introducing new characters with
|
||||
optional example dialogue generation and guidance), the visualizer menu, and saving, with Ctrl/Cmd modifier click behavior.
|
||||
- path: user-guide/scene-directory.md
|
||||
title: Scene Directory Structure
|
||||
summary: Layout of a scene's project directory under scenes/, save .json files, and the assets/, changelog/, nodes/, templates/,
|
||||
@@ -805,7 +805,8 @@
|
||||
- path: user-guide/world-editor/characters/create.md
|
||||
title: Character creation
|
||||
summary: Creating a new character with the Create Character button, the AI generation toggle and instructions field, name,
|
||||
description, generate-attributes option, and the Controlled by Player switch.
|
||||
description, generate-attributes option, the generate-example-dialogue option with its optional guidance field, and the
|
||||
Controlled by Player switch.
|
||||
- path: user-guide/world-editor/characters/deactivate.md
|
||||
title: Activate and Deactivate Characters
|
||||
summary: Temporarily removing a character from dialogue generation with the Deactivate button and bringing them back with
|
||||
|
||||
@@ -34,6 +34,11 @@ Each dialogue example should be formatted as:
|
||||
|
||||
You are limited to {{ focal.max_calls }} {% if focal.max_calls == 1 %}dialogue example{% else %}dialogue examples{% endif %}, indicate that you understand this limitation by clearly stating how many examples you are allowed to add.
|
||||
|
||||
{% if instructions %}
|
||||
**User-provided guidance for the dialogue examples:**
|
||||
{{ instructions }}
|
||||
{% endif -%}
|
||||
|
||||
{% if existing_examples %}
|
||||
**Existing dialogue examples (for reference only):**
|
||||
{% for example in existing_examples -%}
|
||||
|
||||
@@ -108,6 +108,12 @@
|
||||
<v-textarea v-model="newIntroduction.narrate_entry_direction" :label="`Narration direction for ${newIntroduction.name || 'the character'}\'s entry into the scene`" rows="4" auto-grow hide-details></v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-checkbox v-model="newIntroduction.generate_example_dialogue" label="Generate example dialogue" color="primary" messages="Generate a few examples of how the character speaks."></v-checkbox>
|
||||
<v-textarea v-if="newIntroduction.generate_example_dialogue" v-model="newIntroduction.example_dialogue_instructions" label="Example dialogue guidance" class="mt-2" rows="2" auto-grow placeholder="Speaks in short sentences, dry humor ..." hint="Optional guidance for how the example dialogue should be generated."></v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-checkbox v-if="newIntroduction.templates.length > 0" v-model="newIntroduction.augment_attributes_enabled" label="Augment attributes" color="primary" messages="If your template selection includes character attributes, then this option will augment the character sheet with some additional attributes that are not already present."></v-checkbox>
|
||||
@@ -198,6 +204,8 @@ export default {
|
||||
determine_name: true,
|
||||
narrate_entry: true,
|
||||
narrate_entry_direction: "",
|
||||
generate_example_dialogue: false,
|
||||
example_dialogue_instructions: "",
|
||||
augment_attributes_enabled: false,
|
||||
augment_attributes: "Add some additional, interesting attributes that are not already present in the character sheet."
|
||||
},
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
|
||||
<div v-if="character.generation_context.enabled">
|
||||
<v-checkbox density="compact" :disabled="busy" v-model="character.generation_context.generateAttributes" label="Generate attributes" messages="Generate a few attributes based on the instructions and the description."></v-checkbox>
|
||||
<v-checkbox density="compact" :disabled="busy" v-model="character.generation_context.generateExampleDialogue" label="Generate example dialogue" messages="Generate a few examples of how the character speaks."></v-checkbox>
|
||||
<v-textarea
|
||||
v-if="character.generation_context.generateExampleDialogue"
|
||||
:disabled="busy"
|
||||
v-model="character.generation_context.exampleDialogueInstructions"
|
||||
label="Example dialogue guidance"
|
||||
class="mt-2"
|
||||
auto-grow rows="2"
|
||||
placeholder="Speaks in short sentences, dry humor ..."
|
||||
hint="Optional guidance for how the example dialogue should be generated.">
|
||||
</v-textarea>
|
||||
</div>
|
||||
|
||||
<v-checkbox density="compact" :disabled="!canBePlayer || busy" v-model="character.is_player" label="Controlled by the player" hide-details></v-checkbox>
|
||||
@@ -118,6 +129,8 @@ export default {
|
||||
enabled: true,
|
||||
instructions: "",
|
||||
generateAttributes: true,
|
||||
generateExampleDialogue: false,
|
||||
exampleDialogueInstructions: "",
|
||||
},
|
||||
description: "",
|
||||
name: "",
|
||||
@@ -187,6 +200,8 @@ export default {
|
||||
enabled: true,
|
||||
instructions: "",
|
||||
generateAttributes: true,
|
||||
generateExampleDialogue: false,
|
||||
exampleDialogueInstructions: "",
|
||||
},
|
||||
description: "",
|
||||
name: "",
|
||||
@@ -227,6 +242,8 @@ export default {
|
||||
determine_name: this.character.generation_context.enabled && !this.character.name,
|
||||
narrate_entry: false,
|
||||
generate_attributes: this.character.generation_context.generateAttributes,
|
||||
generate_example_dialogue: this.character.generation_context.generateExampleDialogue,
|
||||
example_dialogue_instructions: this.character.generation_context.exampleDialogueInstructions,
|
||||
augment_attributes: "Add some additional, interesting attributes that are not already present in the character sheet.",
|
||||
is_player: this.character.is_player,
|
||||
description: this.character.description,
|
||||
|
||||
@@ -497,6 +497,67 @@ class TestCreatorDialogueExamplesMethod:
|
||||
"creator.determine-character-dialogue-examples"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_character_dialogue_examples_passes_instructions(
|
||||
self, active_context, mock_scene
|
||||
):
|
||||
"""User-provided guidance is handed to the Focal handler as template context."""
|
||||
creator = active_context
|
||||
character = mock_scene.get_character("Elena")
|
||||
|
||||
with patch("talemate.agents.creator.character.focal.Focal") as MockFocal:
|
||||
mock_focal_instance = Mock()
|
||||
mock_focal_instance.request = AsyncMock()
|
||||
mock_focal_instance.context = {}
|
||||
MockFocal.return_value = mock_focal_instance
|
||||
|
||||
await creator.determine_character_dialogue_examples(
|
||||
character=character,
|
||||
text="Elena speaks softly.",
|
||||
instructions="Give her a dry sense of humor.",
|
||||
max_examples=3,
|
||||
)
|
||||
|
||||
kwargs = MockFocal.call_args.kwargs
|
||||
assert kwargs["instructions"] == "Give her a dry sense of humor."
|
||||
assert kwargs["max_calls"] == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_character_dialogue_examples_renders_instructions(
|
||||
self, active_context, mock_scene
|
||||
):
|
||||
"""Guidance lands in the rendered prompt (real Focal + real template)."""
|
||||
creator = active_context
|
||||
character = mock_scene.get_character("Elena")
|
||||
creator.client.send_prompt.return_value = '{"calls": []}'
|
||||
|
||||
await creator.determine_character_dialogue_examples(
|
||||
character=character,
|
||||
text="Elena speaks softly.",
|
||||
instructions="Give her a dry sense of humor.",
|
||||
)
|
||||
|
||||
prompt_text = str(creator.client.send_prompt.call_args_list[0][0][0])
|
||||
assert "User-provided guidance for the dialogue examples" in prompt_text
|
||||
assert "Give her a dry sense of humor." in prompt_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_character_dialogue_examples_no_instructions_section(
|
||||
self, active_context, mock_scene
|
||||
):
|
||||
"""Without guidance the section is omitted from the rendered prompt."""
|
||||
creator = active_context
|
||||
character = mock_scene.get_character("Elena")
|
||||
creator.client.send_prompt.return_value = '{"calls": []}'
|
||||
|
||||
await creator.determine_character_dialogue_examples(
|
||||
character=character,
|
||||
text="Elena speaks softly.",
|
||||
)
|
||||
|
||||
prompt_text = str(creator.client.send_prompt.call_args_list[0][0][0])
|
||||
assert "User-provided guidance for the dialogue examples" not in prompt_text
|
||||
|
||||
|
||||
class TestCreatorScenarioMethods:
|
||||
"""Tests for scenario creation methods."""
|
||||
|
||||
@@ -11,15 +11,18 @@ Covers:
|
||||
- persist_characters_from_worldstate: skip logic (excluded names + names
|
||||
already in scene). The actual persist_character call is replaced with a
|
||||
fake on the agent instance to keep the test focused on iteration.
|
||||
|
||||
The full LLM/persist_character pipeline is not exercised because it depends
|
||||
on a wide spectrum of agent collaborators — that's an integration test, not
|
||||
a unit test.
|
||||
- persist_character: the example dialogue step (opt-in flag, guidance and
|
||||
count forwarding, skipped by default) with the creator's LLM-backed
|
||||
methods patched, plus the PersistCharacterPayload/signature parity guard
|
||||
and the early-error path when the name already exists. The remaining
|
||||
LLM-backed steps (name/attributes/description generation) are bypassed
|
||||
via flags rather than exercised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
import inspect
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -27,6 +30,11 @@ from conftest import MockScene, bootstrap_scene
|
||||
|
||||
import talemate.agents.tts.voice_library as voice_library_mod
|
||||
import talemate.instance as instance
|
||||
from talemate.agents.director.character_management import (
|
||||
PERSIST_CHARACTER_EXAMPLE_DIALOGUE_COUNT,
|
||||
CharacterManagementMixin,
|
||||
)
|
||||
from talemate.agents.director.websocket_handler import PersistCharacterPayload
|
||||
from talemate.agents.tts.schema import Voice # noqa: F401
|
||||
from talemate.character import Character
|
||||
from talemate.world_state import CharacterState
|
||||
@@ -392,6 +400,85 @@ class TestPersistCharactersFromWorldstate:
|
||||
assert result[0].name == "X"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# persist_character — example dialogue generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistCharacterExampleDialogue:
|
||||
"""Cover the example dialogue step of persist_character.
|
||||
|
||||
Creator collaborators are patched with AsyncMocks; the flags passed to
|
||||
persist_character (determine_name=False, generate_attributes=False,
|
||||
description set, narrate_entry=False) skip every other LLM-backed step.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def patched_creator(self, scene):
|
||||
creator = instance.get_agent("creator")
|
||||
with (
|
||||
patch.object(
|
||||
creator,
|
||||
"determine_character_dialogue_instructions",
|
||||
AsyncMock(return_value="speaks softly"),
|
||||
),
|
||||
patch.object(
|
||||
creator,
|
||||
"determine_character_dialogue_examples",
|
||||
AsyncMock(return_value=['Nyx: "Well, that went great." rolls eyes']),
|
||||
) as mock_examples,
|
||||
):
|
||||
yield mock_examples
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generates_examples_with_guidance(
|
||||
self, scene, director, patched_creator
|
||||
):
|
||||
character = await director.persist_character(
|
||||
name="Nyx",
|
||||
content="A mysterious stranger",
|
||||
determine_name=False,
|
||||
generate_attributes=False,
|
||||
description="Already described.",
|
||||
narrate_entry=False,
|
||||
generate_example_dialogue=True,
|
||||
example_dialogue_instructions="Dry humor, short sentences.",
|
||||
)
|
||||
|
||||
assert character is not None
|
||||
patched_creator.assert_awaited_once()
|
||||
kwargs = patched_creator.await_args.kwargs
|
||||
assert kwargs["text"] == "A mysterious stranger"
|
||||
assert kwargs["instructions"] == "Dry humor, short sentences."
|
||||
assert kwargs["max_examples"] == PERSIST_CHARACTER_EXAMPLE_DIALOGUE_COUNT
|
||||
assert character.example_dialogue == [
|
||||
'Nyx: "Well, that went great." rolls eyes'
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skipped_by_default(self, scene, director, patched_creator):
|
||||
character = await director.persist_character(
|
||||
name="Vex",
|
||||
content="A quiet merchant",
|
||||
determine_name=False,
|
||||
generate_attributes=False,
|
||||
description="Already described.",
|
||||
narrate_entry=False,
|
||||
)
|
||||
|
||||
assert character is not None
|
||||
patched_creator.assert_not_awaited()
|
||||
assert not character.example_dialogue
|
||||
|
||||
def test_payload_fields_match_persist_character_signature(self):
|
||||
"""Every field the websocket payload exposes must be a persist_character
|
||||
kwarg — guards backend/frontend parity when either side changes."""
|
||||
params = set(
|
||||
inspect.signature(CharacterManagementMixin.persist_character).parameters
|
||||
) - {"self"}
|
||||
assert set(PersistCharacterPayload.model_fields).issubset(params)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# persist_character — early-error path when name already exists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user