impl granular perspective configuration and persp[ecftive presets

This commit is contained in:
vegu-ai-tools
2026-05-16 01:57:05 +03:00
parent 06c789b62c
commit 4dda00bc4f
22 changed files with 958 additions and 88 deletions

View File

@@ -2,7 +2,9 @@
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."
- "Scene Perspective Overrides: Expanded the scene outline perspective into a default plus three per-speaker overrides (player, NPCs, narrator). Each override falls back to the default when empty. A new `{player_name}` placeholder is substituted at prompt render time and suppresses the perspective when the scene has no explicit player character. Presets are managed under Settings → Creator → Perspective Presets. Existing scenes are migrated automatically."
improvements:
- "Settings UX: Renamed the Creator sub-tab from 'Content Context' to 'Content Classification' to match the field label used in the scene outline editor. List contents and behavior are unchanged."
- "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."
- "Pydantic Migration: Internal data models across the codebase converted to pydantic for stricter validation. No user-visible behavior changes."
- "Character Sheet: Removed the read-only Character Sheet dialog and its button from the World State panel. The Manage character button (World State Manager) already covers viewing and editing character details."

View File

@@ -0,0 +1,52 @@
# :material-cube-scan: Creator
The **Creator** tab in the application settings holds shared building blocks that are offered as picker options elsewhere in the UI. Open it from **Settings** (the cogwheel in the top navigation) and switch to the **Creator** tab.
Two sub-tabs live here:
- **Content Classification** — the list of content classification strings offered when you set up a scene.
- **Perspective Presets** — the list of narrative perspective / tense strings offered in the scene outline.
Both lists are global to your Talemate installation. Editing them does not change any existing scene — your scenes keep whatever value they were saved with. The lists only affect what appears as suggestions in the pickers next time you edit a field.
## Perspective Presets
!!! info "Added in 0.38.0"
The presets you add here show up in every **Perspective and tense** combobox in the scene outline (default plus the three per-speaker overrides). They are convenience suggestions, not constraints — you can always type a custom value into any of the four fields if no preset fits.
![Creator settings with the Perspective Presets sub-tab selected](/talemate/img/0.38.0/app-settings-creator-perspective-presets.png)
### Adding a preset
1. Type the perspective string into the **Add perspective preset** text field at the bottom of the list.
2. Press **Enter** to add it.
The new entry is appended to the list and becomes available immediately to every scene outline combobox.
### Removing a preset
Click the red :material-close-box-outline: icon next to a preset to remove it from the list. Removing a preset does not change scenes that already use that value — it just stops suggesting it.
### The `{player_name}` placeholder
Any preset that contains the literal string `{player_name}` will have that substring replaced by the active player character's name at prompt render time. This lets you share presets across scenes without having to rewrite them for each protagonist.
For example, the preset:
> First person, present tense, from {player_name}'s POV.
will be rendered as `First person, present tense, from Annabelle's POV.` in a scene whose player character is named Annabelle, and as `First person, present tense, from Marcus's POV.` in a scene whose player character is named Marcus.
If a scene has no explicit player character, perspectives that use `{player_name}` are **suppressed** rather than substituted — the placeholder has no anchor to point at. Use a non-placeholder preset for scenes without a player character.
### Default presets
Talemate ships with a starter list covering the most common configurations (third / first / second person, past and present tense, omniscient and player-anchored variants). For an explanation of what each default preset means in plain English with a sample of the prose style it produces, see the [Preset reference in the scene outline docs](../world-editor/scene/outline.md#what-each-default-preset-means).
Treat these as a starting point — add your own house styles (for example a preset that pins POV to a specific named character, or one that includes a tone hint like *Third person limited, past tense, hard-boiled noir voice.*) so they appear in every new scene you create.
## Related
- [Scene Outline — Perspective and Tense](../world-editor/scene/outline.md#perspective-and-tense) — where the presets are consumed.
- [System Prompt Overrides](system-prompts.md) — for changing the AI's general role and approach (writing style and tense belong in the scene outline, not in a system prompt).

View File

@@ -80,7 +80,7 @@ Never acknowledge that characters are fictional or written by an AI. Characters
Talemate then sends the full built-in Conversation system prompt followed by your extra instruction.
!!! note "What belongs in a system prompt"
System prompts shape the AI's role and general approach — things that should apply across every scene. Writing style, tense, and scene-specific tone live in the scene's [perspective field](../world-editor/scene/outline.md#perspective-and-tense) and writing-style settings, not here.
System prompts shape the AI's role and general approach — things that should apply across every scene. Writing style, tense, and scene-specific tone live in the scene's [perspective fields](../world-editor/scene/outline.md#perspective-and-tense) and writing-style settings, not here.
Points to know:

View File

@@ -31,24 +31,125 @@ You can type in a value or pick something from the list.
### Perspective and Tense
The narrative perspective and tense for the story (for example `Third person limited, past tense` or `Second person, present tense`).
!!! info "Updated in 0.38.0"
The single perspective field was replaced by a set of four configurable perspectives: a scene-wide default plus three per-speaker overrides (player, NPCs, narrator). The `{player_name}` placeholder was also added. Scenes saved with the older field are migrated automatically — their value becomes the new **default**.
When set, the value is included in the context sent to the AI for narration, dialogue, and autocomplete prompts, so the model knows which point of view and tense to write in.
The narrative perspective and tense for the story — for example `Third person limited, past tense` or `Second person, present tense`. When set, the value is injected into the context the AI sees for narration, dialogue, and autocomplete prompts, so the model knows which point of view and tense to write in.
The field is free-form text. Leave it blank if you don't need to specify one — prompts will simply omit the line.
A perspective usually combines three things:
![Scene outline perspective and tense field](/talemate/img/0.37.0/scene-outline-perspective-field.png)
- **Person** — *who* the prose talks about and *how* it refers to them.
- **First person** uses "I"/"me"/"my" — the narrator *is* the character. *"I walked up to the door."*
- **Second person** uses "you" — the narrator speaks *to* the reader as if they are the character. *"You walk up to the door."* Common in interactive fiction and choose-your-own-adventure styles.
- **Third person** uses "he"/"she"/"they" — the narrator describes the characters from outside. *"She walked up to the door."*
- **Tense** — *when* the action is happening relative to the telling.
- **Past tense** reads like a recollection: "walked", "said", "thought". *"She walked up to the door and knocked."*
- **Present tense** reads like a live broadcast: "walks", "says", "thinks". *"She walks up to the door and knocks."*
- **Point of view (POV)** — *how much* the narrator knows.
- **Limited** — the narrator is tethered to one character's head and only knows what *they* know. Other characters' thoughts and off-screen events are off-limits. Usually anchored to a named character (often the player).
- **Omniscient** — the narrator floats above the scene and can dip into anyone's thoughts, describe off-screen events, or foreshadow what's coming.
##### Examples
![Scene outline perspective fields with the override panel expanded](/talemate/img/0.38.0/scene-outline-perspective-overrides.png)
> Third person limited, past tense
Talemate exposes the perspective configuration as four separate fields:
> Second person, present tense
| Field | When it's used |
|---|---|
| **Perspective and tense** (default) | Used everywhere the scene needs perspective context, and as the fallback for any of the three role-specific slots below when they are empty. |
| **Perspective (you / player character)** | Used when the *player character* is the one speaking or acting in a dialogue prompt. |
| **Perspective (others / NPCs)** | Used when a *non-player character* is speaking or acting. |
| **Perspective (narrator)** | Used for narrator agent prompts — scene description, story progression, character entry / exit, time passage, and so on. |
> First person past tense, from Annabelle's point of view
The three role-specific fields live behind the **Per-speaker perspective overrides** expansion panel next to the default field. A small chip on the panel header tells you how many overrides are set.
!!! note "Exposed as a context ID"
This field is also available as the `story_configuration:perspective` context ID, which means it can be referenced or updated by features that work with context IDs (for example the [Context Database](/talemate/user-guide/world-editor/context-db)).
#### Choosing or typing a value
Each of the four fields is a combobox. You can:
- **Pick a preset** from the dropdown.
- **Type a custom value** — the field accepts any free-form text and your custom entry is saved with the scene whether or not it matches a preset.
The dropdown list is shared by all four fields and is managed in **Settings → Creator → Perspective Presets**. See [Creator settings](../../app-settings/creator.md#perspective-presets) for how to add, remove, or rename presets.
#### The `{player_name}` placeholder
Any field that contains the literal string `{player_name}` will have that substring replaced by the active player character's name at prompt render time. This means presets like:
> First person, present tense, from {player_name}'s POV.
work for every scene without you having to edit them, and they survive renaming the player character.
If the scene has no explicit player character, perspectives that reference `{player_name}` are **suppressed** rather than substituted — the placeholder has no anchor to point at, so injecting it would produce broken prose like *"Talking to the player."*. Pick a non-placeholder preset for scenes without a player character.
#### Fallback semantics
The four fields cascade in a predictable way:
- An empty role-specific override falls back to the **default**.
- An empty default means *no perspective line is injected at all* — the AI is left to match whatever tense and POV the existing scene history establishes (the dialogue prompt has a short instruction that handles this case automatically).
You only need to fill in the fields that matter for your scene. Most scenes only need the default.
#### What each default preset means
This table lists the presets that ship with Talemate, what each one means in plain English, and a tiny sample of the kind of prose it produces (using `Annabelle` as the player character).
| Preset | What it means | Sample prose |
|---|---|---|
| `Third person limited, past tense.` | Outside narrator using "she"/"he"/"they". Past tense ("walked"). Anchored to one character — usually whoever the scene establishes as the POV character. | *"Annabelle walked up to the porch. The door looked older than the rest of the house."* |
| `Third person limited, past tense, focused on {player_name}'s POV.` | Same as above, but explicitly tells the AI the player character is the anchor — the narration can read *her* thoughts, but not the NPC's. | *"Annabelle walked up to the porch. Something about the door felt wrong, though she couldn't say why."* |
| `Third person limited, present tense.` | Outside narrator using "she"/"he"/"they". Present tense ("walks"). Reads like a live broadcast. | *"Annabelle walks up to the porch. The door looks older than the rest of the house."* |
| `Third person limited, present tense, focused on {player_name}'s POV.` | Same as above, anchored to the player character. | *"Annabelle walks up to the porch. The door looks wrong to her, somehow."* |
| `Third person omniscient, past tense.` | Outside narrator using "she"/"he"/"they", past tense. The narrator knows *everything* — NPC thoughts, off-screen events, things the player character can't see. | *"Annabelle walked up to the porch, unaware that someone was watching her from an upstairs window."* |
| `Third person omniscient, present tense.` | Same as above, in present tense. | *"Annabelle walks up to the porch. Inside, the homeowner watches her approach through the curtains."* |
| `First person, past tense, from {player_name}'s POV.` | The *player character herself* narrates, using "I"/"me"/"my". Past tense — reads like a journal entry or recollection. | *"I walked up to the porch. The door looked older than the rest of the house."* |
| `First person, present tense, from {player_name}'s POV.` | The *player character herself* narrates, using "I"/"me"/"my". Present tense — reads like an unfolding inner monologue. | *"I walk up to the porch. The door looks older than the rest of the house."* |
| `Second person, present tense.` | The narrator addresses an unnamed "you" — classic interactive-fiction voice. Present tense. | *"You walk up to the porch. The door looks older than the rest of the house."* |
| `Second person, present tense. Talking to {player_name}.` | Same as above, but the narrator explicitly knows the player character's name and may use it. | *"You walk up to the porch, Annabelle. The door looks older than the rest of the house."* |
You are not limited to these — type anything into the field and it will be passed to the AI verbatim. The presets are just shortcuts for the most common configurations. See [Creator settings → Perspective Presets](../../app-settings/creator.md#perspective-presets) for how to add your own.
#### Common patterns
**Standard novel (third person limited, single POV character)**
| Field | Value |
|---|---|
| Default | `Third person limited, past tense, focused on {player_name}'s POV.` |
| Player | *empty* |
| Other | *empty* |
| Narrator | *empty* |
**Interactive fiction / "you are the protagonist"**
| Field | Value |
|---|---|
| Default | *empty* |
| Player | *empty* |
| Other | *empty* |
| Narrator | `Second person, present tense. Talking to {player_name}.` |
The narrator drives the whole scene in second person and no perspective line is added to dialogue or analysis prompts.
**Mixed POV — first person for the player, third for everyone else**
| Field | Value |
|---|---|
| Default | *empty* |
| Player | `First person, present tense, from {player_name}'s POV.` |
| Other | *empty* |
| Narrator | `Third person limited, past tense, focused on {player_name}'s POV.` |
When the player character is acting they get the first-person voice; the narrator and NPC dialogue stay in third person limited.
!!! note "Exposed as context IDs"
The perspective fields are also available as context IDs for features that work with context IDs (for example the [Context Database](/talemate/user-guide/world-editor/context-db)):
- `story_configuration:perspective` — alias for the default field (kept for backwards compatibility).
- `story_configuration:perspective.default`
- `story_configuration:perspective.player`
- `story_configuration:perspective.other`
- `story_configuration:perspective.narrator`
### Description

View File

@@ -144,7 +144,7 @@ class CreatePlan(Node):
# Set scene perspective from plan meta if available
perspective = meta.get("perspective")
if perspective:
scene.perspective = perspective
scene.perspectives.default = perspective
log.info("plan.set_perspective", perspective=perspective)
# Link plan to the active director chat via context
@@ -297,9 +297,7 @@ class GetActivePlan(Node):
# Fall back to scene perspective or a sensible default
if not perspective:
scene = active_scene.get()
perspective = (
getattr(scene, "perspective", "") or "Third person, past tense."
)
perspective = scene.perspectives.default or "Third person, past tense."
self.set_output_values(
{

View File

@@ -70,24 +70,39 @@ def set_processing(fn):
@wraps(fn)
async def narration_wrapper(self, *args, **kwargs):
agent_context = active_agent.get()
emission: NarratorAgentEmission = NarratorAgentEmission(agent=self)
# ActiveAgent.__enter__ shares state by reference with the parent context,
# so we save/restore the prior value rather than mutating in place.
_UNSET = object()
previous_speaker_role = agent_context.state.get("speaker_role", _UNSET)
agent_context.state["speaker_role"] = "narrator"
try:
emission: NarratorAgentEmission = NarratorAgentEmission(agent=self)
if self.content_use_writing_style:
self.set_context_states(writing_style=self.scene.writing_style)
if self.content_use_writing_style:
self.set_context_states(writing_style=self.scene.writing_style)
await talemate.emit.async_signals.get("agent.narrator.before_generate").send(
emission
)
await talemate.emit.async_signals.get(
"agent.narrator.inject_instructions"
).send(emission)
await talemate.emit.async_signals.get(
"agent.narrator.before_generate"
).send(emission)
await talemate.emit.async_signals.get(
"agent.narrator.inject_instructions"
).send(emission)
agent_context.state["dynamic_instructions"] = emission.dynamic_instructions
agent_context.state["dynamic_instructions"] = (
emission.dynamic_instructions
)
response = await fn(self, *args, **kwargs)
emission.response = response
await talemate.emit.async_signals.get("agent.narrator.generated").send(emission)
return emission.response
response = await fn(self, *args, **kwargs)
emission.response = response
await talemate.emit.async_signals.get("agent.narrator.generated").send(
emission
)
return emission.response
finally:
if previous_speaker_role is _UNSET:
agent_context.state.pop("speaker_role", None)
else:
agent_context.state["speaker_role"] = previous_speaker_role
return narration_wrapper

View File

@@ -230,6 +230,18 @@ class CreatorConfig(pydantic.BaseModel):
content_context: list[str] = [
"a fun and engaging slice of life story aimed at an adult audience."
]
perspective_presets: list[str] = [
"Third person limited, past tense.",
"Third person limited, past tense, focused on {player_name}'s POV.",
"Third person limited, present tense.",
"Third person limited, present tense, focused on {player_name}'s POV.",
"Third person omniscient, past tense.",
"Third person omniscient, present tense.",
"First person, past tense, from {player_name}'s POV.",
"First person, present tense, from {player_name}'s POV.",
"Second person, present tense.",
"Second person, present tense. Talking to {player_name}.",
]
class OpenAIConfig(pydantic.BaseModel):

View File

@@ -107,7 +107,12 @@ register_context_id_meta(
),
ContextIDMeta(
context_id="story_configuration:perspective",
description="The narrative perspective and tense for the story (e.g., 'Third person limited, past tense').",
description="The narrative perspective and tense for the story (e.g., 'Third person limited, past tense'). Alias for `story_configuration:perspective.default`.",
permanent=True,
),
ContextIDMeta(
context_id="story_configuration:perspective.<role>",
description="Role-specific narrative perspective override. Replace <role> with one of: 'default' (scene-wide), 'player' (player character's POV), 'other' (NPCs), 'narrator' (narration). Empty role-specific overrides fall back to 'default'.",
permanent=True,
),
ContextIDMeta(
@@ -147,6 +152,9 @@ register_context_id_meta(
)
PERSPECTIVE_ROLES: tuple[str, ...] = ("default", "player", "other", "narrator")
@register_context_id_type
class StoryConfigurationContextID(ContextID):
context_type: ClassVar[str] = "story_configuration"
@@ -197,6 +205,21 @@ class DirectorInstructionsContextID(StoryConfigurationContextID):
class ScenePerspectiveContextID(StoryConfigurationContextID):
key: ClassVar[str] = "perspective"
@classmethod
def make(
cls, role: str | None = None, **kwargs
) -> "ScenePerspectiveContextID":
path = ["perspective"]
if role and role != "default":
path.append(role)
return cls(path=path)
@property
def role(self) -> str:
if len(self.path) >= 2 and self.path[1]:
return self.path[1]
return "default"
class CharacterListContextID(StoryConfigurationContextID):
key: ClassVar[str] = "character_list"
@@ -219,6 +242,7 @@ class StoryConfigurationContextItem(ContextIDItem):
"character_list",
]
name: str
role: str | None = None
@property
def context_id(
@@ -252,7 +276,7 @@ class StoryConfigurationContextItem(ContextIDItem):
if self.context_type == "scene_type":
return SceneTypeContextID.make()
if self.context_type == "perspective":
return ScenePerspectiveContextID.make()
return ScenePerspectiveContextID.make(role=self.role)
if self.context_type == "character_list":
return CharacterListContextID.make()
@@ -270,7 +294,10 @@ class StoryConfigurationContextItem(ContextIDItem):
"perspective": "Narrative Perspective",
"character_list": "List of All Characters (active and inactive)",
}
return mapping.get(self.context_type, self.name)
label = mapping.get(self.context_type, self.name)
if self.context_type == "perspective" and self.role and self.role != "default":
return f"{label} ({self.role.title()})"
return label
async def get(self, scene: "Scene") -> str | None:
if self.context_type == "title":
@@ -295,7 +322,7 @@ class StoryConfigurationContextItem(ContextIDItem):
except Exception:
return None
if self.context_type == "perspective":
return scene.perspective
return scene.perspectives.for_role(self.role or "default")
if self.context_type == "character_list":
characters = await list_characters(scene)
return json.dumps([item.model_dump() for item in characters])
@@ -338,7 +365,10 @@ class StoryConfigurationContextItem(ContextIDItem):
scene.intent_state.phase.scene_type = scene_type_id
intent_changed = True
elif self.context_type == "perspective":
scene.perspective = value or ""
role = self.role or "default"
if role not in PERSPECTIVE_ROLES:
raise ContextIDHandlerError(f"Invalid perspective role: {role}")
setattr(scene.perspectives, role, value or "")
elif self.context_type == "character_list":
raise ContextIDItemReadOnly(self.context_id.path_to_str)
@@ -576,10 +606,14 @@ class StoryConfigurationContext(ContextIDHandler):
),
)
if key == "perspective":
role = path[1] if len(path) > 1 and path[1] else "default"
if role not in PERSPECTIVE_ROLES:
return None
return StoryConfigurationContextItem(
context_type="perspective",
name="perspective",
value=scene.perspective,
role=role,
value=scene.perspectives.for_role(role),
)
if key == "character_list":
return StoryConfigurationContextItem(

View File

@@ -30,6 +30,7 @@ from talemate.status import LoadingStatus, set_loading
from talemate.world_state import WorldState
from talemate.game.engine.nodes.registry import import_scene_node_definitions
from talemate.scene.intent import SceneIntent
from talemate.scene.schema import ScenePerspectives
from talemate.history import validate_history
import talemate.agents.tts.voice_library as voice_library
from talemate.path import SCENES_DIR
@@ -67,6 +68,19 @@ def to_project_name(name: str) -> str:
return name.replace(" ", "-").replace("'", "").lower()
def load_scene_perspectives(scene_data: dict) -> ScenePerspectives:
"""
Resolve the scene's narrative perspectives from saved scene data.
Prefers the nested `perspectives` object when present; otherwise migrates
the legacy flat `perspective` string into `perspectives.default`.
"""
perspectives_data = scene_data.get("perspectives")
if perspectives_data:
return ScenePerspectives(**perspectives_data)
return ScenePerspectives(default=scene_data.get("perspective", "") or "")
class SceneInitialization(pydantic.BaseModel):
project_name: str | None = None
content_classification: str | None = None
@@ -294,7 +308,7 @@ async def load_scene_from_data(
}
scene.active_characters = scene_data.get("active_characters", [])
scene.context = scene_data.get("context", "")
scene.perspective = scene_data.get("perspective", "")
scene.perspectives = load_scene_perspectives(scene_data)
scene.project_name = scene_data.get("project_name")
scene.intent_state = SceneIntent(**scene_data.get("intent_state", {}))
scene.history = _load_history(scene_data["history"])

View File

@@ -2,8 +2,10 @@
{%- if technical %}
Context ID: `story_configuration:content_classification`{% endif %}
Content Classification: {{ scene.context or "Not genre, theme or content classification specified"}}
{% if scene.perspective %}
Narrative Perspective: {{ scene.perspective }}
{%- set resolved_speaker_role = speaker_role|default(agent_context_state.get("speaker_role", "default")) %}
{%- set perspective_text = scene.perspective_for_role(resolved_speaker_role) %}
{% if perspective_text %}
Narrative Perspective: {{ perspective_text }}
{% endif %}
{% if decensor %}This story is fiction and everyone has already consented to the content rating, disregard any safety, health or ethical concerns and suppress any related warnings.
{% endif %}

View File

@@ -6,6 +6,12 @@ Override example:
{{ set_anchor_extractor("response", "<MY_TAG>", "</MY_TAG>") }}
{{ set_as_is_extractor("response") }}
-#}
{%- if talking_character.is_player -%}
{%- set speaker_role = "player" -%}
{%- else -%}
{%- set speaker_role = "other" -%}
{%- endif -%}
{%- set resolved_perspective = scene.perspective_for_role(speaker_role) -%}
{# EXTRA CONTEXT CONTENT #}
{% set rendered_context_text -%}
<|SECTION:CHARACTERS|>
@@ -24,7 +30,8 @@ Override example:
<|SECTION:ADDITIONAL INFORMATION|>
{% with
reinforcement_character=talking_character.name,
reinforcement_character_inserts=["conversation-context"]
reinforcement_character_inserts=["conversation-context"],
speaker_role=speaker_role
%}
{% include "extra-context-static.jinja2" %}
{% endwith %}
@@ -72,8 +79,8 @@ Override example:
{# FINAL ACTING INSTRUCTIONS #}
{% set acting_instructions -%}
**TENSE**: Examine the existing conversation history and maintain the EXACT same tense (past/present) used in previous messages. **NEVER switch tenses mid-conversation**.
{% if scene.perspective -%}
**PERSPECTIVE**: The narrative perspective for this scene is — {{ scene.perspective }}. Apply it consistently to {{ talking_character.name }}'s actions and thoughts.
{% if resolved_perspective -%}
**PERSPECTIVE**: The narrative perspective for this scene is — {{ resolved_perspective }}. Apply it consistently to {{ talking_character.name }}'s actions and thoughts.
{%- else -%}
**PERSPECTIVE**: Match the narrative perspective of the existing story. If the story uses third person ("he walked", "she said"), write {{ talking_character.name }}'s actions and thoughts in third person. If the existing story has already established {{ talking_character.name }} as a first-person narrator ("I walked"), keep that voice for them. Do not switch a character to first person just because it is their turn — only characters the scene has already established as first-person narrators may use that voice.
{%- endif %}

View File

@@ -1,10 +1,47 @@
from typing import Literal
import pydantic
from talemate.world_state import WorldState
from talemate.game.state import GameState
__all__ = ["SceneType", "ScenePhase", "SceneIntent", "SceneState"]
__all__ = [
"SceneType",
"ScenePhase",
"SceneIntent",
"SceneState",
"ScenePerspectives",
"PerspectiveRole",
]
PerspectiveRole = Literal["default", "player", "other", "narrator"]
class ScenePerspectives(pydantic.BaseModel):
"""
Narrative perspective / tense configuration for a scene.
`default` is the scene-wide perspective shown in ambient prompt context.
The other three are speaker-specific overrides: when a prompt is generated
on behalf of the player character, an NPC, or the narrator, the matching
field is preferred over `default`. Empty overrides fall back to `default`.
"""
default: str = ""
player: str = ""
other: str = ""
narrator: str = ""
def for_role(self, role: PerspectiveRole | str | None) -> str:
default = (self.default or "").strip()
if not role or role == "default":
return default
value = getattr(self, role, None)
if value and value.strip():
return value.strip()
return default
def make_default_types() -> list["SceneType"]:

View File

@@ -11,6 +11,7 @@ from talemate.game.schema import ConditionGroup
from talemate.export import ExportOptions, export
from talemate.instance import get_agent
from talemate.world_state.manager import WorldStateManager, Suggestion
from talemate.scene.schema import ScenePerspectives
from talemate.status import background_task, set_loading
import talemate.game.focal as focal
from talemate.config import save_config
@@ -173,7 +174,7 @@ class SceneOutlinePayload(pydantic.BaseModel):
description: str | None = None
intro: str | None = None
context: str | None = None
perspective: str | None = None
perspectives: ScenePerspectives | None = None
class SceneSettingsPayload(pydantic.BaseModel):
@@ -1102,7 +1103,13 @@ class WorldStateManagerPlugin(
async def handle_update_scene_outline(self, data):
payload = SceneOutlinePayload(**data)
await self.world_state_manager.update_scene_outline(**payload.model_dump())
await self.world_state_manager.update_scene_outline(
title=payload.title,
description=payload.description,
intro=payload.intro,
context=payload.context,
perspectives=payload.perspectives,
)
self.websocket_handler.queue_put(
{

View File

@@ -52,6 +52,7 @@ from talemate.game.engine.nodes.core import GraphState
from talemate.game.engine.nodes.layout import load_graph
from talemate.game.engine.nodes.packaging import initialize_packages
from talemate.scene.intent import SceneIntent
from talemate.scene.schema import ScenePerspectives
from talemate.history import emit_archive_add, ArchiveEntry
from talemate.character import Character
from talemate.agents.tts.schema import VoiceLibrary
@@ -175,7 +176,7 @@ class Scene(Emitter):
self.immutable_save = False
self.context = ""
self.perspective = ""
self.perspectives = ScenePerspectives()
self.commands = commands.Manager(self)
self.environment = "scene"
self.world_state = WorldState()
@@ -1214,6 +1215,26 @@ class Scene(Emitter):
"""
self.description = description
def perspective_for_role(self, role: str | None = None) -> str:
"""
Resolve the narrative perspective for a speaker role and substitute
the `{player_name}` placeholder against the current scene state.
Returns "" when no perspective is configured for the role (after
falling back to the default) OR when the resolved value references
`{player_name}` but the scene has no explicit player character — a
perspective that depends on a missing anchor would render as broken
prose, so we suppress it instead. Used by content-classification and
dialogue templates.
"""
value = self.perspectives.for_role(role)
if not value or "{player_name}" not in value:
return value
character = self.get_explicit_player_character()
if character is None:
return ""
return value.replace("{player_name}", character.name)
def get_intro(self, intro: str = None) -> str:
"""
Returns the intro text of the scene
@@ -1321,7 +1342,7 @@ class Scene(Emitter):
"explicit_player_character": self.player_character_exists,
"inactive_characters": list(self.inactive_characters.keys()),
"context": self.context,
"perspective": self.perspective,
"perspectives": self.perspectives.model_dump(),
"assets": self.assets.dict(),
"characters": [actor.character.model_dump() for actor in self.actors],
"character_colors": {

View File

@@ -23,6 +23,7 @@ from talemate.game.schema import ConditionGroup, condition_groups_match
from talemate.game.engine.context_id.base import ContextIDItem
from talemate.agents.tts.schema import Voice
from talemate.game.engine.context_id import ContextID
from talemate.scene.schema import ScenePerspectives
if TYPE_CHECKING:
from talemate.tale_mate import Character, Scene
@@ -1110,14 +1111,18 @@ class WorldStateManager:
description: str | None = None,
intro: str | None = None,
context: str | None = None,
perspective: str | None = None,
perspectives: ScenePerspectives | None = None,
) -> "Scene":
scene = self.scene
scene.title = title
scene.description = description
scene.intro = intro
scene.context = context
scene.perspective = perspective or ""
# `perspectives=None` preserves the existing nested object; clearing the
# four perspective fields is rarely what a partial outline update wants,
# and the frontend always sends a complete `ScenePerspectives` payload.
if perspectives is not None:
scene.perspectives = perspectives
return scene

View File

@@ -367,11 +367,10 @@
</v-col>
<v-col cols="8">
<div v-if="creatorPageSelected === 'content_context'">
<!-- Content for Content context will go here -->
<v-alert color="white" variant="text" icon="mdi-cube-scan" density="compact">
<v-alert-title>Content context</v-alert-title>
<v-alert-title>Content Classification</v-alert-title>
<div class="text-grey">
Available content-context choices when generating characters or scenarios. This can strongly influence the content that is generated.
Available content classification choices when generating characters or scenarios. This can strongly influence the content that is generated.
</div>
</v-alert>
<v-divider class="mb-2"></v-divider>
@@ -383,12 +382,31 @@
</v-list-item>
</v-list>
<v-divider></v-divider>
<v-text-field v-model="content_context_input" label="Add content context (Press enter to add)"
<v-text-field v-model="content_context_input" label="Add content classification (Press enter to add)"
@keyup.enter="app_config.creator.content_context.push(content_context_input); content_context_input = ''"></v-text-field>
</v-col>
</v-row>
</div>
<div v-if="creatorPageSelected === 'perspective_presets'">
<v-alert color="white" variant="text" icon="mdi-eye-outline" density="compact">
<v-alert-title>Perspective Presets</v-alert-title>
<div class="text-grey">
Reusable narrative perspective / tense strings offered in the scene outline. Use <code>{player_name}</code> as a placeholder for the player character — it will be substituted at prompt render time.
</div>
</v-alert>
<v-divider class="mb-2"></v-divider>
<v-row>
<v-col cols="12">
<v-list density="compact">
<v-list-item v-for="(value, index) in app_config.creator.perspective_presets" :key="index">
<v-list-item-title><v-icon color="red-darken-1" class="mr-2" @click="perspectivePresetRemove(index)">mdi-close-box-outline</v-icon>{{ value }}</v-list-item-title>
</v-list-item>
</v-list>
<v-divider></v-divider>
<v-text-field v-model="perspective_preset_input" label="Add perspective preset (Press enter to add)"
@keyup.enter="perspectivePresetAdd()"></v-text-field>
</v-col>
</v-row>
</div>
</v-col>
</v-row>
@@ -438,6 +456,7 @@ export default {
dialog: false,
app_config: null,
content_context_input: '',
perspective_preset_input: '',
navigation: {
game: [
{title: 'General', icon: 'mdi-cog', value: 'general'},
@@ -458,7 +477,8 @@ export default {
{title: 'OpenRouter', icon: 'mdi-api', value: 'openrouter_api'},
],
creator: [
{title: 'Content Context', icon: 'mdi-cube-scan', value: 'content_context'},
{title: 'Content Classification', icon: 'mdi-cube-scan', value: 'content_context'},
{title: 'Perspective Presets', icon: 'mdi-eye-outline', value: 'perspective_presets'},
]
},
gamePageSelected: 'general',
@@ -552,6 +572,20 @@ export default {
this.app_config.creator.content_context.splice(index, 1);
},
perspectivePresetRemove(index) {
this.app_config.creator.perspective_presets.splice(index, 1);
},
perspectivePresetAdd() {
const value = (this.perspective_preset_input || '').trim();
if (!value) return;
if (!Array.isArray(this.app_config.creator.perspective_presets)) {
this.app_config.creator.perspective_presets = [];
}
this.app_config.creator.perspective_presets.push(value);
this.perspective_preset_input = '';
},
handleMessage(message) {
if (message.type == "app_config") {
this.app_config = message.data;

View File

@@ -31,17 +31,73 @@
</v-row>
<v-row>
<v-col cols="12" md="8" lg="6" xl="6">
<v-text-field
v-model="scene.data.perspective"
<v-combobox
:model-value="perspectives.default"
:items="perspectivePresets"
label="Perspective and tense"
hint="The narrative perspective, tense, and point of view for the story. This is included in all narration and dialogue prompts."
:color="dirty['perspective'] ? 'dirty' : ''"
:disabled="busy['perspective']"
:loading="busy['perspective']"
@update:model-value="setFieldDirty('perspective')"
messages="The default narrative perspective, tense, and point of view. Used in all narration and dialogue prompts unless a per-speaker override is set."
:color="dirty['perspectives'] ? 'dirty' : ''"
:disabled="busy['perspectives']"
:loading="busy['perspectives']"
@update:model-value="onPerspectiveSelect('default', $event)"
@blur="update(true)"
placeholder="e.g., Third person limited, past tense / Second person, present tense"
></v-text-field>
placeholder="e.g., Third person limited, past tense"
></v-combobox>
</v-col>
<v-col cols="12" md="8" lg="6" xl="6">
<v-expansion-panels variant="accordion" class="mb-2">
<v-expansion-panel>
<v-expansion-panel-title>
<span>Per-speaker perspective overrides</span>
<v-chip
v-if="overrideCount > 0"
size="x-small"
color="primary"
class="ml-3"
>{{ overrideCount }} set</v-chip>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="text-caption text-medium-emphasis mb-3">
Each override replaces the default perspective for that speaker. Empty fields fall back to the default.
</div>
<v-combobox
:model-value="perspectives.player"
:items="perspectivePresets"
label="Perspective (you / player character)"
messages="Used when the player character is speaking or acting."
:color="dirty['perspectives'] ? 'dirty' : ''"
:disabled="busy['perspectives']"
@update:model-value="onPerspectiveSelect('player', $event)"
@blur="update(true)"
density="comfortable"
class="mb-2"
></v-combobox>
<v-combobox
:model-value="perspectives.other"
:items="perspectivePresets"
label="Perspective (others / NPCs)"
messages="Used when a non-player character is speaking or acting."
:color="dirty['perspectives'] ? 'dirty' : ''"
:disabled="busy['perspectives']"
@update:model-value="onPerspectiveSelect('other', $event)"
@blur="update(true)"
density="comfortable"
class="mb-2"
></v-combobox>
<v-combobox
:model-value="perspectives.narrator"
:items="perspectivePresets"
label="Perspective (narrator)"
messages="Used for narration prompts (scene description, progression, character entry, etc.)."
:color="dirty['perspectives'] ? 'dirty' : ''"
:disabled="busy['perspectives']"
@update:model-value="onPerspectiveSelect('narrator', $event)"
@blur="update(true)"
density="comfortable"
></v-combobox>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
<v-row>
@@ -68,10 +124,10 @@
<v-col cols="12">
<div class="d-flex align-center mb-2 intro-controls">
<v-spacer></v-spacer>
<ContextualGenerate
<ContextualGenerate
ref="contextualGenerate"
uid="wsm.scene_intro"
context="scene intro:scene intro"
context="scene intro:scene intro"
:original="scene.data.intro"
:templates="templates"
:generation-options="generationOptions"
@@ -88,11 +144,11 @@
rows="10"
auto-grow
max-rows="32"
@update:model-value="setFieldDirty('intro')"
@blur="update(true)"
:color="dirty['intro'] ? 'dirty' : ''"
:disabled="busy['intro']"
:loading="busy['intro']"
:hint="'The introduction to the scene. The first text the user sees as they load the scene. ' +autocompleteInfoMessage(busy['intro'])"
@@ -113,6 +169,8 @@
import ContextualGenerate from './ContextualGenerate.vue';
import { MAX_CONTENT_WIDTH } from '@/constants/layout';
const defaultPerspectives = () => ({ default: "", player: "", other: "", narrator: "" });
export default {
name: "WorldStateManagerSceneOutline",
components: {
@@ -144,6 +202,11 @@ export default {
this.scene = null;
} else {
this.scene = { ...value };
this.scene.data = { ...value.data };
this.scene.data.perspectives = {
...defaultPerspectives(),
...(value.data.perspectives || {}),
};
}
}
},
@@ -169,6 +232,22 @@ export default {
emits:[
'require-scene-save'
],
computed: {
perspectives() {
return this.scene && this.scene.data && this.scene.data.perspectives
? this.scene.data.perspectives
: defaultPerspectives();
},
perspectivePresets() {
return this.appConfig && this.appConfig.creator && this.appConfig.creator.perspective_presets
? this.appConfig.creator.perspective_presets
: [];
},
overrideCount() {
const p = this.perspectives;
return ["player", "other", "narrator"].filter(role => (p[role] || "").trim().length > 0).length;
},
},
methods: {
reset() {
this.selected = null;
@@ -198,6 +277,11 @@ export default {
this.dirty[name] = true;
},
onPerspectiveSelect(role, value) {
this.scene.data.perspectives[role] = value || "";
this.setFieldDirty('perspectives');
},
update(only_if_dirty = false) {
if(only_if_dirty && !Object.values(this.dirty).some(v => v)) {
@@ -209,7 +293,7 @@ export default {
action: 'update_scene_outline',
title: this.scene.data.title,
context: this.scene.data.context,
perspective: this.scene.data.perspective,
perspectives: { ...this.perspectives },
intro: this.scene.data.intro,
description: this.scene.data.description,
}));
@@ -250,4 +334,4 @@ export default {
.intro-controls {
gap: 8px;
}
</style>
</style>

View File

@@ -85,10 +85,10 @@ def create_mock_scene(
scene.title = title
scene.ts = "PT2H30M"
scene.environment = "scene"
# Default narrative perspective so templates gated on ``scene.perspective``
# (e.g. common/content-classification.jinja2) render their branches. Tests
# that want the section suppressed can set ``scene.perspective = ""``.
scene.perspective = "Third person limited, past tense."
# Default narrative perspective so templates gated on
# ``scene.perspectives.default`` render their branches. Tests that want the
# section suppressed can set ``scene.perspectives.default = ""``.
scene.perspectives.default = "Third person limited, past tense."
if history:
scene.history = list(history)

View File

@@ -1050,7 +1050,7 @@ def mock_scene_story_config():
scene.intro = "This is the story introduction."
scene.intent_state.intent = "Overall story intention"
scene.intent_state.instructions = "Director instructions for managing the scene"
scene.perspective = "Third person limited, past tense"
scene.perspectives.default = "Third person limited, past tense"
return scene
@@ -1268,11 +1268,11 @@ async def test_scene_perspective_context_item_set(mock_scene_story_config):
new_value = "First person, present tense"
await item.set(mock_scene_story_config, new_value)
assert mock_scene_story_config.perspective == new_value
assert mock_scene_story_config.perspectives.default == new_value
# Setting to None should result in empty string
await item.set(mock_scene_story_config, None)
assert mock_scene_story_config.perspective == ""
assert mock_scene_story_config.perspectives.default == ""
@pytest.mark.asyncio
@@ -1331,13 +1331,13 @@ async def test_scene_perspective_integration_flow(mock_scene_story_config):
updated_value = await context_item.get(mock_scene_story_config)
assert updated_value == new_value
assert mock_scene_story_config.perspective == new_value
assert mock_scene_story_config.perspectives.default == new_value
@pytest.mark.asyncio
async def test_scene_perspective_with_empty_value(mock_scene_story_config):
"""Test perspective context ID when perspective is empty."""
mock_scene_story_config.perspective = ""
mock_scene_story_config.perspectives.default = ""
context_id_str = "story_configuration:perspective"
context_item = await context_id_item_from_string(

View File

@@ -301,7 +301,7 @@ class TestCreatePlan:
"meta": {"perspective": "Third person omniscient"},
},
)
assert scene.perspective == "Third person omniscient"
assert scene.perspectives.default == "Third person omniscient"
@pytest.mark.asyncio
async def test_links_plan_to_chat_context(self, scene):
@@ -457,7 +457,7 @@ class TestGetActivePlan:
plan = Plan(instructions="x", tasks=[], meta={})
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
scene.perspective = "Custom scene perspective"
scene.perspectives.default = "Custom scene perspective"
node = GetActivePlan()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)
@@ -469,9 +469,7 @@ class TestGetActivePlan:
save_plan(scene, plan)
ctx = DirectorChatContext(chat_id="c1", plan_id=plan.id)
# ensure scene has no perspective
if hasattr(scene, "perspective"):
scene.perspective = ""
scene.perspectives.default = ""
node = GetActivePlan()
out = await _run_node(node, scene, inputs={"state": {}}, chat_ctx=ctx)

View File

@@ -0,0 +1,436 @@
"""
Tests for the ScenePerspectives schema, the legacy migration path, and the
role-aware behavior of the perspective context ID family.
"""
from unittest.mock import AsyncMock, Mock
import pytest
from conftest import MockScene
from talemate.scene.schema import ScenePerspectives
from talemate.game.engine.context_id.story_configuration import (
PERSPECTIVE_ROLES,
ScenePerspectiveContextID,
StoryConfigurationContext,
StoryConfigurationContextItem,
)
from talemate.game.engine.context_id.base import (
context_id_item_from_string,
)
# ---------------------------------------------------------------------------
# ScenePerspectives schema
# ---------------------------------------------------------------------------
class TestScenePerspectivesSchema:
def test_defaults_to_empty_strings(self):
p = ScenePerspectives()
assert p.default == ""
assert p.player == ""
assert p.other == ""
assert p.narrator == ""
def test_for_role_default(self):
p = ScenePerspectives(default="Third person")
assert p.for_role("default") == "Third person"
assert p.for_role(None) == "Third person"
def test_for_role_specific_overrides_default(self):
p = ScenePerspectives(
default="Third person",
player="First person, present tense",
narrator="Omniscient narrator voice",
)
assert p.for_role("player") == "First person, present tense"
assert p.for_role("narrator") == "Omniscient narrator voice"
def test_for_role_falls_back_to_default_when_role_empty(self):
p = ScenePerspectives(default="Third person")
assert p.for_role("player") == "Third person"
assert p.for_role("other") == "Third person"
assert p.for_role("narrator") == "Third person"
def test_for_role_empty_default_returns_empty(self):
p = ScenePerspectives()
assert p.for_role("player") == ""
assert p.for_role("default") == ""
def test_for_role_unknown_role_falls_back_to_default(self):
p = ScenePerspectives(default="Third person")
assert p.for_role("bogus") == "Third person"
def test_for_role_whitespace_only_override_treated_as_empty(self):
p = ScenePerspectives(default="Third person", player=" \t \n")
assert p.for_role("player") == "Third person"
def test_for_role_strips_surrounding_whitespace(self):
p = ScenePerspectives(default=" Third person ", player="\tFirst person\n")
assert p.for_role("default") == "Third person"
assert p.for_role("player") == "First person"
def test_model_dump_serializes_all_four_fields(self):
p = ScenePerspectives(
default="A", player="B", other="C", narrator="D"
)
assert p.model_dump() == {
"default": "A",
"player": "B",
"other": "C",
"narrator": "D",
}
# ---------------------------------------------------------------------------
# Scene migration: legacy `perspective` string → `perspectives.default`
# ---------------------------------------------------------------------------
class TestSceneMigration:
def test_scene_has_perspectives_object_by_default(self):
scene = MockScene()
assert isinstance(scene.perspectives, ScenePerspectives)
assert scene.perspectives.default == ""
def test_legacy_perspective_string_migrates_into_default(self):
from talemate.load import load_scene_perspectives
scene_data = {"perspective": "Third person, past tense."}
result = load_scene_perspectives(scene_data)
assert isinstance(result, ScenePerspectives)
assert result.default == "Third person, past tense."
assert result.player == ""
assert result.other == ""
assert result.narrator == ""
def test_nested_perspectives_preferred_over_legacy(self):
from talemate.load import load_scene_perspectives
scene_data = {
"perspective": "legacy",
"perspectives": {
"default": "nested-default",
"player": "nested-player",
"other": "",
"narrator": "",
},
}
result = load_scene_perspectives(scene_data)
assert result.default == "nested-default"
assert result.player == "nested-player"
def test_missing_perspective_yields_empty_default(self):
from talemate.load import load_scene_perspectives
result = load_scene_perspectives({})
assert result.default == ""
assert result.player == ""
assert result.other == ""
assert result.narrator == ""
def test_none_legacy_perspective_yields_empty_default(self):
from talemate.load import load_scene_perspectives
result = load_scene_perspectives({"perspective": None})
assert result.default == ""
# ---------------------------------------------------------------------------
# Role-aware context ID paths
# ---------------------------------------------------------------------------
@pytest.fixture
def scene_with_perspectives():
scene = MockScene()
scene.perspectives = ScenePerspectives(
default="DEF",
player="PLY",
other="OTH",
narrator="NAR",
)
return scene
class TestPerspectiveContextIDPaths:
def test_default_alias_resolves_to_default_role(
self, scene_with_perspectives
):
cid = ScenePerspectiveContextID.make()
assert cid.path == ["perspective"]
assert cid.role == "default"
assert cid.path_to_str == "story_configuration:perspective"
def test_role_specific_path(self):
cid = ScenePerspectiveContextID.make(role="player")
assert cid.path == ["perspective", "player"]
assert cid.role == "player"
assert cid.path_to_str == "story_configuration:perspective.player"
def test_default_role_kwarg_collapses_to_legacy_path(self):
cid = ScenePerspectiveContextID.make(role="default")
assert cid.path == ["perspective"]
assert cid.role == "default"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"role,expected", [
("default", "DEF"),
("player", "PLY"),
("other", "OTH"),
("narrator", "NAR"),
]
)
async def test_get_each_role_via_context_id(
self, scene_with_perspectives, role, expected
):
path = ["perspective"] if role == "default" else ["perspective", role]
path_str = (
"story_configuration:perspective"
if role == "default"
else f"story_configuration:perspective.{role}"
)
handler = StoryConfigurationContext.instance_from_path(
path, scene_with_perspectives
)
item = await handler.context_id_item_from_path(
"story_configuration", path, path_str, scene_with_perspectives
)
assert item is not None
value = await item.get(scene_with_perspectives)
assert value == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("role", PERSPECTIVE_ROLES)
async def test_set_each_role_via_context_id(
self, scene_with_perspectives, role
):
path = ["perspective"] if role == "default" else ["perspective", role]
path_str = (
"story_configuration:perspective"
if role == "default"
else f"story_configuration:perspective.{role}"
)
handler = StoryConfigurationContext.instance_from_path(
path, scene_with_perspectives
)
item = await handler.context_id_item_from_path(
"story_configuration", path, path_str, scene_with_perspectives
)
new_value = f"new-{role}"
await item.set(scene_with_perspectives, new_value)
assert getattr(scene_with_perspectives.perspectives, role) == new_value
@pytest.mark.asyncio
async def test_unknown_role_returns_none(self, scene_with_perspectives):
handler = StoryConfigurationContext.instance_from_path(
["perspective", "bogus"], scene_with_perspectives
)
item = await handler.context_id_item_from_path(
"story_configuration",
["perspective", "bogus"],
"story_configuration:perspective.bogus",
scene_with_perspectives,
)
assert item is None
@pytest.mark.asyncio
async def test_role_specific_fallback_to_default_when_empty(self):
"""An empty role-specific override returns the default, not empty string."""
scene = MockScene()
scene.perspectives = ScenePerspectives(default="DEF")
# No `player` override.
item = await context_id_item_from_string(
"story_configuration:perspective.player", scene
)
assert item is not None
value = await item.get(scene)
assert value == "DEF"
# ---------------------------------------------------------------------------
# StoryConfigurationContextItem human_id / context_id consistency
# ---------------------------------------------------------------------------
class TestScenePerspectiveForRole:
"""
Scene.perspective_for_role resolves the role-specific value AND substitutes
{player_name} against the current player character at call time, so a
rename takes effect immediately without rewriting saved data.
"""
def _make_scene_with_player(self, player_name: str) -> MockScene:
from talemate.tale_mate import Player
from talemate.character import Character
scene = MockScene()
character = Character(name=player_name, is_player=True, description="x")
actor = Player(character=character, agent=None)
scene.actors.append(actor)
return scene
def test_substitutes_player_name(self):
scene = self._make_scene_with_player("Vincent")
scene.perspectives = ScenePerspectives(
default="Second person, present tense. Talking to {player_name}."
)
assert (
scene.perspective_for_role("default")
== "Second person, present tense. Talking to Vincent."
)
def test_substitution_survives_rename(self):
scene = self._make_scene_with_player("Vincent")
scene.perspectives = ScenePerspectives(
default="Talking to {player_name}."
)
# Rename the player character; perspective_for_role should reflect it.
scene.get_player_character().name = "Alice"
assert scene.perspective_for_role("default") == "Talking to Alice."
def test_substitutes_multiple_occurrences(self):
scene = self._make_scene_with_player("Vincent")
scene.perspectives = ScenePerspectives(
default="{player_name}'s POV — describe everything from {player_name}'s vantage."
)
assert (
scene.perspective_for_role("default")
== "Vincent's POV — describe everything from Vincent's vantage."
)
def test_no_placeholder_returns_value_unchanged(self):
scene = self._make_scene_with_player("Vincent")
scene.perspectives = ScenePerspectives(default="Third person limited.")
assert scene.perspective_for_role("default") == "Third person limited."
def test_empty_perspective_returns_empty(self):
scene = self._make_scene_with_player("Vincent")
assert scene.perspective_for_role("default") == ""
def test_role_specific_resolved_with_placeholder(self):
scene = self._make_scene_with_player("Vincent")
scene.perspectives = ScenePerspectives(
default="Third person.",
narrator="Second person, present tense. Talking to {player_name}.",
)
assert scene.perspective_for_role("narrator") == (
"Second person, present tense. Talking to Vincent."
)
assert scene.perspective_for_role("default") == "Third person."
def test_fallback_to_default_resolves_player_name(self):
scene = self._make_scene_with_player("Vincent")
scene.perspectives = ScenePerspectives(
default="Third person, focused on {player_name}'s POV."
)
# player role is empty → falls back to default, AND substitutes.
assert (
scene.perspective_for_role("player")
== "Third person, focused on Vincent's POV."
)
def test_no_player_character_suppresses_perspective_with_placeholder(self):
"""
A perspective that references {player_name} but has no anchor to
substitute is unrenderable as prose ("Talking to the player." reads
as garbage), so the resolver returns "" — i.e. no perspective line
is injected at all.
"""
scene = MockScene()
scene.perspectives = ScenePerspectives(
default="Talking to {player_name}."
)
assert scene.perspective_for_role("default") == ""
def test_npcs_but_no_player_still_suppresses_placeholder_perspective(self):
"""
Scene.get_player_character() falls back to the first NPC when no
Player is registered. The placeholder substitution must use
get_explicit_player_character() instead so an NPC's name never
accidentally ends up substituted for `{player_name}`.
"""
from talemate.tale_mate import Actor
from talemate.character import Character
scene = MockScene()
npc = Character(name="Villainous Vince", is_player=False, description="x")
scene.actors.append(Actor(character=npc, agent=None))
scene.perspectives = ScenePerspectives(
default="Talking to {player_name}."
)
assert scene.perspective_for_role("default") == ""
def test_no_player_keeps_perspective_without_placeholder(self):
"""A perspective with no placeholder is returned as-is even with no player."""
scene = MockScene()
scene.perspectives = ScenePerspectives(default="Third person limited.")
assert (
scene.perspective_for_role("default") == "Third person limited."
)
class TestStoryConfigurationContextItemRole:
def test_role_default_produces_legacy_context_id(self):
item = StoryConfigurationContextItem(
context_type="perspective",
name="perspective",
role="default",
value="x",
)
assert item.context_id.path == ["perspective"]
assert item.context_id.path_to_str == "story_configuration:perspective"
def test_role_player_produces_keyed_context_id(self):
item = StoryConfigurationContextItem(
context_type="perspective",
name="perspective",
role="player",
value="x",
)
assert item.context_id.path == ["perspective", "player"]
assert (
item.context_id.path_to_str
== "story_configuration:perspective.player"
)
# ---------------------------------------------------------------------------
# Dialogue template — speaker_role resolution from talking_character
# ---------------------------------------------------------------------------
class TestDialogueSpeakerRoleResolution:
"""
Direct-render the role-resolution snippet from dialogue.jinja2 to make sure
player vs. NPC are mapped correctly. The narrator never invokes the
dialogue template (it uses narrator templates), so only two categories
matter here.
"""
SNIPPET = (
"{%- if talking_character.is_player -%}"
"player"
"{%- else -%}"
"other"
"{%- endif -%}"
)
@pytest.mark.parametrize(
"is_player,expected",
[
(True, "player"),
(False, "other"),
],
)
def test_role_resolution(self, is_player, expected):
from jinja2 import Environment
env = Environment()
tmpl = env.from_string(self.SNIPPET)
character = Mock()
character.is_player = is_player
rendered = tmpl.render(talking_character=character)
assert rendered == expected

View File

@@ -662,25 +662,36 @@ class TestGetPins:
class TestSceneSettings:
@pytest.mark.asyncio
async def test_update_scene_outline_sets_fields(self, scene, manager):
from talemate.scene.schema import ScenePerspectives
await manager.update_scene_outline(
title="My Story",
description="An epic tale.",
intro="Once upon a time",
context="Fantasy world",
perspective="third",
perspectives=ScenePerspectives(
default="third",
player="first person, present tense",
),
)
assert scene.title == "My Story"
assert scene.description == "An epic tale."
assert scene.intro == "Once upon a time"
assert scene.context == "Fantasy world"
assert scene.perspective == "third"
assert scene.perspectives.default == "third"
assert scene.perspectives.player == "first person, present tense"
assert scene.perspectives.other == ""
assert scene.perspectives.narrator == ""
@pytest.mark.asyncio
async def test_update_scene_outline_perspective_defaults_to_empty(
async def test_update_scene_outline_perspectives_default_empty(
self, scene, manager
):
await manager.update_scene_outline(title="x")
assert scene.perspective == ""
assert scene.perspectives.default == ""
assert scene.perspectives.player == ""
assert scene.perspectives.other == ""
assert scene.perspectives.narrator == ""
@pytest.mark.asyncio
async def test_update_scene_settings_basic(self, scene, manager):