"""GameGirl Color -- Structured choice submission tool.
The LLM calls this tool to submit interactive choices for the current
game turn. This bypasses fragile regex parsing by having the model
output choices as structured data that gets converted directly into
Discord buttons.
In multiplayer, the LLM calls this tool ONCE per player (each call
tagged with `player_name`). Calls ACCUMULATE until the response
pipeline consumes them.
# 💀🔥 THE BUTTON IS A CONTRACT. THE MODEL JUST DREAMS THE LABEL.
"""
from __future__ import annotations
import jsonutil as json
import logging
import re
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from tool_context import ToolContext
logger = logging.getLogger(__name__)
TOOL_NAME = "set_game_choices"
TOOL_DESCRIPTION = (
"Submit the interactive choice buttons for a player's turn. "
"Call this AFTER writing each player's narrative block. "
"In multiplayer, call it ONCE per player with their player_name. "
"Each call ADDS to the turn's choices (does not overwrite). "
"Always provide EXACTLY 4 choices per player. "
"Each choice needs a single emoji and a short label (under 70 chars). "
"The choices become clickable Discord buttons beneath the narrative."
)
TOOL_PARAMETERS = {
"type": "object",
"properties": {
"player_name": {
"type": "string",
"description": (
"Name of the player these choices are for. "
"REQUIRED in multiplayer. In single-player, can be omitted. "
"Example: 'VIVIAN', 'SARAH', 'WISHARDRY'"
),
},
"choices": {
"type": "array",
"description": (
"Array of exactly 4 choice objects. Each has an emoji and label. "
"Keep labels under 70 characters. Use the emoji alignment system."
),
"items": {
"type": "object",
"properties": {
"emoji": {
"type": "string",
"description": "Single emoji for the button.",
},
"label": {
"type": "string",
"description": "Short label text for the choice.",
},
},
"required": ["emoji", "label"],
},
"minItems": 4,
"maxItems": 4,
},
},
"required": ["choices"],
}
[docs]
async def run(
choices: list[dict[str, str]],
player_name: str = "",
ctx: ToolContext | None = None,
) -> str:
"""Store structured choices for the response pipeline to render.
In multiplayer, each call APPENDS a player's choice block to the
Redis list. The response pipeline reads ALL accumulated blocks
and builds a combined view.
"""
if ctx is None:
return json.dumps({"error": "No tool context available."})
if not choices:
return json.dumps({"error": "No choices provided."})
channel_id = str(ctx.channel_id)
# Validate and clamp to exactly 4 choices
clean_choices = []
for c in choices[:4]:
emoji = c.get("emoji", "")
label = c.get("label", "")
if emoji and label:
# Extract first emoji safely (handles ZWJ sequences)
_m = re.match(
r"[\U0001f300-\U0001faff\U00002702-\U000027b0"
r"\U0000fe00-\U0000fe0f\U0000200d\U0001f3fb-\U0001f3ff]+",
emoji,
)
_clean_emoji = _m.group(0) if _m else (emoji[:2] if emoji else "\U0001f300")
clean_choices.append(
{
"emoji": _clean_emoji,
"label": label[:70], # Discord button label limit
}
)
if not clean_choices:
return json.dumps({"error": "No valid choices after validation."})
# Pad to exactly 4 if the LLM sent fewer # 🔥
_pad_emoji = ["\U0001f300", "\U0001f480", "\U0001f7e3", "\U0001f534"]
_pad_labels = [
"Stare into the void",
"Accept your fate",
"Break the fourth wall",
"Do nothing (dangerous)",
]
while len(clean_choices) < 4:
idx = len(clean_choices)
clean_choices.append(
{
"emoji": _pad_emoji[idx % len(_pad_emoji)],
"label": _pad_labels[idx % len(_pad_labels)],
}
)
# Build the player block # 🌀
block = {
"player_name": player_name.strip() or "",
"choices": clean_choices,
}
# Store in Redis DRAFT key -- proofreaded by renderer inline
redis = getattr(ctx, "redis", None)
redis_key = f"game:choices:draft:{channel_id}"
if redis is not None:
try:
# Push this player's block onto the list
await redis.rpush(redis_key, json.dumps(block))
# 5 min TTL -- consumed on next send
await redis.expire(redis_key, 300)
except Exception as exc:
logger.warning("Failed to store choices in Redis: %s", exc)
# Also store in memory on the context for immediate access # 🌀
if not hasattr(ctx, "_game_choices"):
ctx._game_choices = {}
# Accumulate per-channel
if channel_id not in ctx._game_choices:
ctx._game_choices[channel_id] = []
ctx._game_choices[channel_id].append(block)
logger.info(
"Game choices set for channel %s player '%s': %d choices",
channel_id,
player_name,
len(clean_choices),
)
return json.dumps(
{
"success": True,
"player": player_name or "(shared)",
"choices_count": len(clean_choices),
"instruction": (
f"Choices registered for {player_name or 'this turn'}. "
"They will appear as clickable buttons beneath your narrative. "
"Do NOT include the choices as text in your response. "
"If there are more players, write their narrative block and "
"call set_game_choices again with their player_name."
),
}
)