From b0fa040d33746ebd6af33b26c32b235a11c3c746 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 28 Feb 2026 05:11:49 -0500 Subject: [PATCH] Add z item management menu with transfer and yes/no confirmation --- client/public/help.json | 4 +- client/public/version.js | 2 +- client/src/input/mainCommandRouter.ts | 7 +- client/src/input/yesNoMenu.ts | 18 ++ client/src/main.ts | 287 +++++++++++++++++-- client/src/network/protocol.ts | 3 +- client/src/state/gameState.ts | 5 +- docs/controls.md | 8 +- docs/item-schema.md | 2 +- docs/protocol-notes.md | 2 + server/app/models.py | 9 +- server/app/server.py | 41 ++- server/tests/test_models.py | 6 + server/tests/test_server_message_handling.py | 110 +++++++ 14 files changed, 476 insertions(+), 28 deletions(-) create mode 100644 client/src/input/yesNoMenu.ts diff --git a/client/public/help.json b/client/public/help.json index 018807e..468ac94 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -82,8 +82,8 @@ "description": "Pick up/drop item" }, { - "keys": "Shift+D", - "description": "Delete item" + "keys": "Z", + "description": "Item management (delete/transfer when permitted)" }, { "keys": "Enter", diff --git a/client/public/version.js b/client/public/version.js index 0371b2e..5ab9d44 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.28 R317"; +window.CHGRID_WEB_VERSION = "2026.02.28 R318"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/input/mainCommandRouter.ts b/client/src/input/mainCommandRouter.ts index fb76b1f..93385a1 100644 --- a/client/src/input/mainCommandRouter.ts +++ b/client/src/input/mainCommandRouter.ts @@ -23,7 +23,8 @@ export type MainModeCommand = | 'speakUsers' | 'addItem' | 'locateOrListItems' - | 'pickupDropOrDelete' + | 'pickupDropItem' + | 'openItemManagement' | 'editOrInspectItem' | 'pingServer' | 'locateOrListUsers' @@ -57,12 +58,12 @@ export function resolveMainModeCommand(code: string, shiftKey: boolean): MainMod if (code === 'KeyU') return shiftKey ? null : 'speakUsers'; if (code === 'KeyA') return shiftKey ? null : 'addItem'; if (code === 'KeyI') return 'locateOrListItems'; - if (code === 'KeyD') return 'pickupDropOrDelete'; + if (code === 'KeyD') return shiftKey ? null : 'pickupDropItem'; if (code === 'KeyO') return 'editOrInspectItem'; if (code === 'KeyP') return shiftKey ? null : 'pingServer'; if (code === 'KeyL') return 'locateOrListUsers'; if (code === 'Slash') return shiftKey ? 'openHelp' : 'openChat'; - if (code === 'KeyZ') return shiftKey ? 'openAdminMenu' : null; + if (code === 'KeyZ') return shiftKey ? 'openAdminMenu' : 'openItemManagement'; if (code === 'Comma') return shiftKey ? 'chatFirst' : 'chatPrev'; if (code === 'Period') return shiftKey ? 'chatLast' : 'chatNext'; if (code === 'Escape') return 'escape'; diff --git a/client/src/input/yesNoMenu.ts b/client/src/input/yesNoMenu.ts new file mode 100644 index 0000000..3c0c6bb --- /dev/null +++ b/client/src/input/yesNoMenu.ts @@ -0,0 +1,18 @@ +import { handleListControlKey, type ListControlResult } from './listController'; + +export type YesNoOption = { + id: 'no' | 'yes'; + label: 'No' | 'Yes'; +}; + +export const YES_NO_OPTIONS: readonly YesNoOption[] = [ + { id: 'no', label: 'No' }, + { id: 'yes', label: 'Yes' }, +]; + +/** + * Handles standardized yes/no menu key input using shared list controls. + */ +export function handleYesNoMenuInput(code: string, key: string, currentIndex: number): ListControlResult { + return handleListControlKey(code, key, YES_NO_OPTIONS, currentIndex, (entry) => entry.label); +} diff --git a/client/src/main.ts b/client/src/main.ts index 8a9dfcf..268c16d 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -26,6 +26,7 @@ import { import { resolveMainModeCommand } from './input/mainCommandRouter'; import { dispatchModeInput } from './input/modeDispatcher'; import { handleListControlKey } from './input/listController'; +import { handleYesNoMenuInput, YES_NO_OPTIONS } from './input/yesNoMenu'; import { getEditSessionAction } from './input/editSession'; import { formatSteppedNumber, snapNumberToStep } from './input/numeric'; import { type IncomingMessage, type OutgoingMessage } from './network/protocol'; @@ -211,6 +212,20 @@ type AdminPendingUserMutation = | { action: 'ban'; username: string } | { action: 'unban'; username: string }; +type ItemManagementAction = 'delete' | 'transfer'; + +type ItemManagementOption = { + action: ItemManagementAction; + label: string; +}; + +type ItemManagementConfirmContext = { + itemId: string; + action: ItemManagementAction; + prompt: string; + targetId?: string; +}; + /** Builds linearized help-view lines from sectioned help content. */ function buildHelpLines(help: HelpData): string[] { const lines: string[] = []; @@ -263,6 +278,7 @@ let lastAnnouncementAt = 0; let outputMode = settings.loadOutputMode(); let authMode: 'login' | 'register' = 'login'; let authUsername = settings.loadAuthUsername(); +let authUserId = ''; let authPolicy: AuthPolicy | null = null; let authRole = 'user'; let authPermissions = new Set(); @@ -320,6 +336,13 @@ let adminPendingUserAction: 'set_role' | 'ban' | 'unban' | null = null; let adminSelectedRoleName = ''; let adminSelectedUsername = ''; let adminPendingUserMutation: AdminPendingUserMutation | null = null; +let itemManagementSelectedItemId: string | null = null; +let itemManagementOptions: ItemManagementOption[] = []; +let itemManagementOptionIndex = 0; +let itemManagementTargetUserIds: string[] = []; +let itemManagementTargetUserIndex = 0; +let itemManagementConfirmIndex = 0; +let itemManagementConfirmContext: ItemManagementConfirmContext | null = null; let activeTeleport: | { startX: number; @@ -1000,7 +1023,10 @@ function getCarriedItem(): WorldItem | null { } /** Opens the shared item-selection flow for the provided context and items. */ -function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'inspect', items: WorldItem[]): void { +function beginItemSelection( + context: 'pickup' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | 'manage', + items: WorldItem[], +): void { if (items.length === 0) { updateStatus('No items available.'); audio.sfxUiCancel(); @@ -1014,6 +1040,66 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins audio.sfxUiBlip(); } +/** Returns whether the local user can delete the provided item. */ +function canManageDeleteItem(item: WorldItem): boolean { + if (hasPermission('item.delete.any')) return true; + return hasPermission('item.delete.own') && authUserId.length > 0 && item.createdBy === authUserId; +} + +/** Returns whether the local user can transfer the provided item. */ +function canManageTransferItem(item: WorldItem): boolean { + if (hasPermission('item.transfer.any')) return true; + return hasPermission('item.transfer.own') && authUserId.length > 0 && item.createdBy === authUserId; +} + +/** Builds available item-management actions for one selected item. */ +function itemManagementOptionsFor(item: WorldItem): ItemManagementOption[] { + const options: ItemManagementOption[] = []; + if (canManageDeleteItem(item)) { + options.push({ action: 'delete', label: 'Delete item' }); + } + if (canManageTransferItem(item) && state.peers.size > 0) { + options.push({ action: 'transfer', label: 'Transfer item' }); + } + return options; +} + +/** Opens item-management options for one selected item. */ +function beginItemManagement(item: WorldItem): void { + const options = itemManagementOptionsFor(item); + if (options.length === 0) { + updateStatus('No item management actions available.'); + audio.sfxUiCancel(); + return; + } + itemManagementSelectedItemId = item.id; + itemManagementOptions = options; + itemManagementOptionIndex = 0; + state.mode = 'itemManageOptions'; + updateStatus(itemManagementOptions[0].label); + audio.sfxUiBlip(); +} + +/** Opens standardized yes/no confirmation prompt for a pending item-management action. */ +function openItemManagementConfirm(context: ItemManagementConfirmContext): void { + itemManagementConfirmContext = context; + itemManagementConfirmIndex = 0; + state.mode = 'confirmYesNo'; + updateStatus(`${context.prompt} ${YES_NO_OPTIONS[itemManagementConfirmIndex].label}.`); + audio.sfxUiBlip(); +} + +/** Clears temporary item-management menu state. */ +function resetItemManagementState(): void { + itemManagementSelectedItemId = null; + itemManagementOptions = []; + itemManagementOptionIndex = 0; + itemManagementTargetUserIds = []; + itemManagementTargetUserIndex = 0; + itemManagementConfirmIndex = 0; + itemManagementConfirmContext = null; +} + /** Opens item property browsing/editing mode for one item. */ function beginItemProperties(item: WorldItem, showAll = false): void { itemPropertiesShowAll = showAll; @@ -1507,6 +1593,7 @@ function sendAuthRequest(): void { function handleAuthRequired(message: Extract): void { const hadPendingRequest = pendingAuthRequest; pendingAuthRequest = false; + authUserId = ''; applyAuthPolicy(message.authPolicy); applyAuthPermissions('user', []); applyServerAdminMenuActions([]); @@ -1530,6 +1617,7 @@ async function handleAuthResult(message: Extract { let connectedAnnouncement: string | null = null; let playSelfLoginSound = false; if (message.type === 'welcome') { + authUserId = String(message.auth?.userId || '').trim(); applyAuthPolicy(message.auth?.policy); applyAuthPermissions(message.auth?.role, message.auth?.permissions); const uiAdminActions = @@ -2230,22 +2321,8 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { ); return; } - case 'pickupDropOrDelete': { + case 'pickupDropItem': { const carried = getCarriedItem(); - if (shiftKey) { - const squareItems = getItemsAtPosition(state.player.x, state.player.y); - if (squareItems.length === 0) { - updateStatus('No items to delete.'); - audio.sfxUiCancel(); - return; - } - if (squareItems.length === 1) { - signaling.send({ type: 'item_delete', itemId: squareItems[0].id }); - return; - } - beginItemSelection('delete', squareItems); - return; - } if (carried) { signaling.send({ type: 'item_drop', itemId: carried.id, x: state.player.x, y: state.player.y }); return; @@ -2263,6 +2340,26 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { beginItemSelection('pickup', squareItems); return; } + case 'openItemManagement': { + const squareItems = getItemsAtPosition(state.player.x, state.player.y); + if (squareItems.length === 0) { + updateStatus('No items to manage on this square.'); + audio.sfxUiCancel(); + return; + } + const manageable = squareItems.filter((item) => itemManagementOptionsFor(item).length > 0); + if (manageable.length === 0) { + updateStatus('No permitted item management actions here.'); + audio.sfxUiCancel(); + return; + } + if (manageable.length === 1) { + beginItemManagement(manageable[0]); + return; + } + beginItemSelection('manage', manageable); + return; + } case 'editOrInspectItem': { const squareItems = getItemsAtPosition(state.player.x, state.player.y); const carried = getCarriedItem(); @@ -2730,6 +2827,10 @@ function handleSelectItemModeInput(code: string, key: string): void { beginItemProperties(selected, true); return; } + if (context === 'manage') { + beginItemManagement(selected); + return; + } return; } if (control.type === 'cancel') { @@ -2740,6 +2841,157 @@ function handleSelectItemModeInput(code: string, key: string): void { } } +/** Handles item-management action menu (`z`) for the selected square item. */ +function handleItemManageOptionsModeInput(code: string, key: string): void { + if (!itemManagementSelectedItemId) { + state.mode = 'normal'; + resetItemManagementState(); + return; + } + const item = state.items.get(itemManagementSelectedItemId); + if (!item) { + state.mode = 'normal'; + resetItemManagementState(); + updateStatus('Item no longer exists.'); + audio.sfxUiCancel(); + return; + } + itemManagementOptions = itemManagementOptionsFor(item); + if (itemManagementOptions.length === 0) { + state.mode = 'normal'; + resetItemManagementState(); + updateStatus('No item management actions available.'); + audio.sfxUiCancel(); + return; + } + itemManagementOptionIndex = Math.max(0, Math.min(itemManagementOptionIndex, itemManagementOptions.length - 1)); + const control = handleListControlKey(code, key, itemManagementOptions, itemManagementOptionIndex, (entry) => entry.label); + if (control.type === 'move') { + itemManagementOptionIndex = control.index; + updateStatus(itemManagementOptions[itemManagementOptionIndex].label); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const option = itemManagementOptions[itemManagementOptionIndex]; + if (option.action === 'delete') { + openItemManagementConfirm({ + itemId: item.id, + action: 'delete', + prompt: `Delete ${itemLabel(item)}?`, + }); + return; + } + const targetIds = Array.from(state.peers.values()) + .map((peer) => peer.id) + .filter((peerId) => peerId !== state.player.id && state.peers.has(peerId)) + .sort((a, b) => { + const left = state.peers.get(a)?.nickname ?? ''; + const right = state.peers.get(b)?.nickname ?? ''; + return left.localeCompare(right, undefined, { sensitivity: 'base' }); + }); + if (targetIds.length === 0) { + updateStatus('No users available to transfer to.'); + audio.sfxUiCancel(); + return; + } + itemManagementTargetUserIds = targetIds; + itemManagementTargetUserIndex = 0; + state.mode = 'itemManageTransferUser'; + const firstLabel = state.peers.get(itemManagementTargetUserIds[0])?.nickname ?? 'Unknown user'; + updateStatus(firstLabel); + audio.sfxUiBlip(); + return; + } + if (control.type === 'cancel') { + state.mode = 'normal'; + resetItemManagementState(); + updateStatus('Cancelled.'); + audio.sfxUiCancel(); + } +} + +/** Handles target-user selection for item transfer action. */ +function handleItemManageTransferUserModeInput(code: string, key: string): void { + if (!itemManagementSelectedItemId || itemManagementTargetUserIds.length === 0) { + state.mode = 'itemManageOptions'; + return; + } + const control = handleListControlKey(code, key, itemManagementTargetUserIds, itemManagementTargetUserIndex, (userId) => { + return state.peers.get(userId)?.nickname ?? 'Unknown user'; + }); + if (control.type === 'move') { + itemManagementTargetUserIndex = control.index; + const label = state.peers.get(itemManagementTargetUserIds[itemManagementTargetUserIndex])?.nickname ?? 'Unknown user'; + updateStatus(label); + audio.sfxUiBlip(); + return; + } + if (control.type === 'select') { + const item = state.items.get(itemManagementSelectedItemId); + const targetId = itemManagementTargetUserIds[itemManagementTargetUserIndex]; + if (!item || !targetId) { + state.mode = 'itemManageOptions'; + audio.sfxUiCancel(); + return; + } + const targetLabel = state.peers.get(targetId)?.nickname ?? 'Unknown user'; + openItemManagementConfirm({ + itemId: item.id, + action: 'transfer', + prompt: `Transfer ${itemLabel(item)} to ${targetLabel}?`, + targetId, + }); + return; + } + if (control.type === 'cancel') { + state.mode = 'itemManageOptions'; + updateStatus(itemManagementOptions[itemManagementOptionIndex]?.label ?? 'Item management.'); + audio.sfxUiCancel(); + } +} + +/** Handles standardized yes/no confirmation for pending item-management actions. */ +function handleConfirmYesNoModeInput(code: string, key: string): void { + if (!itemManagementConfirmContext) { + state.mode = 'normal'; + resetItemManagementState(); + return; + } + const control = handleYesNoMenuInput(code, key, itemManagementConfirmIndex); + if (control.type === 'move') { + itemManagementConfirmIndex = control.index; + updateStatus(`${itemManagementConfirmContext.prompt} ${YES_NO_OPTIONS[itemManagementConfirmIndex].label}.`); + audio.sfxUiBlip(); + return; + } + if (control.type === 'cancel') { + state.mode = 'itemManageOptions'; + itemManagementConfirmContext = null; + updateStatus(itemManagementOptions[itemManagementOptionIndex]?.label ?? 'Item management.'); + audio.sfxUiCancel(); + return; + } + if (control.type === 'select') { + const selected = YES_NO_OPTIONS[itemManagementConfirmIndex]; + const context = itemManagementConfirmContext; + itemManagementConfirmContext = null; + if (selected.id === 'no') { + state.mode = 'itemManageOptions'; + updateStatus(itemManagementOptions[itemManagementOptionIndex]?.label ?? 'Cancelled.'); + audio.sfxUiCancel(); + return; + } + state.mode = 'normal'; + if (context.action === 'delete') { + signaling.send({ type: 'item_delete', itemId: context.itemId }); + } else if (context.action === 'transfer' && context.targetId) { + signaling.send({ type: 'item_transfer', itemId: context.itemId, targetId: context.targetId }); + } + resetItemManagementState(); + } +} + /** Handles top-level Shift+Z admin menu action selection. */ function handleAdminMenuModeInput(code: string, key: string): void { if (adminMenuActions.length === 0) { @@ -3214,6 +3466,9 @@ function setupInputHandlers(): void { listItems: (currentCode, currentKey) => handleListItemsModeInput(currentCode, currentKey), addItem: (currentCode, currentKey) => handleAddItemModeInput(currentCode, currentKey), selectItem: (currentCode, currentKey) => handleSelectItemModeInput(currentCode, currentKey), + itemManageOptions: (currentCode, currentKey) => handleItemManageOptionsModeInput(currentCode, currentKey), + itemManageTransferUser: (currentCode, currentKey) => handleItemManageTransferUserModeInput(currentCode, currentKey), + confirmYesNo: (currentCode, currentKey) => handleConfirmYesNoModeInput(currentCode, currentKey), adminMenu: (currentCode, currentKey) => handleAdminMenuModeInput(currentCode, currentKey), adminRoleList: (currentCode, currentKey) => handleAdminRoleListModeInput(currentCode, currentKey), adminRolePermissionList: (currentCode, currentKey) => handleAdminRolePermissionListModeInput(currentCode, currentKey), diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index b5cb7e4..34a7ffe 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -214,7 +214,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', 'use', 'secondary_use', 'update']), + action: z.enum(['add', 'pickup', 'drop', 'delete', 'transfer', 'use', 'secondary_use', 'update']), message: z.string(), itemId: z.string().optional(), }); @@ -369,6 +369,7 @@ export type OutgoingMessage = | { type: 'item_pickup'; itemId: string } | { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_delete'; itemId: string } + | { type: 'item_transfer'; itemId: string; targetId: string } | { type: 'item_use'; itemId: string } | { type: 'item_secondary_use'; itemId: string } | { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index 9541a21..792128d 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -23,7 +23,7 @@ export type WorldItem = { display?: Record; }; -export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | null; +export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | 'manage' | null; export type GameMode = | 'normal' @@ -39,6 +39,9 @@ export type GameMode = | 'itemProperties' | 'itemPropertyEdit' | 'itemPropertyOptionSelect' + | 'itemManageOptions' + | 'itemManageTransferUser' + | 'confirmYesNo' | 'adminMenu' | 'adminRoleList' | 'adminRolePermissionList' diff --git a/docs/controls.md b/docs/controls.md index f13fe0e..d777a5b 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -31,7 +31,7 @@ This document is the authoritative keymap for the client. - `O`: Edit item properties - `Shift+O`: Inspect all item properties - `D`: Pick up/drop item -- `Shift+D`: Delete item +- `Z`: Item management menu (delete/transfer when permitted) - `Enter`: Use item - `Shift+Enter`: Secondary item action @@ -82,6 +82,12 @@ Applies to effect select, user/item list modes, item selection, item property li - `Space`: Read tooltip/help for current option (where metadata is available) - First-letter navigation: jump to next matching entry +## Yes/No Confirmation Menu + +- `ArrowUp` / `ArrowDown`: Move between `No` and `Yes` +- `Enter`: Confirm current choice (default selection is `No`) +- `Escape`: Cancel + ## Admin Modes - `Shift+Z`: Open admin menu diff --git a/docs/item-schema.md b/docs/item-schema.md index d1322a1..bd5d33c 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -243,7 +243,7 @@ { "type": "item_action_result", "ok": true, - "action": "add | pickup | drop | delete | use | update", + "action": "add | pickup | drop | delete | transfer | use | secondary_use | update", "message": "human-readable status", "itemId": "optional-item-id" } diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index d39451b..9d88bc5 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -27,6 +27,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `chat_message`: player chat. - `ping`: latency measurement. - `item_add`, `item_pickup`, `item_drop`, `item_delete`, `item_use`, `item_update`: item actions. +- `item_transfer`: transfer item ownership to another connected user. - `item_secondary_use`: trigger type-specific secondary action when implemented. - `item_piano_note`: realtime piano note on/off for active piano use mode. - `item_piano_recording`: piano record/playback control (`toggle_record`, `playback`, `stop_playback`). @@ -59,6 +60,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `item_upsert` is full-state replacement for one item, not partial patch. - `item_upsert.item.display` is server-owned display text for readonly/system properties (for example: `createdBy`, `updatedBy`, `createdAt`, `updatedAt`, `capabilities`, `useSound`, `emitSound`). - `item_action_result` messages are intended for direct screen-reader/user status feedback. + - `action` includes: `add`, `pickup`, `drop`, `delete`, `transfer`, `use`, `secondary_use`, `update` - Successful `item_pickup` and `item_drop` also emit system chat lines to other users in the room. - Piano runtime control no longer depends on parsing `item_action_result.message` text. - `item_piano_status` carries machine-readable piano events (`use_mode_entered`, record/playback transitions). diff --git a/server/app/models.py b/server/app/models.py index 6085293..e47dfd7 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -132,6 +132,12 @@ class ItemDeletePacket(BasePacket): itemId: str +class ItemTransferPacket(BasePacket): + type: Literal["item_transfer"] + itemId: str + targetId: str + + class ItemUsePacket(BasePacket): type: Literal["item_use"] itemId: str @@ -186,6 +192,7 @@ ClientPacket = ( | ItemPickupPacket | ItemDropPacket | ItemDeletePacket + | ItemTransferPacket | ItemUsePacket | ItemSecondaryUsePacket | ItemPianoNotePacket @@ -348,7 +355,7 @@ class ItemRemovePacket(BasePacket): class ItemActionResultPacket(BasePacket): type: Literal["item_action_result"] ok: bool - action: Literal["add", "pickup", "drop", "delete", "use", "secondary_use", "update"] + action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update"] message: str itemId: str | None = None diff --git a/server/app/server.py b/server/app/server.py index 50ffebe..d31b3ae 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -86,6 +86,7 @@ from .models import ( ItemPickupPacket, ItemRemovePacket, ItemSecondaryUsePacket, + ItemTransferPacket, ItemUpdatePacket, ItemUpsertPacket, ItemUsePacket, @@ -1240,7 +1241,7 @@ class SignalingServer: self, client: ClientConnection, ok: bool, - action: Literal["add", "pickup", "drop", "delete", "use", "secondary_use", "update"], + action: Literal["add", "pickup", "drop", "delete", "transfer", "use", "secondary_use", "update"], message: str, item_id: str | None = None, ) -> None: @@ -2443,6 +2444,44 @@ class SignalingServer: await self._send_item_result(client, True, "delete", f"Deleted {item.title}.", item.id) return + if isinstance(packet, ItemTransferPacket): + item = self.items.get(packet.itemId) + if not item: + await self._send_item_result(client, False, "transfer", "Item not found.") + return + if item.carrierId: + await self._send_item_result(client, False, "transfer", "Item cannot be transferred while carried.", item.id) + return + if item.x != client.x or item.y != client.y: + await self._send_item_result(client, False, "transfer", "Item is not on your square.", item.id) + return + can_transfer_any = self._client_has_permission(client, "item.transfer.any") + can_transfer_own = self._client_has_permission(client, "item.transfer.own") and self._owns_item(client, item) + if not can_transfer_any and not can_transfer_own: + await self._send_item_result(client, False, "transfer", "Not authorized to transfer this item.", item.id) + return + target = self._get_client_by_id(packet.targetId) + if not target or not target.authenticated or not target.user_id: + await self._send_item_result(client, False, "transfer", "Target user is not available.", item.id) + return + if target.id == client.id: + await self._send_item_result(client, False, "transfer", "Cannot transfer an item to yourself.", item.id) + return + if item.createdBy == target.user_id: + await self._send_item_result(client, False, "transfer", "Item already belongs to that user.", item.id) + return + item.createdBy = target.user_id + item.createdByName = target.username or target.nickname + 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 + await self._broadcast_item(item) + self._request_state_save() + await self._send_item_result(client, True, "transfer", f"Transferred {item.title} to {target.nickname}.", item.id) + return + if isinstance(packet, ItemUsePacket): if not self._client_has_permission(client, "item.use"): await self._send_item_result(client, False, "use", "Not authorized to use items.") diff --git a/server/tests/test_models.py b/server/tests/test_models.py index 177c205..fbe80f3 100644 --- a/server/tests/test_models.py +++ b/server/tests/test_models.py @@ -30,3 +30,9 @@ def test_item_piano_recording_packet_validates() -> None: assert packet.type == "item_piano_recording" stop_packet = adapter.validate_python({"type": "item_piano_recording", "itemId": "p1", "action": "stop_record"}) assert stop_packet.type == "item_piano_recording" + + +def test_item_transfer_packet_validates() -> None: + adapter = TypeAdapter(ClientPacket) + packet = adapter.validate_python({"type": "item_transfer", "itemId": "i1", "targetId": "u2"}) + assert packet.type == "item_transfer" diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index d7fe2bd..94efa64 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -325,6 +325,116 @@ async def test_item_drop_rejects_out_of_bounds(monkeypatch: pytest.MonkeyPatch) assert "out of bounds" in send_payloads[-1].message.lower() +@pytest.mark.asyncio +async def test_item_transfer_updates_item_owner(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) + owner_ws = _fake_ws() + target_ws = _fake_ws() + owner = ClientConnection( + websocket=owner_ws, + id="u1", + nickname="owner", + authenticated=True, + user_id="1", + username="owner_user", + permissions={"item.transfer.own"}, + x=5, + y=6, + ) + target = ClientConnection( + websocket=target_ws, + id="u2", + nickname="target", + authenticated=True, + user_id="2", + username="target_user", + permissions=set(), + x=10, + y=10, + ) + server.clients[owner_ws] = owner + server.clients[target_ws] = target + item = server.item_service.default_item(owner, "dice") + item.x = owner.x + item.y = owner.y + server.item_service.add_item(item) + + send_payloads: list[object] = [] + broadcasted_items: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + async def fake_broadcast_item(broadcast_item: object) -> None: + broadcasted_items.append(broadcast_item) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast_item", fake_broadcast_item) + + await server._handle_message(owner, json.dumps({"type": "item_transfer", "itemId": item.id, "targetId": target.id})) + + assert item.createdBy == target.user_id + assert item.createdByName == target.username + assert broadcasted_items + assert send_payloads + result = send_payloads[-1] + assert result.type == "item_action_result" + assert result.ok is True + assert result.action == "transfer" + + +@pytest.mark.asyncio +async def test_item_transfer_rejects_when_not_authorized(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) + owner_ws = _fake_ws() + target_ws = _fake_ws() + owner = ClientConnection( + websocket=owner_ws, + id="u1", + nickname="owner", + authenticated=True, + user_id="1", + username="owner_user", + permissions={"item.use"}, + x=5, + y=6, + ) + target = ClientConnection( + websocket=target_ws, + id="u2", + nickname="target", + authenticated=True, + user_id="2", + username="target_user", + permissions=set(), + x=10, + y=10, + ) + server.clients[owner_ws] = owner + server.clients[target_ws] = target + item = server.item_service.default_item(owner, "dice") + item.x = owner.x + item.y = owner.y + server.item_service.add_item(item) + + send_payloads: list[object] = [] + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + + await server._handle_message(owner, json.dumps({"type": "item_transfer", "itemId": item.id, "targetId": target.id})) + + assert item.createdBy == owner.user_id + assert send_payloads + result = send_payloads[-1] + assert result.type == "item_action_result" + assert result.ok is False + assert result.action == "transfer" + assert "not authorized" in result.message.lower() + + @pytest.mark.asyncio async def test_broadcast_fanout_is_concurrent(monkeypatch: pytest.MonkeyPatch) -> None: server = SignalingServer("127.0.0.1", 8765, None, None)