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 39eea11244
commit da1db16e5f
12 changed files with 274 additions and 89 deletions

View File

@@ -142,22 +142,8 @@ export function createCardTableController(deps: CardTableControllerDeps): {
deps.sfxUiCancel(); deps.sfxUiCancel();
return; return;
} }
const card = drawPile[0]; deps.signalingSend({ type: 'item_interact', itemId: item.id, action: 'draw' });
const newDrawPile = drawPile.slice(1); deps.updateStatus('Drawing a card.');
const hands = item.params['hands'];
const handsObj: Record<string, string[]> =
hands && typeof hands === 'object' && !Array.isArray(hands)
? (hands as Record<string, string[]>)
: {};
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.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }
@@ -292,58 +278,30 @@ export function createCardTableController(deps: CardTableControllerDeps): {
if (control.type === 'select') { if (control.type === 'select') {
const actionIdx = deps.state.cardTableCardActionIndex; const actionIdx = deps.state.cardTableCardActionIndex;
const card = hand[cardIndex];
const newHand = [...hand];
newHand.splice(cardIndex, 1);
const hands = item.params['hands'];
const handsObj: Record<string, string[]> =
hands && typeof hands === 'object' && !Array.isArray(hands)
? (hands as Record<string, string[]>)
: {};
if (actionIdx === 0) { if (actionIdx === 0) {
// Discard // Discard
const discardPile = getDiscardPile(item);
const newDiscard = [card, ...discardPile];
const newHands = { ...handsObj, [nickname]: newHand };
deps.signalingSend({ deps.signalingSend({
type: 'item_update', type: 'item_interact',
itemId: item.id, 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.state.mode = 'cardTableHand';
deps.updateStatus(`${cardName(card)} discarded.`); deps.updateStatus('Discarding card.');
}
deps.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }
if (actionIdx === 1) { if (actionIdx === 1) {
// Return to draw pile // Return to draw pile
const drawPile = getDrawPile(item);
const newDrawPile = [...drawPile, card];
const newHands = { ...handsObj, [nickname]: newHand };
deps.signalingSend({ deps.signalingSend({
type: 'item_update', type: 'item_interact',
itemId: item.id, 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.state.mode = 'cardTableHand';
deps.updateStatus(`${cardName(card)} returned to draw pile.`); deps.updateStatus('Returning card to draw pile.');
}
deps.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }
@@ -395,28 +353,15 @@ export function createCardTableController(deps: CardTableControllerDeps): {
// Take card from discard into hand // Take card from discard into hand
const cardIdx = deps.state.cardTableDiscardIndex; const cardIdx = deps.state.cardTableDiscardIndex;
const card = discardPile[cardIdx];
const newDiscard = [...discardPile];
newDiscard.splice(cardIdx, 1);
const hands = item.params['hands'];
const handsObj: Record<string, string[]> =
hands && typeof hands === 'object' && !Array.isArray(hands)
? (hands as Record<string, string[]>)
: {};
const currentHand = Array.isArray(handsObj[nickname]) ? [...handsObj[nickname]] : [];
currentHand.push(card);
const newHands = { ...handsObj, [nickname]: currentHand };
deps.signalingSend({ deps.signalingSend({
type: 'item_update', type: 'item_interact',
itemId: item.id, itemId: item.id,
params: { discard_pile: newDiscard, hands: newHands }, action: 'draw_from_discard',
params: { card_index: cardIdx },
}); });
deps.state.mode = 'cardTableMenu'; deps.state.mode = 'cardTableMenu';
deps.state.cardTableMenuIndex = 0; deps.state.cardTableMenuIndex = 0;
deps.updateStatus(`Took ${cardName(card)} from discard pile.`); deps.updateStatus('Taking card from discard pile.');
deps.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }

View File

@@ -141,10 +141,13 @@ export function createWhiteboardController(deps: WhiteboardControllerDeps): {
deps.sfxUiBlip(); deps.sfxUiBlip();
} else { } else {
// Delete // Delete
const newLines = [...lines]; deps.signalingSend({
newLines.splice(lineIndex, 1); type: 'item_interact',
deps.signalingSend({ type: 'item_update', itemId: item.id, params: { lines: newLines } }); itemId: item.id,
deps.state.whiteboardLineIndex = Math.min(deps.state.whiteboardLineIndex, newLines.length); 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.state.mode = 'whiteboardLines';
deps.updateStatus('Line deleted.'); deps.updateStatus('Line deleted.');
deps.sfxUiBlip(); deps.sfxUiBlip();
@@ -175,20 +178,21 @@ export function createWhiteboardController(deps: WhiteboardControllerDeps): {
return; return;
} }
const lines = getLines(item);
const editIndex = deps.state.whiteboardEditingLineIndex; const editIndex = deps.state.whiteboardEditingLineIndex;
let newLines: string[];
if (editIndex !== null) { if (editIndex !== null) {
newLines = [...lines]; deps.signalingSend({
newLines[editIndex] = text; type: 'item_interact',
itemId: item.id,
action: 'edit_line',
params: { line_index: editIndex, text },
});
} else { } else {
newLines = [...lines, text]; deps.signalingSend({
} type: 'item_interact',
itemId: item.id,
deps.signalingSend({ type: 'item_update', itemId: item.id, params: { lines: newLines } }); action: 'add_line',
params: { text },
if (editIndex === null) { });
deps.state.whiteboardLineIndex = newLines.length - 1;
} }
deps.state.nicknameInput = ''; deps.state.nicknameInput = '';

View File

@@ -241,7 +241,7 @@ export const itemRemoveSchema = z.object({
export const itemActionResultSchema = z.object({ export const itemActionResultSchema = z.object({
type: z.literal('item_action_result'), type: z.literal('item_action_result'),
ok: z.boolean(), 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(), message: z.string(),
itemId: z.string().optional(), itemId: z.string().optional(),
}); });
@@ -415,6 +415,7 @@ export type OutgoingMessage =
| { type: 'item_transfer'; itemId: string; targetUserId: string } | { type: 'item_transfer'; itemId: string; targetUserId: string }
| { type: 'item_use'; itemId: string } | { type: 'item_use'; itemId: string }
| { type: 'item_secondary_use'; itemId: string } | { type: 'item_secondary_use'; itemId: string }
| { type: 'item_interact'; itemId: string; action: string; params?: Record<string, unknown> }
| { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } | { 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' } | { type: 'item_piano_recording'; itemId: string; action: 'toggle_record' | 'playback' | 'stop_playback' | 'stop_record' }
| { | {

View File

@@ -11,6 +11,7 @@ ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = {
validate_update=module.validate_update, validate_update=module.validate_update,
use=module.use_item, use=module.use_item,
secondary_use=getattr(module, "secondary_use_item", None), secondary_use=getattr(module, "secondary_use_item", None),
interact=getattr(module, "interact_item", None),
) )
for item_type, module in ITEM_MODULES.items() for item_type, module in ITEM_MODULES.items()
} }

View File

@@ -26,3 +26,4 @@ class ItemTypeHandler:
validate_update: Callable[[WorldItem, dict], dict] validate_update: Callable[[WorldItem, dict], dict]
use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None secondary_use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] | None = None
interact: Callable[[WorldItem, str, dict | None, str], ItemUseResult] | None = None

View File

@@ -8,6 +8,26 @@ from typing import Callable
from ....item_types import ItemUseResult from ....item_types import ItemUseResult
from ....models import WorldItem 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]: def _build_deck(include_jokers: bool) -> list[str]:
"""Return a sorted list of 52 (or 54) card codes.""" """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": {}, "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, validate_update=validator.validate_update,
use_item=actions.use_item, use_item=actions.use_item,
secondary_use_item=actions.secondary_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 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.""" """Compose a plugin module-like object from split definition/validator/actions files."""
exports: dict[str, Any] = { 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 exports["use_item"] = use_item
if secondary_use_item is not None: if secondary_use_item is not None:
exports["secondary_use_item"] = secondary_use_item exports["secondary_use_item"] = secondary_use_item
if interact_item is not None:
exports["interact_item"] = interact_item
return SimpleNamespace(**exports) return SimpleNamespace(**exports)

View File

@@ -6,6 +6,9 @@ from typing import Callable
from ....item_types import ItemUseResult from ....item_types import ItemUseResult
from ....models import WorldItem 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: 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}.", self_message=f"You open {item.title}. {line_text}.",
others_message=f"{nickname} opens {item.title}.", 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, definition,
validate_update=validator.validate_update, validate_update=validator.validate_update,
use_item=actions.use_item, use_item=actions.use_item,
interact_item=actions.interact_item,
), ),
} }

View File

@@ -176,6 +176,13 @@ class ItemUpdatePacket(BasePacket):
params: dict | None = None 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 = ( ClientPacket = (
UpdatePositionPacket UpdatePositionPacket
| TeleportCompletePacket | TeleportCompletePacket
@@ -204,6 +211,7 @@ ClientPacket = (
| ItemTransferTargetsPacket | ItemTransferTargetsPacket
| ItemUsePacket | ItemUsePacket
| ItemSecondaryUsePacket | ItemSecondaryUsePacket
| ItemInteractPacket
| ItemPianoNotePacket | ItemPianoNotePacket
| ItemPianoRecordingPacket | ItemPianoRecordingPacket
| ItemUpdatePacket | ItemUpdatePacket
@@ -366,7 +374,7 @@ class ItemRemovePacket(BasePacket):
class ItemActionResultPacket(BasePacket): class ItemActionResultPacket(BasePacket):
type: Literal["item_action_result"] type: Literal["item_action_result"]
ok: bool 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 message: str
itemId: str | None = None itemId: str | None = None

View File

@@ -80,6 +80,7 @@ from .models import (
ItemClockAnnouncePacket, ItemClockAnnouncePacket,
ItemDeletePacket, ItemDeletePacket,
ItemDropPacket, ItemDropPacket,
ItemInteractPacket,
ItemPianoNoteBroadcastPacket, ItemPianoNoteBroadcastPacket,
ItemPianoNotePacket, ItemPianoNotePacket,
ItemPianoRecordingPacket, ItemPianoRecordingPacket,
@@ -2972,6 +2973,52 @@ class SignalingServer:
await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id) await self._send_item_result(client, True, "secondary_use", secondary_result.self_message, item.id)
return 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 isinstance(packet, ItemPianoNotePacket):
if not self._client_has_permission(client, "item.use"): if not self._client_has_permission(client, "item.use"):
return return