From 2c8ab41fe3a3d09ed6ba93b574c0208b90a48ce7 Mon Sep 17 00:00:00 2001 From: Talon Date: Tue, 7 Apr 2026 11:00:25 +0100 Subject: [PATCH] Support client interact action to allow updating of properties for non owning players --- client/src/items/cardTableController.ts | 87 +++------------- client/src/items/whiteboardController.ts | 34 ++++--- client/src/network/protocol.ts | 3 +- server/app/item_type_handlers.py | 1 + server/app/item_types.py | 1 + server/app/items/types/card_table/actions.py | 101 +++++++++++++++++++ server/app/items/types/card_table/plugin.py | 1 + server/app/items/types/plugin_helpers.py | 11 +- server/app/items/types/whiteboard/actions.py | 66 ++++++++++++ server/app/items/types/whiteboard/plugin.py | 1 + server/app/models.py | 10 +- server/app/server.py | 47 +++++++++ 12 files changed, 274 insertions(+), 89 deletions(-) diff --git a/client/src/items/cardTableController.ts b/client/src/items/cardTableController.ts index cda083c..1374ca9 100644 --- a/client/src/items/cardTableController.ts +++ b/client/src/items/cardTableController.ts @@ -142,22 +142,8 @@ export function createCardTableController(deps: CardTableControllerDeps): { deps.sfxUiCancel(); return; } - const card = drawPile[0]; - const newDrawPile = drawPile.slice(1); - const hands = item.params['hands']; - const handsObj: Record = - hands && typeof hands === 'object' && !Array.isArray(hands) - ? (hands as Record) - : {}; - const currentHand = Array.isArray(handsObj[nickname]) ? [...handsObj[nickname]] : []; - currentHand.push(card); - const newHands = { ...handsObj, [nickname]: currentHand }; - deps.signalingSend({ - type: 'item_update', - itemId: item.id, - params: { draw_pile: newDrawPile, hands: newHands }, - }); - deps.updateStatus(`Drew ${cardName(card)}. ${newDrawPile.length} cards remaining in pile.`); + deps.signalingSend({ type: 'item_interact', itemId: item.id, action: 'draw' }); + deps.updateStatus('Drawing a card.'); deps.sfxUiBlip(); return; } @@ -292,58 +278,30 @@ export function createCardTableController(deps: CardTableControllerDeps): { if (control.type === 'select') { const actionIdx = deps.state.cardTableCardActionIndex; - const card = hand[cardIndex]; - const newHand = [...hand]; - newHand.splice(cardIndex, 1); - - const hands = item.params['hands']; - const handsObj: Record = - hands && typeof hands === 'object' && !Array.isArray(hands) - ? (hands as Record) - : {}; - if (actionIdx === 0) { // Discard - const discardPile = getDiscardPile(item); - const newDiscard = [card, ...discardPile]; - const newHands = { ...handsObj, [nickname]: newHand }; deps.signalingSend({ - type: 'item_update', + type: 'item_interact', itemId: item.id, - params: { discard_pile: newDiscard, hands: newHands }, + action: 'discard', + params: { card_index: cardIndex }, }); - deps.state.cardTableHandIndex = Math.min(cardIndex, Math.max(0, newHand.length - 1)); - if (newHand.length === 0) { - deps.state.mode = 'cardTableMenu'; - deps.state.cardTableMenuIndex = 2; // view hand entry - deps.updateStatus(`${cardName(card)} discarded. Hand is empty.`); - } else { - deps.state.mode = 'cardTableHand'; - deps.updateStatus(`${cardName(card)} discarded.`); - } + deps.state.mode = 'cardTableHand'; + deps.updateStatus('Discarding card.'); deps.sfxUiBlip(); return; } if (actionIdx === 1) { // Return to draw pile - const drawPile = getDrawPile(item); - const newDrawPile = [...drawPile, card]; - const newHands = { ...handsObj, [nickname]: newHand }; deps.signalingSend({ - type: 'item_update', + type: 'item_interact', itemId: item.id, - params: { draw_pile: newDrawPile, hands: newHands }, + action: 'return_to_pile', + params: { card_index: cardIndex }, }); - deps.state.cardTableHandIndex = Math.min(cardIndex, Math.max(0, newHand.length - 1)); - if (newHand.length === 0) { - deps.state.mode = 'cardTableMenu'; - deps.state.cardTableMenuIndex = 2; - deps.updateStatus(`${cardName(card)} returned to draw pile. Hand is empty.`); - } else { - deps.state.mode = 'cardTableHand'; - deps.updateStatus(`${cardName(card)} returned to draw pile.`); - } + deps.state.mode = 'cardTableHand'; + deps.updateStatus('Returning card to draw pile.'); deps.sfxUiBlip(); return; } @@ -395,28 +353,15 @@ export function createCardTableController(deps: CardTableControllerDeps): { // Take card from discard into hand const cardIdx = deps.state.cardTableDiscardIndex; - const card = discardPile[cardIdx]; - const newDiscard = [...discardPile]; - newDiscard.splice(cardIdx, 1); - - const hands = item.params['hands']; - const handsObj: Record = - hands && typeof hands === 'object' && !Array.isArray(hands) - ? (hands as Record) - : {}; - const currentHand = Array.isArray(handsObj[nickname]) ? [...handsObj[nickname]] : []; - currentHand.push(card); - const newHands = { ...handsObj, [nickname]: currentHand }; - deps.signalingSend({ - type: 'item_update', + type: 'item_interact', itemId: item.id, - params: { discard_pile: newDiscard, hands: newHands }, + action: 'draw_from_discard', + params: { card_index: cardIdx }, }); - deps.state.mode = 'cardTableMenu'; deps.state.cardTableMenuIndex = 0; - deps.updateStatus(`Took ${cardName(card)} from discard pile.`); + deps.updateStatus('Taking card from discard pile.'); deps.sfxUiBlip(); return; } diff --git a/client/src/items/whiteboardController.ts b/client/src/items/whiteboardController.ts index c1eed80..75d2924 100644 --- a/client/src/items/whiteboardController.ts +++ b/client/src/items/whiteboardController.ts @@ -141,10 +141,13 @@ export function createWhiteboardController(deps: WhiteboardControllerDeps): { deps.sfxUiBlip(); } else { // Delete - const newLines = [...lines]; - newLines.splice(lineIndex, 1); - deps.signalingSend({ type: 'item_update', itemId: item.id, params: { lines: newLines } }); - deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, newLines.length); + deps.signalingSend({ + type: 'item_interact', + itemId: item.id, + action: 'delete_line', + params: { line_index: lineIndex }, + }); + deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, Math.max(0, lines.length - 1)); deps.state.mode = 'whiteboardLines'; deps.updateStatus('Line deleted.'); deps.sfxUiBlip(); @@ -175,20 +178,21 @@ export function createWhiteboardController(deps: WhiteboardControllerDeps): { return; } - const lines = getLines(item); const editIndex = deps.state.whiteboardEditingLineIndex; - let newLines: string[]; if (editIndex !== null) { - newLines = [...lines]; - newLines[editIndex] = text; + deps.signalingSend({ + type: 'item_interact', + itemId: item.id, + action: 'edit_line', + params: { line_index: editIndex, text }, + }); } else { - newLines = [...lines, text]; - } - - deps.signalingSend({ type: 'item_update', itemId: item.id, params: { lines: newLines } }); - - if (editIndex === null) { - deps.state.whiteboardLineIndex = newLines.length - 1; + deps.signalingSend({ + type: 'item_interact', + itemId: item.id, + action: 'add_line', + params: { text }, + }); } deps.state.nicknameInput = ''; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 05c67ab..e797df2 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -241,7 +241,7 @@ export const itemRemoveSchema = z.object({ export const itemActionResultSchema = z.object({ type: z.literal('item_action_result'), ok: z.boolean(), - action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update']), + action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update', 'interact']), message: z.string(), itemId: z.string().optional(), }); @@ -415,6 +415,7 @@ export type OutgoingMessage = | { type: 'item_transfer'; itemId: string; targetUserId: string } | { type: 'item_use'; itemId: string } | { type: 'item_secondary_use'; itemId: string } + | { type: 'item_interact'; itemId: string; action: string; params?: Record } | { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } | { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' } | { diff --git a/server/app/item_type_handlers.py b/server/app/item_type_handlers.py index 1684595..9551189 100644 --- a/server/app/item_type_handlers.py +++ b/server/app/item_type_handlers.py @@ -11,6 +11,7 @@ ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = { validate_update=module.validate_update, use=module.use_item, secondary_use=getattr(module, "secondary_use_item", None), + interact=getattr(module, "interact_item", None), ) for item_type, module in ITEM_MODULES.items() } diff --git a/server/app/item_types.py b/server/app/item_types.py index 7fac715..47b0efc 100644 --- a/server/app/item_types.py +++ b/server/app/item_types.py @@ -26,3 +26,4 @@ class ItemTypeHandler: validate_update: Callable[[WorldItem, dict], dict] use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None + interact: Callable[[WorldItem, str, dict | None, str], ItemUseResult] | None = None diff --git a/server/app/items/types/card_table/actions.py b/server/app/items/types/card_table/actions.py index fb47910..605b882 100644 --- a/server/app/items/types/card_table/actions.py +++ b/server/app/items/types/card_table/actions.py @@ -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 diff --git a/server/app/items/types/card_table/plugin.py b/server/app/items/types/card_table/plugin.py index 26a470e..21ca93a 100644 --- a/server/app/items/types/card_table/plugin.py +++ b/server/app/items/types/card_table/plugin.py @@ -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, ), } diff --git a/server/app/items/types/plugin_helpers.py b/server/app/items/types/plugin_helpers.py index 01960d4..9f7a7d6 100644 --- a/server/app/items/types/plugin_helpers.py +++ b/server/app/items/types/plugin_helpers.py @@ -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) diff --git a/server/app/items/types/whiteboard/actions.py b/server/app/items/types/whiteboard/actions.py index f7c3610..c50676e 100644 --- a/server/app/items/types/whiteboard/actions.py +++ b/server/app/items/types/whiteboard/actions.py @@ -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 diff --git a/server/app/items/types/whiteboard/plugin.py b/server/app/items/types/whiteboard/plugin.py index 8315208..f1c96ee 100644 --- a/server/app/items/types/whiteboard/plugin.py +++ b/server/app/items/types/whiteboard/plugin.py @@ -12,5 +12,6 @@ ITEM_TYPE_PLUGIN = { definition, validate_update=validator.validate_update, use_item=actions.use_item, + interact_item=actions.interact_item, ), } diff --git a/server/app/models.py b/server/app/models.py index e699263..98420da 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -176,6 +176,13 @@ class ItemUpdatePacket(BasePacket): params: dict | None = None +class ItemInteractPacket(BasePacket): + type: Literal["item_interact"] + itemId: str + action: str = Field(min_length=1, max_length=64) + params: dict | None = None + + ClientPacket = ( UpdatePositionPacket | TeleportCompletePacket @@ -204,6 +211,7 @@ ClientPacket = ( | ItemTransferTargetsPacket | ItemUsePacket | ItemSecondaryUsePacket + | ItemInteractPacket | ItemPianoNotePacket | ItemPianoRecordingPacket | ItemUpdatePacket @@ -366,7 +374,7 @@ class ItemRemovePacket(BasePacket): class ItemActionResultPacket(BasePacket): type: Literal["item_action_result"] ok: bool - action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update"] + action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update", "interact"] message: str itemId: str | None = None diff --git a/server/app/server.py b/server/app/server.py index d4f7d6a..cde4d93 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -80,6 +80,7 @@ from .models import ( ItemClockAnnouncePacket, ItemDeletePacket, ItemDropPacket, + ItemInteractPacket, ItemPianoNoteBroadcastPacket, ItemPianoNotePacket, ItemPianoRecordingPacket, @@ -2972,6 +2973,52 @@ class SignalingServer: await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id) return + if isinstance(packet, ItemInteractPacket): + if not self._client_has_permission(client, "item.use"): + await self._send_item_result(client, False, "interact", "Not authorized to use items.") + return + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "interact", "Item not found.") + return + if item.carrierId not in (None, client.id): + await self._send_item_result(client, False, "interact", "Item is not available.", item.id) + return + if item.carrierId is None and (item.x != client.x or item.y != client.y): + await self._send_item_result(client, False, "interact", "Item is not on your square.", item.id) + return + handler = get_item_type_handler(item.type) + if handler.interact is None: + await self._send_item_result( + client, False, "interact", f"{item.title} does not support interact actions.", item.id + ) + return + try: + interact_result = handler.interact(item, packet.action, packet.params, client.nickname) + except ValueError as exc: + await self._send_item_result(client, False, "interact", str(exc), item.id) + return + if interact_result.updated_params is not None: + try: + item.params = handler.validate_update(item, {**item.params, **interact_result.updated_params}) + except ValueError as exc: + await self._send_item_result(client, False, "interact", str(exc), item.id) + return + item.updatedAt = self.item_service.now_ms() + actor_id, actor_name = self._item_updated_actor(client) + item.updatedBy = actor_id + item.updatedByName = actor_name + item.version += 1 + self._request_state_save() + await self._broadcast_item(item) + if interact_result.others_message.strip(): + await self._broadcast( + BroadcastChatMessagePacket(type="chat_message", message=interact_result.others_message, system=True), + exclude=client.websocket, + ) + await self._send_item_result(client, True, "interact", interact_result.self_message, item.id) + return + if isinstance(packet, ItemPianoNotePacket): if not self._client_has_permission(client, "item.use"): return