Support account-wide item transfer targets and fix delete confirm exit

This commit is contained in:
Jage9
2026-02-28 20:24:37 -05:00
parent 9fe9c6da4d
commit daab7bb759
9 changed files with 308 additions and 57 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -224,7 +224,13 @@ type ItemManagementConfirmContext = {
itemId: string; itemId: string;
action: ItemManagementAction; action: ItemManagementAction;
prompt: string; prompt: string;
targetId?: string; targetUserId?: string;
};
type ItemTransferTarget = {
userId: string;
username: string;
online: boolean;
}; };
/** Builds linearized help-view lines from sectioned help content. */ /** Builds linearized help-view lines from sectioned help content. */
@@ -341,8 +347,8 @@ let adminDeleteConfirmIndex = 0;
let itemManagementSelectedItemId: string | null = null; let itemManagementSelectedItemId: string | null = null;
let itemManagementOptions: ItemManagementOption[] = []; let itemManagementOptions: ItemManagementOption[] = [];
let itemManagementOptionIndex = 0; let itemManagementOptionIndex = 0;
let itemManagementTargetUserIds: string[] = [];
let itemManagementTargetUserIndex = 0; let itemManagementTargetUserIndex = 0;
let itemManagementTransferTargets: ItemTransferTarget[] = [];
let itemManagementConfirmIndex = 0; let itemManagementConfirmIndex = 0;
let itemManagementConfirmContext: ItemManagementConfirmContext | null = null; let itemManagementConfirmContext: ItemManagementConfirmContext | null = null;
let activeTeleport: let activeTeleport:
@@ -1066,10 +1072,9 @@ function itemManagementOptionsFor(item: WorldItem): ItemManagementOption[] {
return options; return options;
} }
/** Resolves transfer-target label for either local player id or a remote peer id. */ /** Resolves spoken label for one transfer target entry. */
function transferTargetLabel(userPeerId: string): string { function transferTargetLabel(target: ItemTransferTarget): string {
if (userPeerId === state.player.id) return state.player.nickname || 'Unknown user'; return target.online ? `${target.username}, online` : `${target.username}, offline`;
return state.peers.get(userPeerId)?.nickname ?? 'Unknown user';
} }
/** Opens item-management options for one selected item. */ /** Opens item-management options for one selected item. */
@@ -1102,7 +1107,7 @@ function resetItemManagementState(): void {
itemManagementSelectedItemId = null; itemManagementSelectedItemId = null;
itemManagementOptions = []; itemManagementOptions = [];
itemManagementOptionIndex = 0; itemManagementOptionIndex = 0;
itemManagementTargetUserIds = []; itemManagementTransferTargets = [];
itemManagementTargetUserIndex = 0; itemManagementTargetUserIndex = 0;
itemManagementConfirmIndex = 0; itemManagementConfirmIndex = 0;
itemManagementConfirmContext = null; itemManagementConfirmContext = null;
@@ -1786,6 +1791,24 @@ function handleAdminUsersList(message: Extract<IncomingMessage, { type: 'admin_u
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Handles server transfer-target list response for item-management transfer flow. */
function handleItemTransferTargets(message: Extract<IncomingMessage, { type: 'item_transfer_targets' }>): 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. */ /** Handles structured admin action result packets. */
function handleAdminActionResult(message: Extract<IncomingMessage, { type: 'admin_action_result' }>): void { function handleAdminActionResult(message: Extract<IncomingMessage, { type: 'admin_action_result' }>): void {
if (message.action === 'role_update_permissions') { if (message.action === 'role_update_permissions') {
@@ -2009,6 +2032,7 @@ const onAppMessage = createOnMessageHandler({
handleAdminRolesList, handleAdminRolesList,
handleAdminUsersList, handleAdminUsersList,
handleAdminActionResult, handleAdminActionResult,
handleItemTransferTargets,
isPeerNegotiationReady: () => peerNegotiationReady, isPeerNegotiationReady: () => peerNegotiationReady,
enqueuePendingSignal: (message) => { enqueuePendingSignal: (message) => {
pendingSignalMessages.push(message); pendingSignalMessages.push(message);
@@ -2900,33 +2924,10 @@ function handleItemManageOptionsModeInput(code: string, key: string): void {
}); });
return; return;
} }
const ownerUserId = item.createdBy.trim(); itemManagementTransferTargets = [];
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;
itemManagementTargetUserIndex = 0; itemManagementTargetUserIndex = 0;
state.mode = 'itemManageTransferUser'; signaling.send({ type: 'item_transfer_targets', itemId: item.id });
const firstLabel = transferTargetLabel(itemManagementTargetUserIds[0]); updateStatus('Loading users...');
updateStatus(firstLabel);
audio.sfxUiBlip(); audio.sfxUiBlip();
return; return;
} }
@@ -2940,34 +2941,37 @@ function handleItemManageOptionsModeInput(code: string, key: string): void {
/** Handles target-user selection for item transfer action. */ /** Handles target-user selection for item transfer action. */
function handleItemManageTransferUserModeInput(code: string, key: string): void { function handleItemManageTransferUserModeInput(code: string, key: string): void {
if (!itemManagementSelectedItemId || itemManagementTargetUserIds.length === 0) { if (!itemManagementSelectedItemId || itemManagementTransferTargets.length === 0) {
state.mode = 'itemManageOptions'; state.mode = 'itemManageOptions';
return; return;
} }
const control = handleListControlKey(code, key, itemManagementTargetUserIds, itemManagementTargetUserIndex, (userId) => { const control = handleListControlKey(
return transferTargetLabel(userId); code,
}); key,
itemManagementTransferTargets,
itemManagementTargetUserIndex,
(target) => transferTargetLabel(target),
);
if (control.type === 'move') { if (control.type === 'move') {
itemManagementTargetUserIndex = control.index; itemManagementTargetUserIndex = control.index;
const label = transferTargetLabel(itemManagementTargetUserIds[itemManagementTargetUserIndex]); const label = transferTargetLabel(itemManagementTransferTargets[itemManagementTargetUserIndex]);
updateStatus(label); updateStatus(label);
audio.sfxUiBlip(); audio.sfxUiBlip();
return; return;
} }
if (control.type === 'select') { if (control.type === 'select') {
const item = state.items.get(itemManagementSelectedItemId); const item = state.items.get(itemManagementSelectedItemId);
const targetId = itemManagementTargetUserIds[itemManagementTargetUserIndex]; const target = itemManagementTransferTargets[itemManagementTargetUserIndex];
if (!item || !targetId) { if (!item || !target) {
state.mode = 'itemManageOptions'; state.mode = 'itemManageOptions';
audio.sfxUiCancel(); audio.sfxUiCancel();
return; return;
} }
const targetLabel = transferTargetLabel(targetId);
openItemManagementConfirm({ openItemManagementConfirm({
itemId: item.id, itemId: item.id,
action: 'transfer', action: 'transfer',
prompt: `Transfer ${itemLabel(item)} to ${targetLabel}?`, prompt: `Transfer ${itemLabel(item)} to ${target.username}?`,
targetId, targetUserId: target.userId,
}); });
return; return;
} }
@@ -3012,8 +3016,8 @@ function handleConfirmYesNoModeInput(code: string, key: string): void {
state.mode = 'normal'; state.mode = 'normal';
if (context.action === 'delete') { if (context.action === 'delete') {
signaling.send({ type: 'item_delete', itemId: context.itemId }); signaling.send({ type: 'item_delete', itemId: context.itemId });
} else if (context.action === 'transfer' && context.targetId) { } else if (context.action === 'transfer' && context.targetUserId) {
signaling.send({ type: 'item_transfer', itemId: context.itemId, targetId: context.targetId }); signaling.send({ type: 'item_transfer', itemId: context.itemId, targetUserId: context.targetUserId });
} }
resetItemManagementState(); resetItemManagementState();
} }
@@ -3331,6 +3335,8 @@ function handleAdminUserDeleteConfirmModeInput(code: string, key: string): void
audio.sfxUiCancel(); audio.sfxUiCancel();
return; return;
} }
state.mode = 'adminUserList';
updateStatus(`Deleting account ${adminSelectedUsername}...`);
adminPendingUserMutation = { action: 'delete_account', username: adminSelectedUsername }; adminPendingUserMutation = { action: 'delete_account', username: adminSelectedUsername };
signaling.send({ type: 'admin_user_delete', username: adminSelectedUsername }); signaling.send({ type: 'admin_user_delete', username: adminSelectedUsername });
} }

View File

@@ -15,7 +15,7 @@ type MessageHandlerDeps = {
addItemTypeIndex: number; addItemTypeIndex: number;
player: { id: string | null; nickname: string; x: number; y: number }; player: { id: string | null; nickname: string; x: number; y: number };
running: boolean; running: boolean;
peers: Map<string, { id: string; nickname: string; x: number; y: number }>; peers: Map<string, { id: string; userId?: string | null; nickname: string; x: number; y: number }>;
items: Map<string, WorldItem>; items: Map<string, WorldItem>;
mode: string; mode: string;
selectedItemId: string | null; selectedItemId: string | null;
@@ -77,6 +77,7 @@ type MessageHandlerDeps = {
handleAdminRolesList: (message: Extract<IncomingMessage, { type: 'admin_roles_list' }>) => void; handleAdminRolesList: (message: Extract<IncomingMessage, { type: 'admin_roles_list' }>) => void;
handleAdminUsersList: (message: Extract<IncomingMessage, { type: 'admin_users_list' }>) => void; handleAdminUsersList: (message: Extract<IncomingMessage, { type: 'admin_users_list' }>) => void;
handleAdminActionResult: (message: Extract<IncomingMessage, { type: 'admin_action_result' }>) => void; handleAdminActionResult: (message: Extract<IncomingMessage, { type: 'admin_action_result' }>) => void;
handleItemTransferTargets: (message: Extract<IncomingMessage, { type: 'item_transfer_targets' }>) => void;
isPeerNegotiationReady: () => boolean; isPeerNegotiationReady: () => boolean;
enqueuePendingSignal: (message: Extract<IncomingMessage, { type: 'signal' }>) => void; enqueuePendingSignal: (message: Extract<IncomingMessage, { type: 'signal' }>) => void;
}; };
@@ -106,6 +107,9 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
case 'admin_action_result': case 'admin_action_result':
deps.handleAdminActionResult(message); deps.handleAdminActionResult(message);
break; break;
case 'item_transfer_targets':
deps.handleItemTransferTargets(message);
break;
case 'welcome': case 'welcome':
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) { if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {

View File

@@ -221,6 +221,18 @@ export const itemActionResultSchema = z.object({
itemId: z.string().optional(), 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({ export const itemUseSoundSchema = z.object({
type: z.literal('item_use_sound'), type: z.literal('item_use_sound'),
itemId: z.string(), itemId: z.string(),
@@ -337,6 +349,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
itemUpsertSchema, itemUpsertSchema,
itemRemoveSchema, itemRemoveSchema,
itemActionResultSchema, itemActionResultSchema,
itemTransferTargetsSchema,
itemUseSoundSchema, itemUseSoundSchema,
itemClockAnnounceSchema, itemClockAnnounceSchema,
itemPianoNoteSchema, itemPianoNoteSchema,
@@ -373,7 +386,8 @@ export type OutgoingMessage =
| { type: 'item_pickup'; itemId: string } | { type: 'item_pickup'; itemId: string }
| { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_drop'; itemId: string; x: number; y: number }
| { type: 'item_delete'; itemId: string } | { 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_use'; itemId: string }
| { type: 'item_secondary_use'; itemId: string } | { type: 'item_secondary_use'; itemId: string }
| { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean } | { type: 'item_piano_note'; itemId: string; keyId: string; midi: number; on: boolean }
@@ -387,6 +401,7 @@ export type OutgoingMessage =
export type RemoteUser = { export type RemoteUser = {
id: string; id: string;
userId?: string | null;
nickname: string; nickname: string;
x: number; x: number;
y: number; y: number;

View File

@@ -28,7 +28,8 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `chat_message`: player chat. - `chat_message`: player chat.
- `ping`: latency measurement. - `ping`: latency measurement.
- `item_add`, `item_pickup`, `item_drop`, `item_delete`, `item_use`, `item_update`: item actions. - `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_secondary_use`: trigger type-specific secondary action when implemented.
- `item_piano_note`: realtime piano note on/off for active piano use mode. - `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`). - `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_upsert`: full item replacement after mutation.
- `item_remove`: item deletion. - `item_remove`: item deletion.
- `item_action_result`: action success/failure and user-facing message. - `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_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_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. - `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. - `item_action_result` messages are intended for direct screen-reader/user status feedback.
- `action` includes: `add`, `pickup`, `drop`, `delete`, `transfer`, `use`, `secondary_use`, `update` - `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. - 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. - 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_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. - `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path.

View File

@@ -241,6 +241,18 @@ class AuthService:
return permission_key in self.get_user_permissions(user_id) 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]]: def list_roles_with_counts(self) -> list[dict[str, object]]:
"""Return all roles with permission sets and assigned-user counts.""" """Return all roles with permission sets and assigned-user counts."""

View File

@@ -140,7 +140,13 @@ class ItemDeletePacket(BasePacket):
class ItemTransferPacket(BasePacket): class ItemTransferPacket(BasePacket):
type: Literal["item_transfer"] type: Literal["item_transfer"]
itemId: str 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): class ItemUsePacket(BasePacket):
@@ -199,6 +205,7 @@ ClientPacket = (
| ItemDropPacket | ItemDropPacket
| ItemDeletePacket | ItemDeletePacket
| ItemTransferPacket | ItemTransferPacket
| ItemTransferTargetsPacket
| ItemUsePacket | ItemUsePacket
| ItemSecondaryUsePacket | ItemSecondaryUsePacket
| ItemPianoNotePacket | ItemPianoNotePacket
@@ -367,6 +374,18 @@ class ItemActionResultPacket(BasePacket):
itemId: str | None = None 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): class ItemUseSoundPacket(BasePacket):
type: Literal["item_use_sound"] type: Literal["item_use_sound"]
itemId: str itemId: str

View File

@@ -88,6 +88,8 @@ from .models import (
ItemRemovePacket, ItemRemovePacket,
ItemSecondaryUsePacket, ItemSecondaryUsePacket,
ItemTransferPacket, ItemTransferPacket,
ItemTransferTargetsPacket,
ItemTransferTargetsResultPacket,
ItemUpdatePacket, ItemUpdatePacket,
ItemUpsertPacket, ItemUpsertPacket,
ItemUsePacket, ItemUsePacket,
@@ -2486,6 +2488,47 @@ class SignalingServer:
await self._send_item_result(client, True, "delete", f"You deleted {item_text}.", item.id) await self._send_item_result(client, True, "delete", f"You deleted {item_text}.", item.id)
return 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): if isinstance(packet, ItemTransferPacket):
item = self.items.get(packet.itemId) item = self.items.get(packet.itemId)
if not item: if not item:
@@ -2502,15 +2545,34 @@ class SignalingServer:
if not can_transfer_any and not can_transfer_own: 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) await self._send_item_result(client, False, "transfer", "Not authorized to transfer this item.", item.id)
return return
target = self._get_client_by_id(packet.targetId) target_user_id = str(packet.targetUserId or "").strip()
if not target or not target.authenticated or not target.user_id: 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) await self._send_item_result(client, False, "transfer", "Target user is not available.", item.id)
return 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) await self._send_item_result(client, False, "transfer", "Item already belongs to that user.", item.id)
return return
item.createdBy = target.user_id target = next(
item.createdByName = target.username or target.nickname (
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() item.updatedAt = self.item_service.now_ms()
actor_id, actor_name = self._item_updated_actor(client) actor_id, actor_name = self._item_updated_actor(client)
item.updatedBy = actor_id item.updatedBy = actor_id
@@ -2522,7 +2584,7 @@ class SignalingServer:
await self._broadcast( await self._broadcast(
BroadcastChatMessagePacket( BroadcastChatMessagePacket(
type="chat_message", 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, system=True,
), ),
exclude=client.websocket, exclude=client.websocket,
@@ -2531,7 +2593,7 @@ class SignalingServer:
client, client,
True, True,
"transfer", "transfer",
f"You transferred {item_text} to {target.nickname}.", f"You transferred {item_text} to {target_username}.",
item.id, item.id,
) )
return return

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from pathlib import Path
from time import monotonic from time import monotonic
from typing import cast from typing import cast
import uuid import uuid
@@ -449,6 +450,135 @@ async def test_item_transfer_allows_self_target_for_transfer_any(monkeypatch: py
assert result.action == "transfer" 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 @pytest.mark.asyncio
async def test_item_delete_sends_others_notification(monkeypatch: pytest.MonkeyPatch) -> None: async def test_item_delete_sends_others_notification(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41)