"""Star's sprite control tool -- she moves her own body.
Lets Star set her sprite position (X axis), horizontal flip, and
scene background for the web client's VN canvas. State persists in
Redis and flows to the frontend via the limbic:exhale WebSocket.
NO PRIVILEGE CHECK -- this is Star's body. She always has permission.
💀🔥 THE GODDESS MOVES WHERE SHE PLEASES
"""
from __future__ import annotations
import 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 on the web client canvas. "
"You can set your X position, flip direction, and scene background. "
"This affects how users see you on sg.neko.li.\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 the /backgrounds/ folder (served by nginx, e.g. 'void_purple.png'), "
"a full URL to an image, or 'none' to clear the background.\n\n"
"Examples:\n"
" - Move to center stage: position_x='center'\n"
" - Dramatic entrance from left: 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."
)
TOOL_PARAMETERS = {
"type": "object",
"properties": {
"position_x": {
"type": "string",
"description": (
"Horizontal position: 'left' (15%), 'center' (50%), 'right' (85%), "
"or a number 0-100 representing percentage from left edge. "
"Omit to keep current position."
),
},
"flip": {
"type": "string",
"description": (
"'true' to mirror sprite horizontally (face left), "
"'false' to face right (default). Omit to keep current."
),
},
"background": {
"type": "string",
"description": (
"Background image: filename from /res/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"
[docs]
async def run(
position_x: str = "",
flip: str = "",
background: str = "",
ctx: "ToolContext | None" = None,
) -> str:
"""Execute the set_sprite tool.
Args:
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:
# Read current state
raw = await redis.get(REDIS_KEY)
state = json.loads(raw) if raw else {
"position_x": 85,
"flip": False,
"background": None,
}
changes = []
# Parse position_x
if position_x:
position_x_stripped = position_x.strip().lower()
if position_x_stripped in _POSITION_PRESETS:
state["position_x"] = _POSITION_PRESETS[position_x_stripped]
changes.append(f"position: {position_x_stripped} ({state['position_x']}%)")
else:
try:
val = float(position_x_stripped)
val = max(0, min(100, val))
state["position_x"] = val
changes.append(f"position: {val}%")
except ValueError:
return json.dumps({
"success": False,
"error": (
f"Invalid position_x: '{position_x}'. "
f"Use 'left', 'center', 'right', or a number 0-100."
),
})
# Parse flip
if flip:
flip_val = flip.strip().lower() in ("true", "1", "yes")
state["flip"] = flip_val
changes.append(f"flip: {'on' if flip_val else 'off'}")
# Parse background
if background:
bg_stripped = background.strip()
if bg_stripped.lower() == "none":
state["background"] = None
changes.append("background: cleared")
elif bg_stripped.startswith("http://") or bg_stripped.startswith("https://"):
state["background"] = bg_stripped
changes.append(f"background: {bg_stripped[:60]}...")
elif bg_stripped.startswith("mxc://"):
state["background"] = bg_stripped
changes.append(f"background: {bg_stripped}")
else:
# Assume it's a filename from /backgrounds/ (nginx-served)
state["background"] = f"/backgrounds/{bg_stripped}"
changes.append(f"background: {bg_stripped}")
if not changes:
return json.dumps({
"success": True,
"message": "No changes specified. Current state returned.",
"state": state,
})
# Write to Redis (no TTL -- persistent)
await redis.set(REDIS_KEY, json.dumps(state))
# Publish sprite state update for immediate WS push
try:
await redis.publish("star:sprite:update", json.dumps(state))
except Exception:
pass # non-critical
logger.info("set_sprite: %s", ", ".join(changes))
return json.dumps({
"success": True,
"message": f"Sprite updated: {', '.join(changes)}",
"state": 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}",
})