Support client interact action to allow updating of properties for non owning players
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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' }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user