diff --git a/client/public/version.js b/client/public/version.js index d4a7322..3136faa 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.03.01 R322"; +window.CHGRID_WEB_VERSION = "2026.03.01 R323"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index 11d8495..1294819 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -224,7 +224,13 @@ type ItemManagementConfirmContext = { itemId: string; action: ItemManagementAction; prompt: string; - targetId?: string; + targetUserId?: string; +}; + +type ItemTransferTarget = { + userId: string; + username: string; + online: boolean; }; /** Builds linearized help-view lines from sectioned help content. */ @@ -341,8 +347,8 @@ let adminDeleteConfirmIndex = 0; let itemManagementSelectedItemId: string | null = null; let itemManagementOptions: ItemManagementOption[] = []; let itemManagementOptionIndex = 0; -let itemManagementTargetUserIds: string[] = []; let itemManagementTargetUserIndex = 0; +let itemManagementTransferTargets: ItemTransferTarget[] = []; let itemManagementConfirmIndex = 0; let itemManagementConfirmContext: ItemManagementConfirmContext | null = null; let activeTeleport: @@ -1066,10 +1072,9 @@ function itemManagementOptionsFor(item: WorldItem): ItemManagementOption[] { return options; } -/** Resolves transfer-target label for either local player id or a remote peer id. */ -function transferTargetLabel(userPeerId: string): string { - if (userPeerId === state.player.id) return state.player.nickname || 'Unknown user'; - return state.peers.get(userPeerId)?.nickname ?? 'Unknown user'; +/** Resolves spoken label for one transfer target entry. */ +function transferTargetLabel(target: ItemTransferTarget): string { + return target.online ? `${target.username}, online` : `${target.username}, offline`; } /** Opens item-management options for one selected item. */ @@ -1102,7 +1107,7 @@ function resetItemManagementState(): void { itemManagementSelectedItemId = null; itemManagementOptions = []; itemManagementOptionIndex = 0; - itemManagementTargetUserIds = []; + itemManagementTransferTargets = []; itemManagementTargetUserIndex = 0; itemManagementConfirmIndex = 0; itemManagementConfirmContext = null; @@ -1786,6 +1791,24 @@ function handleAdminUsersList(message: Extract): void { + if (itemManagementSelectedItemId !== message.itemId) return; + itemManagementTransferTargets = [...message.targets].sort((a, b) => + a.username.localeCompare(b.username, undefined, { sensitivity: 'base' }), + ); + if (itemManagementTransferTargets.length === 0) { + state.mode = 'itemManageOptions'; + updateStatus('No users available to transfer to.'); + audio.sfxUiCancel(); + return; + } + itemManagementTargetUserIndex = 0; + state.mode = 'itemManageTransferUser'; + updateStatus(transferTargetLabel(itemManagementTransferTargets[0])); + audio.sfxUiBlip(); +} + /** Handles structured admin action result packets. */ function handleAdminActionResult(message: Extract): void { if (message.action === 'role_update_permissions') { @@ -2009,6 +2032,7 @@ const onAppMessage = createOnMessageHandler({ handleAdminRolesList, handleAdminUsersList, handleAdminActionResult, + handleItemTransferTargets, isPeerNegotiationReady: () => peerNegotiationReady, enqueuePendingSignal: (message) => { pendingSignalMessages.push(message); @@ -2900,33 +2924,10 @@ function handleItemManageOptionsModeInput(code: string, key: string): void { }); return; } - const ownerUserId = item.createdBy.trim(); - const targetIds = [ - ...(state.player.id ? [state.player.id] : []), - ...Array.from(state.peers.values()).map((peer) => peer.id), - ] - .filter((peerId, index, arr) => arr.indexOf(peerId) === index) - .filter((peerId) => { - if (!ownerUserId) return true; - if (peerId === state.player.id) return authUserId !== ownerUserId; - const peer = state.peers.get(peerId); - return (peer?.userId ?? '') !== ownerUserId; - }) - .sort((a, b) => { - const left = transferTargetLabel(a); - const right = transferTargetLabel(b); - return left.localeCompare(right, undefined, { sensitivity: 'base' }); - }); - if (targetIds.length === 0) { - updateStatus('No users available to transfer to.'); - audio.sfxUiCancel(); - return; - } - itemManagementTargetUserIds = targetIds; + itemManagementTransferTargets = []; itemManagementTargetUserIndex = 0; - state.mode = 'itemManageTransferUser'; - const firstLabel = transferTargetLabel(itemManagementTargetUserIds[0]); - updateStatus(firstLabel); + signaling.send({ type: 'item_transfer_targets', itemId: item.id }); + updateStatus('Loading users...'); audio.sfxUiBlip(); return; } @@ -2940,34 +2941,37 @@ function handleItemManageOptionsModeInput(code: string, key: string): void { /** Handles target-user selection for item transfer action. */ function handleItemManageTransferUserModeInput(code: string, key: string): void { - if (!itemManagementSelectedItemId || itemManagementTargetUserIds.length === 0) { + if (!itemManagementSelectedItemId || itemManagementTransferTargets.length === 0) { state.mode = 'itemManageOptions'; return; } - const control = handleListControlKey(code, key, itemManagementTargetUserIds, itemManagementTargetUserIndex, (userId) => { - return transferTargetLabel(userId); - }); + const control = handleListControlKey( + code, + key, + itemManagementTransferTargets, + itemManagementTargetUserIndex, + (target) => transferTargetLabel(target), + ); if (control.type === 'move') { itemManagementTargetUserIndex = control.index; - const label = transferTargetLabel(itemManagementTargetUserIds[itemManagementTargetUserIndex]); + const label = transferTargetLabel(itemManagementTransferTargets[itemManagementTargetUserIndex]); updateStatus(label); audio.sfxUiBlip(); return; } if (control.type === 'select') { const item = state.items.get(itemManagementSelectedItemId); - const targetId = itemManagementTargetUserIds[itemManagementTargetUserIndex]; - if (!item || !targetId) { + const target = itemManagementTransferTargets[itemManagementTargetUserIndex]; + if (!item || !target) { state.mode = 'itemManageOptions'; audio.sfxUiCancel(); return; } - const targetLabel = transferTargetLabel(targetId); openItemManagementConfirm({ itemId: item.id, action: 'transfer', - prompt: `Transfer ${itemLabel(item)} to ${targetLabel}?`, - targetId, + prompt: `Transfer ${itemLabel(item)} to ${target.username}?`, + targetUserId: target.userId, }); return; } @@ -3012,8 +3016,8 @@ function handleConfirmYesNoModeInput(code: string, key: string): void { 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 }); + } else if (context.action === 'transfer' && context.targetUserId) { + signaling.send({ type: 'item_transfer', itemId: context.itemId, targetUserId: context.targetUserId }); } resetItemManagementState(); } @@ -3331,6 +3335,8 @@ function handleAdminUserDeleteConfirmModeInput(code: string, key: string): void audio.sfxUiCancel(); return; } + state.mode = 'adminUserList'; + updateStatus(`Deleting account ${adminSelectedUsername}...`); adminPendingUserMutation = { action: 'delete_account', username: adminSelectedUsername }; signaling.send({ type: 'admin_user_delete', username: adminSelectedUsername }); } diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index ac96247..da8ee4b 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -15,7 +15,7 @@ type MessageHandlerDeps = { addItemTypeIndex: number; player: { id: string | null; nickname: string; x: number; y: number }; running: boolean; - peers: Map; + peers: Map; items: Map; mode: string; selectedItemId: string | null; @@ -77,6 +77,7 @@ type MessageHandlerDeps = { handleAdminRolesList: (message: Extract) => void; handleAdminUsersList: (message: Extract) => void; handleAdminActionResult: (message: Extract) => void; + handleItemTransferTargets: (message: Extract) => void; isPeerNegotiationReady: () => boolean; enqueuePendingSignal: (message: Extract) => void; }; @@ -106,6 +107,9 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco case 'admin_action_result': deps.handleAdminActionResult(message); break; + case 'item_transfer_targets': + deps.handleItemTransferTargets(message); + break; case 'welcome': if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 6108e0d..9afbbe2 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -221,6 +221,18 @@ export const itemActionResultSchema = z.object({ itemId: z.string().optional(), }); +export const itemTransferTargetsSchema = z.object({ + type: z.literal('item_transfer_targets'), + itemId: z.string(), + targets: z.array( + z.object({ + userId: z.string(), + username: z.string(), + online: z.boolean(), + }), + ), +}); + export const itemUseSoundSchema = z.object({ type: z.literal('item_use_sound'), itemId: z.string(), @@ -337,6 +349,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [ itemUpsertSchema, itemRemoveSchema, itemActionResultSchema, + itemTransferTargetsSchema, itemUseSoundSchema, itemClockAnnounceSchema, itemPianoNoteSchema, @@ -373,7 +386,8 @@ 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_transfer_targets'; itemId: string } + | { type: 'item_transfer'; itemId: string; targetId?: string; targetUserId?: string } | { type: 'item_use'; itemId: string } | { type: 'item_secondary_use'; itemId: string } | { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } @@ -387,6 +401,7 @@ export type OutgoingMessage = export type RemoteUser = { id: string; + userId?: string | null; nickname: string; x: number; y: number; diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 0b1c1d0..6308503 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -28,7 +28,8 @@ 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_transfer_targets`: request transfer target accounts for one item (includes online + offline active users, excluding current owner). +- `item_transfer`: transfer item ownership to another account (supports `targetUserId`; `targetId` remains accepted for compatibility). - `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`). @@ -52,6 +53,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `item_upsert`: full item replacement after mutation. - `item_remove`: item deletion. - `item_action_result`: action success/failure and user-facing message. +- `item_transfer_targets`: transfer target account list for one item. - `item_use_sound`: spatial one-shot sound on successful item use (if `useSound` configured). - `item_clock_announce`: ordered list of clock speech samples to play sequentially as spatial audio. - `item_piano_note`: broadcast piano note on/off with resolved instrument/envelope/spatial params. @@ -64,6 +66,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `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. +- Item transfer ownership is account-based; target accounts do not need to be currently connected. - 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). - `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path. diff --git a/server/app/auth_service.py b/server/app/auth_service.py index 8a0e932..42c7b5e 100644 --- a/server/app/auth_service.py +++ b/server/app/auth_service.py @@ -241,6 +241,18 @@ class AuthService: return permission_key in self.get_user_permissions(user_id) + def get_username_by_id(self, user_id: str) -> str | None: + """Return username for one numeric user id, or None when not found.""" + + try: + user_id_value = int(user_id) + except (TypeError, ValueError): + return None + row = self._db_fetchone("SELECT username FROM users WHERE id = ?", (user_id_value,)) + if row is None: + return None + return str(row["username"]) + def list_roles_with_counts(self) -> list[dict[str, object]]: """Return all roles with permission sets and assigned-user counts.""" diff --git a/server/app/models.py b/server/app/models.py index ed38e65..bc7f860 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -140,7 +140,13 @@ class ItemDeletePacket(BasePacket): class ItemTransferPacket(BasePacket): type: Literal["item_transfer"] itemId: str - targetId: str + targetId: str | None = None + targetUserId: str | None = None + + +class ItemTransferTargetsPacket(BasePacket): + type: Literal["item_transfer_targets"] + itemId: str class ItemUsePacket(BasePacket): @@ -199,6 +205,7 @@ ClientPacket = ( | ItemDropPacket | ItemDeletePacket | ItemTransferPacket + | ItemTransferTargetsPacket | ItemUsePacket | ItemSecondaryUsePacket | ItemPianoNotePacket @@ -367,6 +374,18 @@ class ItemActionResultPacket(BasePacket): itemId: str | None = None +class ItemTransferTargetSummary(BaseModel): + userId: str + username: str + online: bool + + +class ItemTransferTargetsResultPacket(BasePacket): + type: Literal["item_transfer_targets"] + itemId: str + targets: list[ItemTransferTargetSummary] + + class ItemUseSoundPacket(BasePacket): type: Literal["item_use_sound"] itemId: str diff --git a/server/app/server.py b/server/app/server.py index d559bd9..a90f459 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -88,6 +88,8 @@ from .models import ( ItemRemovePacket, ItemSecondaryUsePacket, ItemTransferPacket, + ItemTransferTargetsPacket, + ItemTransferTargetsResultPacket, ItemUpdatePacket, ItemUpsertPacket, ItemUsePacket, @@ -2486,6 +2488,47 @@ class SignalingServer: await self._send_item_result(client, True, "delete", f"You deleted {item_text}.", item.id) return + if isinstance(packet, ItemTransferTargetsPacket): + 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 + users = self.auth_service.list_users_for_admin() + connected_user_ids = { + other.user_id + for other in self.clients.values() + if other.authenticated and other.user_id + } + targets = [ + { + "userId": str(entry["id"]), + "username": str(entry["username"]), + "online": str(entry.get("id")) in connected_user_ids, + } + for entry in users + if str(entry.get("status")) == "active" and str(entry["id"]) != item.createdBy + ] + await self._send( + client.websocket, + ItemTransferTargetsResultPacket( + type="item_transfer_targets", + itemId=item.id, + targets=targets, + ), + ) + return + if isinstance(packet, ItemTransferPacket): item = self.items.get(packet.itemId) if not item: @@ -2502,15 +2545,34 @@ class SignalingServer: 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: + target_user_id = str(packet.targetUserId or "").strip() + if not target_user_id and packet.targetId: + target = self._get_client_by_id(packet.targetId) + if target and target.authenticated and target.user_id: + target_user_id = target.user_id + if not target_user_id: await self._send_item_result(client, False, "transfer", "Target user is not available.", item.id) return - if item.createdBy == target.user_id: + 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 + target = next( + ( + other + for other in self.clients.values() + if other.authenticated and other.user_id == target_user_id + ), + None, + ) + target_username = ( + target.username + if target and target.username + else target.nickname + if target + else self.auth_service.get_username_by_id(target_user_id) or target_user_id + ) + item.createdBy = target_user_id + item.createdByName = target_username item.updatedAt = self.item_service.now_ms() actor_id, actor_name = self._item_updated_actor(client) item.updatedBy = actor_id @@ -2522,7 +2584,7 @@ class SignalingServer: await self._broadcast( BroadcastChatMessagePacket( type="chat_message", - message=f"{client.nickname} transferred {item_text} to {target.nickname}.", + message=f"{client.nickname} transferred {item_text} to {target_username}.", system=True, ), exclude=client.websocket, @@ -2531,7 +2593,7 @@ class SignalingServer: client, True, "transfer", - f"You transferred {item_text} to {target.nickname}.", + f"You transferred {item_text} to {target_username}.", item.id, ) return diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index 401006a..c73ebe6 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import json +from pathlib import Path from time import monotonic from typing import cast import uuid @@ -449,6 +450,135 @@ async def test_item_transfer_allows_self_target_for_transfer_any(monkeypatch: py assert result.action == "transfer" +@pytest.mark.asyncio +async def test_item_transfer_accepts_offline_target_user_id(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, auth_db_path=tmp_path / "auth.db", grid_size=41) + owner_session = server.auth_service.register("owner_test", "password99") + actor_session = server.auth_service.register("actor_test", "password99") + offline_session = server.auth_service.register("offline_test", "password99") + owner_ws = _fake_ws() + actor_ws = _fake_ws() + owner = ClientConnection( + websocket=owner_ws, + id="u1", + nickname="owner", + authenticated=True, + user_id=owner_session.user.id, + username=owner_session.user.username, + permissions=set(), + x=5, + y=6, + ) + actor = ClientConnection( + websocket=actor_ws, + id="u3", + nickname="actor", + authenticated=True, + user_id=actor_session.user.id, + username=actor_session.user.username, + permissions={"item.transfer.any"}, + x=5, + y=6, + ) + server.clients[owner_ws] = owner + server.clients[actor_ws] = actor + item = server.item_service.default_item(owner, "dice") + item.x = actor.x + item.y = actor.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( + actor, + json.dumps({"type": "item_transfer", "itemId": item.id, "targetUserId": offline_session.user.id}), + ) + + assert item.createdBy == offline_session.user.id + assert item.createdByName == offline_session.user.username + 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_targets_lists_online_and_offline(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, auth_db_path=tmp_path / "auth.db", grid_size=41) + owner_session = server.auth_service.register("owner_menu", "password99") + actor_session = server.auth_service.register("actor_menu", "password99") + online_session = server.auth_service.register("online_menu", "password99") + offline_session = server.auth_service.register("offline_menu", "password99") + owner_ws = _fake_ws() + actor_ws = _fake_ws() + online_ws = _fake_ws() + owner = ClientConnection( + websocket=owner_ws, + id="u1", + nickname="owner", + authenticated=True, + user_id=owner_session.user.id, + username=owner_session.user.username, + permissions=set(), + x=5, + y=6, + ) + actor = ClientConnection( + websocket=actor_ws, + id="u3", + nickname="actor", + authenticated=True, + user_id=actor_session.user.id, + username=actor_session.user.username, + permissions={"item.transfer.any"}, + x=5, + y=6, + ) + online = ClientConnection( + websocket=online_ws, + id="u4", + nickname="online", + authenticated=True, + user_id=online_session.user.id, + username=online_session.user.username, + permissions=set(), + x=10, + y=10, + ) + server.clients[owner_ws] = owner + server.clients[actor_ws] = actor + server.clients[online_ws] = online + item = server.item_service.default_item(owner, "dice") + item.x = actor.x + item.y = actor.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(actor, json.dumps({"type": "item_transfer_targets", "itemId": item.id})) + + assert send_payloads + result = send_payloads[-1] + assert result.type == "item_transfer_targets" + usernames = {entry.username for entry in result.targets} + assert owner_session.user.username not in usernames + assert online_session.user.username in usernames + assert offline_session.user.username in usernames + by_username = {entry.username: entry for entry in result.targets} + assert by_username[online_session.user.username].online is True + assert by_username[offline_session.user.username].online is False + + @pytest.mark.asyncio async def test_item_delete_sends_others_notification(monkeypatch: pytest.MonkeyPatch) -> None: server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41)