Move readonly item property display values to server

This commit is contained in:
Jage9
2026-02-27 01:32:25 -05:00
parent ad50fc9afb
commit 4840aa454b
7 changed files with 62 additions and 15 deletions

View File

@@ -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";

View File

@@ -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 '';

View File

@@ -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({

View File

@@ -19,6 +19,7 @@ export type WorldItem = {
emitSound?: string;
params: Record<string, unknown>;
carrierId?: string | null;
display?: Record<string, string>;
};
export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | null;

View File

@@ -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).

View File

@@ -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):

View File

@@ -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,