From 4fcd006856bf5c21bcef2ebf465d33daa20d6f81 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Fri, 27 Feb 2026 02:27:16 -0500 Subject: [PATCH] Track item updatedBy as readonly metadata and inspect field --- client/public/version.js | 2 +- client/src/items/itemRegistry.ts | 1 + client/src/network/protocol.ts | 1 + client/src/state/gameState.ts | 1 + docs/item-schema.md | 3 +++ docs/item-types.md | 1 + docs/protocol-notes.md | 2 +- server/app/item_catalog.py | 1 + server/app/item_service.py | 4 ++++ server/app/models.py | 2 ++ server/app/server.py | 20 ++++++++++++++++++-- server/tests/test_item_schema_ui.py | 1 + 12 files changed, 35 insertions(+), 4 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 2687b90..5a7c1f0 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.27 R281"; +window.CHGRID_WEB_VERSION = "2026.02.27 R282"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 6859677..aaf7d1d 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -184,6 +184,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] { 'carrierId', 'version', 'createdBy', + 'updatedBy', 'createdAt', 'updatedAt', 'capabilities', diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 537025f..bfce08c 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -7,6 +7,7 @@ export const itemSchema = z.object({ x: z.number().int(), y: z.number().int(), createdBy: z.string(), + updatedBy: z.string(), createdAt: z.number().int(), updatedAt: z.number().int(), version: z.number().int(), diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index 3206824..f789175 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -11,6 +11,7 @@ export type WorldItem = { x: number; y: number; createdBy: string; + updatedBy: string; createdAt: number; updatedAt: number; version: number; diff --git a/docs/item-schema.md b/docs/item-schema.md index 58c305e..a499748 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -10,6 +10,7 @@ "x": 0, "y": 0, "createdBy": "user-id", + "updatedBy": "user-id", "createdAt": 1735689600000, "updatedAt": 1735689600000, "version": 1, @@ -24,6 +25,7 @@ - `useSound`: optional client-played one-shot sound when item `use` succeeds; global item field and not user-editable in V1. - `emitSound`: optional continuously-looping spatial sound emitted from the item on the grid; global item field and not user-editable in V1. - `capabilities`, `useSound`, and `emitSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state). +- `updatedBy` tracks the user account that last changed item state. - `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`, `widget=1000`, `piano=1000`), not per-instance editable. - `emitRange`: global spatial range default per item type (`radio_station=10`, `dice=15`, `wheel=15`, `clock=10`, `widget=15`, `piano=15`). - `radio_station` can override this per instance via `params.emitRange` (`5..20`). @@ -39,6 +41,7 @@ "x": 0, "y": 0, "createdBy": "user-id", + "updatedBy": "user-id", "createdAt": 1735689600000, "updatedAt": 1735689600000, "version": 1, diff --git a/docs/item-types.md b/docs/item-types.md index 9ee5c91..fb3a315 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -13,6 +13,7 @@ This is behavior-focused documentation for item types and their defaults. - `emitRange` (spatial range in squares) - `directional` (directional attenuation enabled) - Instance fields are persisted in `server/runtime/items.json`. +- Read-only inspect fields include `createdBy` and `updatedBy` for ownership/change tracking. ## `radio_station` diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 47b8fa2..407b20d 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -46,7 +46,7 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Item Packet Behavior - `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: `createdAt`, `updatedAt`, `capabilities`, `useSound`, `emitSound`). +- `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. - 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/item_catalog.py b/server/app/item_catalog.py index 36c4e2e..0630fa4 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -86,6 +86,7 @@ GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = { "carrierId": {"valueType": "text", "tooltip": "User id currently carrying this item, or none.", "label": "Carrier"}, "version": {"valueType": "number", "tooltip": "Server-side item version incremented on each update.", "label": "Version"}, "createdBy": {"valueType": "text", "tooltip": "Username that originally created this item.", "label": "Created by"}, + "updatedBy": {"valueType": "text", "tooltip": "Username that most recently updated this item.", "label": "Updated by"}, "createdAt": {"valueType": "text", "tooltip": "Creation timestamp for this item.", "label": "Created at"}, "updatedAt": {"valueType": "text", "tooltip": "Last update timestamp for this item.", "label": "Updated at"}, "capabilities": {"valueType": "text", "tooltip": "Supported actions for this item type.", "label": "Capabilities"}, diff --git a/server/app/item_service.py b/server/app/item_service.py index 94e2027..43f8d50 100644 --- a/server/app/item_service.py +++ b/server/app/item_service.py @@ -46,6 +46,7 @@ class ItemService: x=client.x, y=client.y, createdBy=client.username or client.nickname or client.id, + updatedBy=client.username or client.nickname or client.id, createdAt=now, updatedAt=now, version=1, @@ -90,6 +91,7 @@ class ItemService: item.x = client.x item.y = client.y item.updatedAt = self.now_ms() + item.updatedBy = "system" changed.append(item) return changed @@ -115,6 +117,7 @@ class ItemService: x=persisted.x, y=persisted.y, createdBy=persisted.createdBy, + updatedBy=persisted.updatedBy or persisted.createdBy, createdAt=persisted.createdAt, updatedAt=persisted.updatedAt, version=persisted.version, @@ -168,6 +171,7 @@ class ItemService: x=item.x, y=item.y, createdBy=item.createdBy, + updatedBy=item.updatedBy, createdAt=item.createdAt, updatedAt=item.updatedAt, version=item.version, diff --git a/server/app/models.py b/server/app/models.py index 3dc39d5..90b6c23 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -243,6 +243,7 @@ class WorldItem(BaseModel): x: int y: int createdBy: str + updatedBy: str createdAt: int updatedAt: int version: int @@ -262,6 +263,7 @@ class PersistedWorldItem(BaseModel): x: int y: int createdBy: str + updatedBy: str | None = None createdAt: int updatedAt: int version: int diff --git a/server/app/server.py b/server/app/server.py index cb35c38..9da1860 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -387,6 +387,7 @@ class SignalingServer: "carrierId": item.carrierId or "none", "version": str(item.version), "createdBy": item.createdBy, + "updatedBy": item.updatedBy, "createdAt": self._format_display_timestamp_ms(item.createdAt), "updatedAt": self._format_display_timestamp_ms(item.updatedAt), "capabilities": ", ".join(item.capabilities) if item.capabilities else "none", @@ -399,6 +400,12 @@ class SignalingServer: return item.model_copy(update={"display": self._build_item_display_values(item)}) + @staticmethod + def _item_updated_by_actor(client: ClientConnection) -> str: + """Resolve display name stored in `updatedBy` for client-initiated item mutations.""" + + return client.username or client.nickname or client.id + def _get_item_emit_range(self, item: WorldItem) -> int: """Return effective emit range for one item with sane bounds.""" @@ -474,6 +481,7 @@ class SignalingServer: item.params["stationName"] = station_name item.params["nowPlaying"] = now_playing item.updatedAt = self.item_service.now_ms() + item.updatedBy = "system" item.version += 1 self._request_state_save() await self._broadcast_item(item) @@ -808,15 +816,16 @@ class SignalingServer: "events": compact_events, } self.item_service.save_piano_songs() + owner_id = str(session.get("ownerClientId", "")) + owner = self._get_client_by_id(owner_id) if owner_id else None item.params["songId"] = song_id item.params.pop("recording", None) item.params.pop("recordingLengthMs", None) item.updatedAt = self.item_service.now_ms() + item.updatedBy = owner.username if owner and owner.username else "system" item.version += 1 self._request_state_save() await self._broadcast_item(item) - owner_id = str(session.get("ownerClientId", "")) - owner = self._get_client_by_id(owner_id) if owner_id else None if owner and notify_owner: await self._send_piano_status( owner, @@ -1514,6 +1523,7 @@ class SignalingServer: carried.x = client.x carried.y = client.y carried.updatedAt = self.item_service.now_ms() + carried.updatedBy = self._item_updated_by_actor(client) await self._broadcast_item(carried) return @@ -1549,6 +1559,7 @@ class SignalingServer: carried.x = client.x carried.y = client.y carried.updatedAt = self.item_service.now_ms() + carried.updatedBy = self._item_updated_by_actor(client) await self._broadcast_item(carried) await self._broadcast( BroadcastTeleportCompletePacket( @@ -1725,6 +1736,7 @@ class SignalingServer: item.x = client.x item.y = client.y item.updatedAt = self.item_service.now_ms() + item.updatedBy = self._item_updated_by_actor(client) await self._broadcast_item(item) self._request_state_save() await self._send_item_result(client, True, "pickup", f"Picked up {item.title}.", item.id) @@ -1745,6 +1757,7 @@ class SignalingServer: item.x = packet.x item.y = packet.y item.updatedAt = self.item_service.now_ms() + item.updatedBy = self._item_updated_by_actor(client) await self._broadcast_item(item) self._request_state_save() await self._send_item_result(client, True, "drop", f"Dropped {item.title}.", item.id) @@ -1824,6 +1837,7 @@ class SignalingServer: await self._send_item_result(client, False, "use", str(exc), item.id) return item.updatedAt = now_ms + item.updatedBy = self._item_updated_by_actor(client) self._request_state_save() await self._broadcast_item(item) @@ -1900,6 +1914,7 @@ class SignalingServer: await self._send_item_result(client, False, "secondary_use", str(exc), item.id) return item.updatedAt = self.item_service.now_ms() + item.updatedBy = self._item_updated_by_actor(client) item.version += 1 self._request_state_save() await self._broadcast_item(item) @@ -2074,6 +2089,7 @@ class SignalingServer: return item.params = next_params item.updatedAt = self.item_service.now_ms() + item.updatedBy = self._item_updated_by_actor(client) item.version += 1 await self._broadcast_item(item) self._request_state_save() diff --git a/server/tests/test_item_schema_ui.py b/server/tests/test_item_schema_ui.py index 60085c6..d9a7536 100644 --- a/server/tests/test_item_schema_ui.py +++ b/server/tests/test_item_schema_ui.py @@ -35,6 +35,7 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None: "carrierId", "version", "createdBy", + "updatedBy", "createdAt", "updatedAt", "capabilities",