diff --git a/client/public/version.js b/client/public/version.js index 7d7e4c9..5d2a1a2 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.25 R275"; +window.CHGRID_WEB_VERSION = "2026.02.25 R276"; // 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/itemPropertyPresentation.ts b/client/src/items/itemPropertyPresentation.ts index 9c79fae..f22aea8 100644 --- a/client/src/items/itemPropertyPresentation.ts +++ b/client/src/items/itemPropertyPresentation.ts @@ -34,17 +34,7 @@ export function createItemPropertyPresentation(deps: PresentationDeps): { const getItemPropertyValue = (item: WorldItem, key: string): string => { if (key === 'title') return item.title; - if (key === 'type') return item.type; - if (key === 'x') return String(item.x); - if (key === 'y') return String(item.y); - if (key === 'carrierId') return item.carrierId ?? 'none'; - if (key === 'version') return String(item.version); - if (key === 'createdBy') return item.createdBy; - if (key === 'createdAt') return deps.formatTimestampMs(item.createdAt); - if (key === 'updatedAt') return deps.formatTimestampMs(item.updatedAt); - if (key === 'capabilities') return item.capabilities.join(', ') || 'none'; - if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound); - if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound); + if (item.display && typeof item.display[key] === 'string') return item.display[key]; const metadata = getItemPropertyMetadata(item.type, key); const globalValue = getItemTypeGlobalProperties(item.type)?.[key]; const paramValue = item.params[key]; @@ -69,6 +59,17 @@ export function createItemPropertyPresentation(deps: PresentationDeps): { if (metadata?.valueType === 'list' || metadata?.valueType === 'text') { return rawValue === undefined || rawValue === null ? '' : String(rawValue); } + if (key === 'type') return item.type; + if (key === 'x') return String(item.x); + if (key === 'y') return String(item.y); + if (key === 'carrierId') return item.carrierId ?? 'none'; + if (key === 'version') return String(item.version); + if (key === 'createdBy') return item.createdBy; + if (key === 'createdAt') return deps.formatTimestampMs(item.createdAt); + if (key === 'updatedAt') return deps.formatTimestampMs(item.updatedAt); + if (key === 'capabilities') return item.capabilities.join(', ') || 'none'; + if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound); + if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound); if (paramValue !== undefined) return String(paramValue); if (globalValue !== undefined) return String(globalValue); return ''; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 658f1c5..537025f 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -15,6 +15,7 @@ export const itemSchema = z.object({ emitSound: z.string().optional(), params: z.record(z.string(), z.unknown()), carrierId: z.string().nullable().optional(), + display: z.record(z.string(), z.string()).optional(), }); export const welcomeMessageSchema = z.object({ diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index c936be7..3206824 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -19,6 +19,7 @@ export type WorldItem = { emitSound?: string; params: Record; carrierId?: string | null; + display?: Record; }; export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | null; diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 0cbcf3a..c803965 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -46,6 +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_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/models.py b/server/app/models.py index 9a36629..3dc39d5 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -251,6 +251,7 @@ class WorldItem(BaseModel): emitSound: str | None = None params: dict carrierId: str | None = None + display: dict[str, str] | None = None class PersistedWorldItem(BaseModel): diff --git a/server/app/server.py b/server/app/server.py index 6d44c00..feadc39 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -6,7 +6,7 @@ import argparse import asyncio from collections import deque from contextlib import suppress -from datetime import datetime +from datetime import datetime, timezone from getpass import getpass from importlib.metadata import PackageNotFoundError, version as package_version import json @@ -355,6 +355,48 @@ class SignalingServer: return item.useSound.strip() return None + @staticmethod + def _format_display_sound_name(value: object) -> str: + """Return display-friendly sound token (file name only) for item property menus.""" + + raw = str(value or "").strip() + if not raw: + return "none" + if raw.lower() == "none": + return "none" + without_query = raw.split("?", 1)[0].split("#", 1)[0] + segments = [segment for segment in without_query.split("/") if segment] + return segments[-1] if segments else raw + + @staticmethod + def _format_display_timestamp_ms(value: int) -> str: + """Format epoch milliseconds to compact UTC text used in item property menus.""" + + dt = datetime.fromtimestamp(value / 1000, tz=timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M") + + def _build_item_display_values(self, item: WorldItem) -> dict[str, str]: + """Build server-authoritative item property display values for readonly/system fields.""" + + return { + "type": item.type, + "x": str(item.x), + "y": str(item.y), + "carrierId": item.carrierId or "none", + "version": str(item.version), + "createdBy": item.createdBy, + "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", + "useSound": self._format_display_sound_name(item.params.get("useSound", item.useSound)), + "emitSound": self._format_display_sound_name(item.params.get("emitSound", item.emitSound)), + } + + def _outbound_item(self, item: WorldItem) -> WorldItem: + """Return one outbound item snapshot enriched with server-owned display values.""" + + return item.model_copy(update={"display": self._build_item_display_values(item)}) + def _get_item_emit_range(self, item: WorldItem) -> int: """Return effective emit range for one item with sane bounds.""" @@ -1023,7 +1065,7 @@ class SignalingServer: async def _broadcast_item(self, item: WorldItem) -> None: """Broadcast a full item snapshot update to all connected clients.""" - await self._broadcast(ItemUpsertPacket(type="item_upsert", item=item)) + await self._broadcast(ItemUpsertPacket(type="item_upsert", item=self._outbound_item(item))) async def start(self) -> None: """Start websocket serving and run until cancelled.""" @@ -1117,7 +1159,7 @@ class SignalingServer: id=client.id, player=RemoteUser(id=client.id, nickname=client.nickname, x=client.x, y=client.y), users=users, - items=[item.model_dump(exclude_none=True) for item in self.items.values()], + items=[self._outbound_item(item).model_dump(exclude_none=True) for item in self.items.values()], worldConfig={ "gridSize": self.grid_size, "movementTickMs": self.movement_tick_ms,