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. // 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.02.25 R275"; window.CHGRID_WEB_VERSION = "2026.02.25 R276";
// 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

@@ -34,17 +34,7 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
const getItemPropertyValue = (item: WorldItem, key: string): string => { const getItemPropertyValue = (item: WorldItem, key: string): string => {
if (key === 'title') return item.title; if (key === 'title') return item.title;
if (key === 'type') return item.type; if (item.display && typeof item.display[key] === 'string') return item.display[key];
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);
const metadata = getItemPropertyMetadata(item.type, key); const metadata = getItemPropertyMetadata(item.type, key);
const globalValue = getItemTypeGlobalProperties(item.type)?.[key]; const globalValue = getItemTypeGlobalProperties(item.type)?.[key];
const paramValue = item.params[key]; const paramValue = item.params[key];
@@ -69,6 +59,17 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
if (metadata?.valueType === 'list' || metadata?.valueType === 'text') { if (metadata?.valueType === 'list' || metadata?.valueType === 'text') {
return rawValue === undefined || rawValue === null ? '' : String(rawValue); 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 (paramValue !== undefined) return String(paramValue);
if (globalValue !== undefined) return String(globalValue); if (globalValue !== undefined) return String(globalValue);
return ''; return '';

View File

@@ -15,6 +15,7 @@ export const itemSchema = z.object({
emitSound: z.string().optional(), emitSound: z.string().optional(),
params: z.record(z.string(), z.unknown()), params: z.record(z.string(), z.unknown()),
carrierId: z.string().nullable().optional(), carrierId: z.string().nullable().optional(),
display: z.record(z.string(), z.string()).optional(),
}); });
export const welcomeMessageSchema = z.object({ export const welcomeMessageSchema = z.object({

View File

@@ -19,6 +19,7 @@ export type WorldItem = {
emitSound?: string; emitSound?: string;
params: Record<string, unknown>; params: Record<string, unknown>;
carrierId?: string | null; carrierId?: string | null;
display?: Record<string, string>;
}; };
export type SelectionContext = 'pickup' | 'drop' | 'delete' | 'edit' | 'use' | 'secondaryUse' | 'inspect' | null; 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 Packet Behavior
- `item_upsert` is full-state replacement for one item, not partial patch. - `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. - `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. - 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).

View File

@@ -251,6 +251,7 @@ class WorldItem(BaseModel):
emitSound: str | None = None emitSound: str | None = None
params: dict params: dict
carrierId: str | None = None carrierId: str | None = None
display: dict[str, str] | None = None
class PersistedWorldItem(BaseModel): class PersistedWorldItem(BaseModel):

View File

@@ -6,7 +6,7 @@ import argparse
import asyncio import asyncio
from collections import deque from collections import deque
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime, timezone
from getpass import getpass from getpass import getpass
from importlib.metadata import PackageNotFoundError, version as package_version from importlib.metadata import PackageNotFoundError, version as package_version
import json import json
@@ -355,6 +355,48 @@ class SignalingServer:
return item.useSound.strip() return item.useSound.strip()
return None 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: def _get_item_emit_range(self, item: WorldItem) -> int:
"""Return effective emit range for one item with sane bounds.""" """Return effective emit range for one item with sane bounds."""
@@ -1023,7 +1065,7 @@ class SignalingServer:
async def _broadcast_item(self, item: WorldItem) -> None: async def _broadcast_item(self, item: WorldItem) -> None:
"""Broadcast a full item snapshot update to all connected clients.""" """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: async def start(self) -> None:
"""Start websocket serving and run until cancelled.""" """Start websocket serving and run until cancelled."""
@@ -1117,7 +1159,7 @@ class SignalingServer:
id=client.id, id=client.id,
player=RemoteUser(id=client.id, nickname=client.nickname, x=client.x, y=client.y), player=RemoteUser(id=client.id, nickname=client.nickname, x=client.x, y=client.y),
users=users, 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={ worldConfig={
"gridSize": self.grid_size, "gridSize": self.grid_size,
"movementTickMs": self.movement_tick_ms, "movementTickMs": self.movement_tick_ms,