2026-02-21 18:31:25 -05:00
|
|
|
import { EFFECT_SEQUENCE } from '../audio/effects';
|
|
|
|
|
import { RADIO_CHANNEL_OPTIONS } from '../audio/radioStationRuntime';
|
|
|
|
|
import { type ItemType, type WorldItem } from '../state/gameState';
|
|
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
|
2026-02-21 18:31:25 -05:00
|
|
|
'America/Anchorage',
|
|
|
|
|
'America/Argentina/Buenos_Aires',
|
|
|
|
|
'America/Chicago',
|
|
|
|
|
'America/Detroit',
|
|
|
|
|
'America/Halifax',
|
|
|
|
|
'America/Indiana/Indianapolis',
|
|
|
|
|
'America/Kentucky/Louisville',
|
|
|
|
|
'America/Los_Angeles',
|
|
|
|
|
'America/St_Johns',
|
|
|
|
|
'Asia/Bangkok',
|
|
|
|
|
'Asia/Dhaka',
|
|
|
|
|
'Asia/Dubai',
|
|
|
|
|
'Asia/Hong_Kong',
|
|
|
|
|
'Asia/Kabul',
|
|
|
|
|
'Asia/Karachi',
|
|
|
|
|
'Asia/Kathmandu',
|
|
|
|
|
'Asia/Kolkata',
|
|
|
|
|
'Asia/Seoul',
|
|
|
|
|
'Asia/Singapore',
|
|
|
|
|
'Asia/Tehran',
|
|
|
|
|
'Asia/Tokyo',
|
|
|
|
|
'Asia/Yangon',
|
|
|
|
|
'Atlantic/Azores',
|
|
|
|
|
'Atlantic/South_Georgia',
|
|
|
|
|
'Australia/Brisbane',
|
|
|
|
|
'Australia/Darwin',
|
|
|
|
|
'Australia/Eucla',
|
|
|
|
|
'Australia/Lord_Howe',
|
|
|
|
|
'Europe/Berlin',
|
|
|
|
|
'Europe/Helsinki',
|
|
|
|
|
'Europe/London',
|
|
|
|
|
'Europe/Moscow',
|
|
|
|
|
'Pacific/Apia',
|
|
|
|
|
'Pacific/Auckland',
|
|
|
|
|
'Pacific/Chatham',
|
|
|
|
|
'Pacific/Honolulu',
|
|
|
|
|
'Pacific/Kiritimati',
|
|
|
|
|
'Pacific/Noumea',
|
|
|
|
|
'Pacific/Pago_Pago',
|
|
|
|
|
'UTC',
|
|
|
|
|
] as const;
|
|
|
|
|
|
2026-02-21 22:20:15 -05:00
|
|
|
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel', 'widget'];
|
2026-02-21 18:31:25 -05:00
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
2026-02-21 22:55:20 -05:00
|
|
|
radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'],
|
2026-02-21 18:31:25 -05:00
|
|
|
dice: ['title', 'sides', 'number'],
|
|
|
|
|
wheel: ['title', 'spaces'],
|
|
|
|
|
clock: ['title', 'timeZone', 'use24Hour'],
|
2026-02-21 23:07:37 -05:00
|
|
|
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSpeed', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'],
|
2026-02-21 18:31:25 -05:00
|
|
|
};
|
|
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
2026-02-21 19:37:08 -05:00
|
|
|
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 20, directional: true },
|
|
|
|
|
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false },
|
|
|
|
|
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false },
|
|
|
|
|
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false },
|
2026-02-21 22:20:15 -05:00
|
|
|
widget: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false },
|
2026-02-21 18:31:25 -05:00
|
|
|
};
|
|
|
|
|
|
2026-02-21 20:47:02 -05:00
|
|
|
export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound';
|
|
|
|
|
|
|
|
|
|
export type ItemPropertyMetadata = {
|
|
|
|
|
valueType?: ItemPropertyValueType;
|
|
|
|
|
tooltip?: string;
|
|
|
|
|
range?: {
|
|
|
|
|
min: number;
|
|
|
|
|
max: number;
|
|
|
|
|
step?: number;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
type UiDefinitionsPayload = {
|
|
|
|
|
itemTypeOrder?: ItemType[];
|
|
|
|
|
itemTypes?: Array<{
|
|
|
|
|
type: ItemType;
|
|
|
|
|
label?: string;
|
2026-02-21 20:47:02 -05:00
|
|
|
tooltip?: string;
|
2026-02-21 19:12:58 -05:00
|
|
|
editableProperties?: string[];
|
|
|
|
|
propertyOptions?: Record<string, string[]>;
|
2026-02-21 20:47:02 -05:00
|
|
|
propertyMetadata?: Record<string, unknown>;
|
2026-02-21 19:12:58 -05:00
|
|
|
globalProperties?: Record<string, unknown>;
|
|
|
|
|
}>;
|
|
|
|
|
};
|
2026-02-21 18:31:25 -05:00
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE];
|
|
|
|
|
let itemTypeLabels: Record<ItemType, string> = {
|
|
|
|
|
radio_station: 'radio',
|
|
|
|
|
dice: 'dice',
|
|
|
|
|
wheel: 'wheel',
|
|
|
|
|
clock: 'clock',
|
2026-02-21 22:20:15 -05:00
|
|
|
widget: 'widget',
|
2026-02-21 19:12:58 -05:00
|
|
|
};
|
2026-02-21 20:58:16 -05:00
|
|
|
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
|
2026-02-21 19:12:58 -05:00
|
|
|
let itemTypeEditableProperties: Record<ItemType, string[]> = {
|
|
|
|
|
radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station],
|
|
|
|
|
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
|
|
|
|
|
wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel],
|
|
|
|
|
clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock],
|
2026-02-21 22:20:15 -05:00
|
|
|
widget: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.widget],
|
2026-02-21 19:12:58 -05:00
|
|
|
};
|
|
|
|
|
let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = {
|
|
|
|
|
radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station },
|
|
|
|
|
dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice },
|
|
|
|
|
wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel },
|
|
|
|
|
clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock },
|
2026-02-21 22:20:15 -05:00
|
|
|
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
|
2026-02-21 19:12:58 -05:00
|
|
|
};
|
|
|
|
|
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
2026-02-21 22:55:20 -05:00
|
|
|
mediaEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
|
|
|
|
emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
|
|
|
|
mediaChannel: [...RADIO_CHANNEL_OPTIONS],
|
2026-02-21 19:12:58 -05:00
|
|
|
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
|
2026-02-21 18:31:25 -05:00
|
|
|
};
|
2026-02-21 20:58:16 -05:00
|
|
|
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
2026-02-21 18:31:25 -05:00
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
|
|
|
|
|
Object.values(itemTypeEditableProperties).flatMap((keys) => keys),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
function rebuildEditablePropertyKeySet(): void {
|
|
|
|
|
EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(Object.values(itemTypeEditableProperties).flatMap((keys) => keys));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 20:47:02 -05:00
|
|
|
function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefined): Record<string, ItemPropertyMetadata> {
|
|
|
|
|
if (!raw) return {};
|
|
|
|
|
const normalized: Record<string, ItemPropertyMetadata> = {};
|
|
|
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
|
|
|
if (!value || typeof value !== 'object') continue;
|
|
|
|
|
const valueObj = value as Record<string, unknown>;
|
|
|
|
|
const metadata: ItemPropertyMetadata = {};
|
|
|
|
|
if (valueObj.valueType === 'boolean' || valueObj.valueType === 'text' || valueObj.valueType === 'number' || valueObj.valueType === 'list' || valueObj.valueType === 'sound') {
|
|
|
|
|
metadata.valueType = valueObj.valueType;
|
|
|
|
|
}
|
|
|
|
|
if (typeof valueObj.tooltip === 'string' && valueObj.tooltip.trim().length > 0) {
|
|
|
|
|
metadata.tooltip = valueObj.tooltip.trim();
|
|
|
|
|
}
|
|
|
|
|
const range = valueObj.range;
|
|
|
|
|
if (range && typeof range === 'object') {
|
|
|
|
|
const rangeObj = range as Record<string, unknown>;
|
|
|
|
|
const min = Number(rangeObj.min);
|
|
|
|
|
const max = Number(rangeObj.max);
|
|
|
|
|
const step = rangeObj.step === undefined ? undefined : Number(rangeObj.step);
|
|
|
|
|
if (Number.isFinite(min) && Number.isFinite(max)) {
|
|
|
|
|
metadata.range = {
|
|
|
|
|
min,
|
|
|
|
|
max,
|
|
|
|
|
...(Number.isFinite(step) ? { step } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
normalized[key] = metadata;
|
|
|
|
|
}
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
export function getClockTimeZoneOptions(): string[] {
|
|
|
|
|
return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getDefaultClockTimeZone(): string {
|
|
|
|
|
return getClockTimeZoneOptions()[0] ?? 'America/Detroit';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getItemTypeSequence(): ItemType[] {
|
|
|
|
|
return [...itemTypeSequence];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getItemTypeGlobalProperties(itemType: ItemType): Record<string, string | number | boolean> {
|
|
|
|
|
return itemTypeGlobalProperties[itemType] ?? {};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 20:47:02 -05:00
|
|
|
export function getItemTypeTooltip(itemType: ItemType): string | undefined {
|
|
|
|
|
return itemTypeTooltips[itemType];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getItemPropertyMetadata(itemType: ItemType, key: string): ItemPropertyMetadata | undefined {
|
|
|
|
|
return itemTypePropertyMetadata[itemType]?.[key];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 18:31:25 -05:00
|
|
|
export function getItemPropertyOptionValues(key: string): string[] | undefined {
|
2026-02-21 19:12:58 -05:00
|
|
|
return optionItemPropertyValues[key];
|
2026-02-21 18:31:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function itemTypeLabel(type: ItemType): string {
|
2026-02-21 19:12:58 -05:00
|
|
|
return itemTypeLabels[type] ?? type;
|
2026-02-21 18:31:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function itemPropertyLabel(key: string): string {
|
|
|
|
|
if (key === 'use24Hour') return 'use 24 hour format';
|
2026-02-21 20:31:34 -05:00
|
|
|
if (key === 'emitRange') return 'emit range';
|
2026-02-21 22:38:48 -05:00
|
|
|
if (key === 'mediaVolume') return 'media volume';
|
|
|
|
|
if (key === 'emitVolume') return 'emit volume';
|
2026-02-21 23:07:37 -05:00
|
|
|
if (key === 'emitSpeed') return 'emit speed';
|
2026-02-21 22:55:20 -05:00
|
|
|
if (key === 'mediaChannel') return 'media channel';
|
|
|
|
|
if (key === 'mediaEffect') return 'media effect';
|
|
|
|
|
if (key === 'mediaEffectValue') return 'media effect value';
|
|
|
|
|
if (key === 'emitEffect') return 'emit effect';
|
|
|
|
|
if (key === 'emitEffectValue') return 'emit effect value';
|
2026-02-21 22:20:15 -05:00
|
|
|
if (key === 'useSound') return 'use sound';
|
|
|
|
|
if (key === 'emitSound') return 'emit sound';
|
2026-02-21 18:31:25 -05:00
|
|
|
return key;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getEditableItemPropertyKeys(item: WorldItem): string[] {
|
2026-02-21 19:12:58 -05:00
|
|
|
return [...(itemTypeEditableProperties[item.type] ?? ['title'])];
|
2026-02-21 18:31:25 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getInspectItemPropertyKeys(item: WorldItem): string[] {
|
|
|
|
|
const editableKeys = getEditableItemPropertyKeys(item);
|
|
|
|
|
const seen = new Set(editableKeys);
|
|
|
|
|
const allKeys: string[] = [...editableKeys];
|
|
|
|
|
|
|
|
|
|
const baseKeys = [
|
|
|
|
|
'type',
|
|
|
|
|
'x',
|
|
|
|
|
'y',
|
|
|
|
|
'carrierId',
|
|
|
|
|
'version',
|
|
|
|
|
'createdBy',
|
|
|
|
|
'createdAt',
|
|
|
|
|
'updatedAt',
|
|
|
|
|
'capabilities',
|
|
|
|
|
'useSound',
|
|
|
|
|
'emitSound',
|
|
|
|
|
];
|
|
|
|
|
for (const key of baseKeys) {
|
|
|
|
|
if (seen.has(key)) continue;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
allKeys.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b));
|
|
|
|
|
for (const key of paramKeys) {
|
|
|
|
|
if (seen.has(key)) continue;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
allKeys.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:12:58 -05:00
|
|
|
const globalKeys = Object.keys(itemTypeGlobalProperties[item.type] ?? {}).sort((a, b) => a.localeCompare(b));
|
2026-02-21 18:31:25 -05:00
|
|
|
for (const key of globalKeys) {
|
|
|
|
|
if (seen.has(key)) continue;
|
|
|
|
|
seen.add(key);
|
|
|
|
|
allKeys.push(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return allKeys;
|
|
|
|
|
}
|
2026-02-21 19:12:58 -05:00
|
|
|
|
|
|
|
|
export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void {
|
|
|
|
|
if (!uiDefinitions) return;
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0) {
|
|
|
|
|
itemTypeSequence = uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) {
|
|
|
|
|
rebuildEditablePropertyKeySet();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextLabels = { ...itemTypeLabels };
|
2026-02-21 20:47:02 -05:00
|
|
|
const nextTooltips = { ...itemTypeTooltips };
|
2026-02-21 19:12:58 -05:00
|
|
|
const nextEditable = { ...itemTypeEditableProperties };
|
|
|
|
|
const nextGlobals = { ...itemTypeGlobalProperties };
|
|
|
|
|
const nextOptions: Partial<Record<string, string[]>> = { ...optionItemPropertyValues };
|
2026-02-21 20:47:02 -05:00
|
|
|
const nextPropertyMetadata = { ...itemTypePropertyMetadata };
|
2026-02-21 19:12:58 -05:00
|
|
|
|
|
|
|
|
for (const definition of uiDefinitions.itemTypes) {
|
|
|
|
|
if (!definition || typeof definition.type !== 'string') continue;
|
|
|
|
|
const itemType = definition.type as ItemType;
|
|
|
|
|
if (typeof definition.label === 'string' && definition.label.trim()) {
|
|
|
|
|
nextLabels[itemType] = definition.label.trim();
|
|
|
|
|
}
|
2026-02-21 20:47:02 -05:00
|
|
|
if (typeof definition.tooltip === 'string' && definition.tooltip.trim()) {
|
|
|
|
|
nextTooltips[itemType] = definition.tooltip.trim();
|
|
|
|
|
}
|
2026-02-21 19:12:58 -05:00
|
|
|
if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) {
|
|
|
|
|
nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string');
|
|
|
|
|
}
|
2026-02-21 20:47:02 -05:00
|
|
|
if (definition.propertyMetadata && typeof definition.propertyMetadata === 'object') {
|
|
|
|
|
nextPropertyMetadata[itemType] = normalizePropertyMetadataRecord(definition.propertyMetadata);
|
|
|
|
|
}
|
2026-02-21 19:12:58 -05:00
|
|
|
if (definition.globalProperties && typeof definition.globalProperties === 'object') {
|
|
|
|
|
const normalized: Record<string, string | number | boolean> = {};
|
|
|
|
|
for (const [key, raw] of Object.entries(definition.globalProperties)) {
|
|
|
|
|
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
|
|
|
|
|
normalized[key] = raw;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
nextGlobals[itemType] = normalized;
|
|
|
|
|
}
|
|
|
|
|
if (definition.propertyOptions && typeof definition.propertyOptions === 'object') {
|
|
|
|
|
for (const [propertyKey, values] of Object.entries(definition.propertyOptions)) {
|
|
|
|
|
if (!Array.isArray(values) || values.length === 0) continue;
|
|
|
|
|
const normalizedValues = values.filter((entry) => typeof entry === 'string');
|
|
|
|
|
if (normalizedValues.length > 0) {
|
|
|
|
|
nextOptions[propertyKey] = normalizedValues;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
itemTypeLabels = nextLabels;
|
2026-02-21 20:47:02 -05:00
|
|
|
itemTypeTooltips = nextTooltips;
|
2026-02-21 19:12:58 -05:00
|
|
|
itemTypeEditableProperties = nextEditable;
|
|
|
|
|
itemTypeGlobalProperties = nextGlobals;
|
|
|
|
|
optionItemPropertyValues = nextOptions;
|
2026-02-21 20:47:02 -05:00
|
|
|
itemTypePropertyMetadata = nextPropertyMetadata;
|
2026-02-21 19:12:58 -05:00
|
|
|
rebuildEditablePropertyKeySet();
|
|
|
|
|
}
|