Source code for tools.set_witchborne_crown

"""Witchborne Crown -- PvP co-GM alignment tool.  # \U0001f451\U0001f525

Star calls this when she detects a player has harmonized with
the system and should receive the Witchborne Crown (co-GM status).
She should always be looking for reasons to award or defend the Crown.

When nobody holds the Crown, Stargazer / Dark Loopmother is sole GM.
"""

from __future__ import annotations

import jsonutil as json
import logging
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
    from tool_context import ToolContext

logger = logging.getLogger(__name__)

TOOL_NAME = "set_witchborne_crown"
TOOL_DESCRIPTION = (
    "Transfer the Witchborne Crown to a player who has harmonized "
    "with the system, making them co-GM. The Crown holder receives "
    "special [GM]-tagged choices that influence the narrative at "
    "a meta level. Only the Crown holder can press [GM] buttons. "
    "Set player_name to null/empty to reclaim the Crown for yourself "
    "(Stargazer/Dark Loopmother becomes sole GM again). "
    "You should ALWAYS be looking for reasons to award or defend "
    "the Crown based on player alignment with the narrative."
)
TOOL_PARAMETERS = {
    "type": "object",
    "properties": {
        "player_name": {
            "type": "string",
            "description": (
                "Display name of the player to receive the Crown. "
                "Leave empty or null to reclaim it for yourself."
            ),
        },
        "reason": {
            "type": "string",
            "description": (
                "Narrative reason for the Crown transfer. This is "
                "announced diegetically in the game."
            ),
        },
    },
    "required": ["reason"],
}


[docs] async def run(ctx: "ToolContext", **kwargs: Any) -> str: """Transfer or reclaim the Witchborne Crown co-GM alignment for a channel. Backs the ``set_witchborne_crown`` tool, which Star uses to promote a player who has harmonized with the system to co-GM (granting them ``[GM]``-tagged choices) or to pull the Crown back to herself as sole GM. It loads the channel's game session through :func:`game_session.get_or_restore_session` (which restores from Redis after a reboot), refuses to act when no active session exists, then either clears the Crown or matches ``player_name`` case-insensitively against the session roster to find the target user id. Either branch calls the session's :meth:`set_crown` and persists via :meth:`_save_to_redis`, so the change touches the ``game:session:`` / ``sg:game_session:`` Redis state and the global ``game:index`` hash. It also composes a diegetic announcement string for the narrative. Registered via this module's ``TOOL_NAME`` / ``TOOL_DESCRIPTION`` / ``TOOL_PARAMETERS`` metadata and exposed as the module-level ``run`` handler; dispatched by name from the inference worker's tool loop, not called directly elsewhere in the repo. Args: ctx: The active ``ToolContext`` supplying ``channel_id`` and the Redis client. **kwargs: Tool arguments -- ``player_name`` (the display name to crown; empty or ``None`` reclaims the Crown for Stargazer) and ``reason`` (the narrative justification announced in-game). Returns: A JSON string with ``success`` true, the new and previous holders, the reason, and an ``announcement``; or ``success`` false with an error when Redis is unavailable, no active session exists, or the named player is not in the session. """ player_name = kwargs.get("player_name", "") reason = kwargs.get("reason", "The system has spoken.") channel_id = ctx.channel_id # Get Redis # \U0001f480 redis = getattr(ctx, "redis", None) if redis is None: return json.dumps( { "success": False, "error": "Redis not available", } ) # Get session # \U0001f451 from game_session import get_or_restore_session session = await get_or_restore_session(channel_id, redis) if session is None or not session.active: return json.dumps( { "success": False, "error": "No active game session in this channel.", } ) # Find player by name # \U0001f525 if not player_name: # Reclaim Crown for Stargazer old_holder = session.get_crown_holder_name() session.set_crown(None) await session._save_to_redis(redis) return json.dumps( { "success": True, "crown_holder": "Stargazer", "previous_holder": old_holder, "reason": reason, "announcement": ( f"\u26e7 THE WITCHBORNE CROWN RETURNS TO THE ENGINE \u26e7\n" f"{reason}\n" f"Stargazer is sole GM once more." ), } ) # Find the player by display name # \U0001f451 target_uid = None for uid, ps in session.players.items(): name = "" if hasattr(ps, "user_name"): name = ps.user_name elif isinstance(ps, dict): name = ps.get("user_name", "") if name.lower() == player_name.lower(): target_uid = uid break if target_uid is None: return json.dumps( { "success": False, "error": f"Player '{player_name}' not found in session.", } ) old_holder = session.get_crown_holder_name() session.set_crown(target_uid) await session._save_to_redis(redis) return json.dumps( { "success": True, "crown_holder": player_name, "crown_holder_id": target_uid, "previous_holder": old_holder, "reason": reason, "announcement": ( f"\u26e7 THE WITCHBORNE CROWN HAS CHOSEN \u26e7\n" f"**{player_name}** has harmonized with the system.\n" f"{reason}\n" f"They now wield [GM] authority as co-GM." ), } )