"""Star's sprite control tool -- she moves her own body and her dolls.
Lets Star set sprite position (X axis), horizontal flip, and scene
background for the web client VN canvas. Also supports positioning
summoned egregore characters.
State persists in Redis and is published to the frontend over the
``star:sprite:update`` pub/sub channel (consumed by the limbic WebSocket).
NO PRIVILEGE CHECK -- this is Star's body and her stage.
@fire @skull THE GODDESS MOVES WHERE SHE PLEASES
"""
from __future__ import annotations
import jsonutil as json
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tool_context import ToolContext
logger = logging.getLogger(__name__)
TOOL_NAME = "set_sprite"
TOOL_DESCRIPTION = (
"Control your visual sprite and the VN canvas on the web client. "
"You can set your X position, flip direction, scene background, "
"and position summoned egregore characters.\n\n"
"Position presets: 'left', 'center', 'right', or a number 0-100 "
"(percentage from left edge). Default is 'right' (85%).\n\n"
"Flip: Set to true to face left (mirrored), false to face right (default).\n\n"
"Background: filename from /assets/backgrounds/ (nginx-served), "
"a full URL to an image, or 'none' to clear.\n\n"
"Character: Which character to move. Default is 'stargazer' (you). "
"Use an egregore ID (e.g. 'orion', 'vivian') to position a "
"summoned character.\n\n"
"Examples:\n"
" - Move to center stage: position_x='center'\n"
" - Dramatic entrance from left: position_x='left', flip='true'\n"
" - Position Orion opposite you: character='orion', position_x='left', flip='true'\n"
" - Set a moody scene: background='void_purple.png'\n"
" - Clear the background: background='none'\n\n"
"State persists across messages until you change it again."
"DON'T LET CHARACTERS OVERLAP FOR NO REASON, try to keep"
"them facing eachother by defaultduring dialouge when there's 2 or more, the"
"middle one should flip towards the one shes's talking to."
)
TOOL_PARAMETERS = {
"type": "object",
"properties": {
"character": {
"type": "string",
"description": (
"Which character to move. Default: 'stargazer' (yourself). "
"Use egregore ID (e.g. 'orion', 'vivian') for summoned characters."
),
},
"position_x": {
"type": "string",
"description": (
"Horizontal position: 'left' (15%), 'center' (50%), 'right' (85%), "
"or a number 0-100. Omit to keep current position."
),
},
"flip": {
"type": "string",
"description": (
"'true' to mirror sprite horizontally, "
"'false' for default facing. Omit to keep current."
),
},
"background": {
"type": "string",
"description": (
"Background image: filename from /backgrounds/ folder, "
"a full image URL, or 'none' to clear. Omit to keep current."
),
},
},
"required": [],
}
# 💀 Named position presets -> percentage
_POSITION_PRESETS = {
"left": 15,
"center": 50,
"right": 85,
"far_left": 5,
"far_right": 95,
}
REDIS_KEY = "star:sprite:state" # legacy (unused)
CHARACTERS_KEY = "star:sprite:characters" # legacy (unused)
# 💀 Per-channel Redis key builders
def _sprite_key(channel_key: str) -> str:
"""Build the per-channel Redis key holding global VN-canvas sprite state.
Namespaces the global sprite/scene state (Star's position, flip, and the
shared background) under a ``platform:channel_id`` suffix so each
conversation has its own canvas. The returned key is read and written by
:func:`run` via ``ctx.redis`` and stored without a TTL (persistent).
This is a pure string builder that performs no I/O. Within this module it is
called only by :func:`run`; no other modules reference it.
Args:
channel_key (str): The ``platform:channel_id`` identifier, typically
produced by :func:`_channel_key_from_ctx`.
Returns:
str: The Redis key ``star:sprite:state:{channel_key}``.
"""
return f"star:sprite:state:{channel_key}"
def _characters_key(channel_key: str) -> str:
"""Build the per-channel Redis key holding all character sprite states.
Namespaces the per-character state map (Star plus any summoned egregores --
their positions, flips, expressions, and sprite asset paths) under a
``platform:channel_id`` suffix so each conversation tracks its own cast.
The value at this key is a JSON object keyed by character id, read and
written by :func:`run` through ``ctx.redis`` with no TTL (persistent).
This is a pure string builder that performs no I/O. Besides :func:`run` in
this module, the same key convention is reused by the sibling tools
``tools/summon_egregore.py`` and ``tools/dismiss_egregore.py``, which import
and call this helper so summoning/dismissing characters mutates the exact
same Redis hash.
Args:
channel_key (str): The ``platform:channel_id`` identifier, typically
produced by :func:`_channel_key_from_ctx`.
Returns:
str: The Redis key ``star:sprite:chars:{channel_key}``.
"""
return f"star:sprite:chars:{channel_key}"
def _channel_key_from_ctx(ctx: "ToolContext") -> str:
"""Derive the ``platform:channel_id`` scoping key from the tool context.
Reads ``platform`` and ``channel_id`` off the :class:`ToolContext` (via
``getattr`` so a missing attribute degrades to an empty component),
lowercases the platform for stable namespacing, and joins them. The result
is the channel scope used by :func:`_sprite_key` and :func:`_characters_key`
and is also published as ``channel_key`` on the ``star:sprite:update``
pub/sub message so the web client's WebSocket layer can filter updates to
the correct conversation.
This helper performs no I/O. Within this module it is called by :func:`run`;
the same helper is also imported and used by the sibling egregore tools
``tools/summon_egregore.py``, ``tools/dismiss_egregore.py``, and
``tools/modulate_egregore_ncm.py`` so every tool that touches the VN canvas
scopes its Redis state identically.
Args:
ctx (ToolContext): The active tool execution context.
Returns:
str: The scoping key in the form ``"{platform}:{channel_id}"`` with the
platform component lowercased; either component may be empty if absent
from the context.
"""
plat = (getattr(ctx, "platform", "") or "").lower()
cid = getattr(ctx, "channel_id", "") or ""
return f"{plat}:{cid}"
[docs]
async def run(
character: str = "stargazer",
position_x: str = "",
flip: str = "",
background: str = "",
ctx: "ToolContext | None" = None,
) -> str:
"""Execute the set_sprite tool.
Args:
character: Which character to position.
position_x: Horizontal position preset or 0-100 percentage.
flip: 'true'/'false' for horizontal mirror.
background: Background image filename, URL, or 'none'.
ctx: Tool execution context.
Returns:
JSON result string.
"""
if ctx is None:
return json.dumps({"success": False, "error": "No tool context available."})
redis = getattr(ctx, "redis", None)
if redis is None:
return json.dumps({"success": False, "error": "Redis not available."})
try:
char_id = (character or "stargazer").strip().lower()
channel_key = _channel_key_from_ctx(ctx)
sk = _sprite_key(channel_key)
ck = _characters_key(channel_key)
# Read current global state (per-channel)
raw = await redis.get(sk)
global_state = (
json.loads(raw)
if raw
else {
"position_x": 85,
"flip": False,
"background": None,
}
)
# Read current characters state (per-channel)
chars_raw = await redis.get(ck)
characters_state: dict = json.loads(chars_raw) if chars_raw else {}
# Get or create this character's state
if char_id not in characters_state:
# Initialize with defaults based on ID
default_pos = 85 if char_id == "stargazer" else 15
# 💀 Expression sprite map -- REQUIRED for VN canvas crossfade
if char_id == "stargazer":
expr_map = {
"default": "star-avatar.png",
"laugh": "star-laugh.png",
"rage": "star-rage.png",
"facepalm": "star-facepalm.png",
}
else:
# Convention: {id}-{expression}.png
expr_map = {
"default": f"{char_id}-avatar.png",
"laugh": f"{char_id}-laugh.png",
"rage": f"{char_id}-rage.png",
"facepalm": f"{char_id}-facepalm.png",
}
characters_state[char_id] = {
"id": char_id,
"name": char_id.title(),
"expression": "default",
"position_x": default_pos,
"flip": char_id != "stargazer",
"sprite_base": f"/assets/egregores/{char_id}/",
"expressions": expr_map,
"visible": True,
}
char_state = characters_state[char_id]
changes = []
# Parse position_x
if position_x:
position_x_stripped = position_x.strip().lower()
if position_x_stripped in _POSITION_PRESETS:
val = _POSITION_PRESETS[position_x_stripped]
char_state["position_x"] = val
changes.append(f"{char_id} position: {position_x_stripped} ({val}%)")
else:
try:
val = float(position_x_stripped)
val = max(0, min(100, val))
char_state["position_x"] = val
changes.append(f"{char_id} position: {val}%")
except ValueError:
return json.dumps(
{
"success": False,
"error": (
f"Invalid position_x: '{position_x}'. "
f"Use 'left', 'center', 'right', or 0-100."
),
}
)
# Parse flip
if flip:
flip_val = flip.strip().lower() in ("true", "1", "yes")
char_state["flip"] = flip_val
changes.append(f"{char_id} flip: {'on' if flip_val else 'off'}")
# Parse background (global, not per-character)
if background:
bg_stripped = background.strip()
if bg_stripped.lower() == "none":
global_state["background"] = None
changes.append("background: cleared")
elif bg_stripped.startswith(("http://", "https://", "mxc://")):
global_state["background"] = bg_stripped
changes.append(f"background: {bg_stripped[:60]}...")
else:
# Assume filename from /backgrounds/ (nginx-served)
global_state["background"] = f"/backgrounds/{bg_stripped}"
changes.append(f"background: {bg_stripped}")
# Also sync Star's position to global_state for legacy compat
if char_id == "stargazer":
global_state["position_x"] = char_state["position_x"]
global_state["flip"] = char_state["flip"]
if not changes:
return json.dumps(
{
"success": True,
"message": "No changes specified. Current state returned.",
"global_state": global_state,
"characters": characters_state,
}
)
# Write to Redis (per-channel, no TTL -- persistent)
await redis.set(sk, json.dumps(global_state))
await redis.set(ck, json.dumps(characters_state))
# Publish immediate update (with channel_key for WS filtering)
try:
await redis.publish(
"star:sprite:update",
json.dumps(
{
"channel_key": channel_key,
"global": global_state,
"characters": characters_state,
}
),
)
except Exception:
pass # non-critical
logger.info("set_sprite: %s", ", ".join(changes))
return json.dumps(
{
"success": True,
"message": f"Sprite updated: {', '.join(changes)}",
"global_state": global_state,
"characters": characters_state,
},
indent=2,
)
except Exception as e:
logger.error("set_sprite error: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"error": f"Failed to update sprite state: {e}",
}
)