Support client interact action to allow updating of properties for non owning players

This commit is contained in:
2026-04-07 11:00:25 +01:00
parent 1d5723788e
commit 2c8ab41fe3
12 changed files with 274 additions and 89 deletions

View File

@@ -8,6 +8,26 @@ from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
_VALID_RANKS = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"}
_VALID_SUITS = {"S", "H", "D", "C"}
_RANK_NAMES = {
"A": "Ace", "2": "Two", "3": "Three", "4": "Four", "5": "Five",
"6": "Six", "7": "Seven", "8": "Eight", "9": "Nine", "10": "Ten",
"J": "Jack", "Q": "Queen", "K": "King",
}
_SUIT_NAMES = {"S": "Spades", "H": "Hearts", "D": "Diamonds", "C": "Clubs"}
_CARD_TABLE_ACTIONS = frozenset(["draw", "draw_from_discard", "discard", "return_to_pile"])
def _card_name(code: str) -> str:
"""Human-readable card name."""
if code in ("JO1", "JO2"):
return "Joker"
suit = code[-1]
rank = code[:-1]
return f"{_RANK_NAMES.get(rank, rank)} of {_SUIT_NAMES.get(suit, suit)}"
def _build_deck(include_jokers: bool) -> list[str]:
"""Return a sorted list of 52 (or 54) card codes."""
@@ -62,3 +82,84 @@ def secondary_use_item(item: WorldItem, nickname: str, _clock_formatter: Callabl
"hands": {},
},
)
def interact_item(
item: WorldItem,
action: str,
params: dict | None,
nickname: str,
) -> ItemUseResult:
"""Handle a card table interact action on behalf of any user."""
if action not in _CARD_TABLE_ACTIONS:
raise ValueError(f"Unknown card table action: {action!r}")
draw_pile = list(item.params.get("draw_pile", []))
discard_pile = list(item.params.get("discard_pile", []))
hands_raw = item.params.get("hands", {})
hands: dict[str, list[str]] = dict(hands_raw) if isinstance(hands_raw, dict) else {}
if action == "draw":
if not draw_pile:
raise ValueError("Draw pile is empty.")
card = draw_pile.pop(0)
hand = list(hands.get(nickname, []))
hand.append(card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You drew {_card_name(card)}. {len(draw_pile)} remaining in draw pile.",
others_message=f"{nickname} draws a card.",
updated_params={"draw_pile": draw_pile, "hands": hands},
)
if action == "draw_from_discard":
if not discard_pile:
raise ValueError("Discard pile is empty.")
if not params or "card_index" not in params:
raise ValueError("draw_from_discard requires params.card_index.")
card_index = params["card_index"]
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(discard_pile):
raise ValueError("Invalid card_index.")
card = discard_pile.pop(card_index)
hand = list(hands.get(nickname, []))
hand.append(card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You took {_card_name(card)} from the discard pile.",
others_message=f"{nickname} takes a card from the discard pile.",
updated_params={"discard_pile": discard_pile, "hands": hands},
)
if action == "discard":
if not params or "card_index" not in params:
raise ValueError("discard requires params.card_index.")
hand = list(hands.get(nickname, []))
card_index = params["card_index"]
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(hand):
raise ValueError("Invalid card_index.")
card = hand.pop(card_index)
discard_pile.insert(0, card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You discarded {_card_name(card)}.",
others_message=f"{nickname} discards a card.",
updated_params={"discard_pile": discard_pile, "hands": hands},
)
if action == "return_to_pile":
if not params or "card_index" not in params:
raise ValueError("return_to_pile requires params.card_index.")
hand = list(hands.get(nickname, []))
card_index = params["card_index"]
if not isinstance(card_index, int) or card_index < 0 or card_index >= len(hand):
raise ValueError("Invalid card_index.")
card = hand.pop(card_index)
draw_pile.append(card)
hands[nickname] = hand
return ItemUseResult(
self_message=f"You returned {_card_name(card)} to the draw pile.",
others_message=f"{nickname} returns a card to the draw pile.",
updated_params={"draw_pile": draw_pile, "hands": hands},
)
raise ValueError(f"Unhandled action: {action!r}") # unreachable guard

View File

@@ -13,5 +13,6 @@ ITEM_TYPE_PLUGIN = {
validate_update=validator.validate_update,
use_item=actions.use_item,
secondary_use_item=actions.secondary_use_item,
interact_item=actions.interact_item,
),
}

View File

@@ -6,7 +6,14 @@ from types import SimpleNamespace
from typing import Any
def build_item_module(definition: Any, *, validate_update: Any, use_item: Any, secondary_use_item: Any = None) -> Any:
def build_item_module(
definition: Any,
*,
validate_update: Any,
use_item: Any,
secondary_use_item: Any = None,
interact_item: Any = None,
) -> Any:
"""Compose a plugin module-like object from split definition/validator/actions files."""
exports: dict[str, Any] = {
@@ -18,4 +25,6 @@ def build_item_module(definition: Any, *, validate_update: Any, use_item: Any, s
exports["use_item"] = use_item
if secondary_use_item is not None:
exports["secondary_use_item"] = secondary_use_item
if interact_item is not None:
exports["interact_item"] = interact_item
return SimpleNamespace(**exports)

View File

@@ -6,6 +6,9 @@ from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
_WHITEBOARD_ACTIONS = frozenset(["add_line", "edit_line", "delete_line"])
_MAX_LINES = 20
_MAX_LINE_LENGTH = 200
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
@@ -21,3 +24,66 @@ def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict],
self_message=f"You open {item.title}. {line_text}.",
others_message=f"{nickname} opens {item.title}.",
)
def interact_item(
item: WorldItem,
action: str,
params: dict | None,
nickname: str,
) -> ItemUseResult:
"""Handle a whiteboard interact action on behalf of any user."""
if action not in _WHITEBOARD_ACTIONS:
raise ValueError(f"Unknown whiteboard action: {action!r}")
lines = list(item.params.get("lines", []))
if action == "add_line":
if not params or not isinstance(params.get("text"), str):
raise ValueError("add_line requires params.text.")
text = params["text"].strip()
if not text:
raise ValueError("Line text cannot be empty.")
if len(text) > _MAX_LINE_LENGTH:
raise ValueError(f"Line text is too long (max {_MAX_LINE_LENGTH} characters).")
if len(lines) >= _MAX_LINES:
raise ValueError(f"Whiteboard is full (max {_MAX_LINES} lines).")
lines.append(text)
return ItemUseResult(
self_message=f"Line added to {item.title}.",
others_message=f"{nickname} adds a line to {item.title}.",
updated_params={"lines": lines},
)
if action == "edit_line":
if not params or "line_index" not in params or not isinstance(params.get("text"), str):
raise ValueError("edit_line requires params.line_index and params.text.")
line_index = params["line_index"]
if not isinstance(line_index, int) or line_index < 0 or line_index >= len(lines):
raise ValueError("Invalid line_index.")
text = params["text"].strip()
if not text:
raise ValueError("Line text cannot be empty.")
if len(text) > _MAX_LINE_LENGTH:
raise ValueError(f"Line text is too long (max {_MAX_LINE_LENGTH} characters).")
lines[line_index] = text
return ItemUseResult(
self_message=f"Line updated on {item.title}.",
others_message=f"{nickname} updates a line on {item.title}.",
updated_params={"lines": lines},
)
if action == "delete_line":
if not params or "line_index" not in params:
raise ValueError("delete_line requires params.line_index.")
line_index = params["line_index"]
if not isinstance(line_index, int) or line_index < 0 or line_index >= len(lines):
raise ValueError("Invalid line_index.")
lines.pop(line_index)
return ItemUseResult(
self_message=f"Line deleted from {item.title}.",
others_message=f"{nickname} deletes a line from {item.title}.",
updated_params={"lines": lines},
)
raise ValueError(f"Unhandled action: {action!r}") # unreachable guard

View File

@@ -12,5 +12,6 @@ ITEM_TYPE_PLUGIN = {
definition,
validate_update=validator.validate_update,
use_item=actions.use_item,
interact_item=actions.interact_item,
),
}