Source code for tools.set_sprite

"""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}", } )