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