Move readonly item property display values to server
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user