refactor: complete server-first item schema wiring and plugin contract checks
This commit is contained in:
@@ -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.24 R227";
|
window.CHGRID_WEB_VERSION = "2026.02.24 R228";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type EditorDeps = {
|
|||||||
getItemPropertyValue: (item: WorldItem, key: string) => string;
|
getItemPropertyValue: (item: WorldItem, key: string) => string;
|
||||||
itemPropertyLabel: (key: string) => string;
|
itemPropertyLabel: (key: string) => string;
|
||||||
isItemPropertyEditable: (item: WorldItem, key: string) => boolean;
|
isItemPropertyEditable: (item: WorldItem, key: string) => boolean;
|
||||||
getItemPropertyOptionValues: (key: string) => string[] | undefined;
|
getItemPropertyOptionValues: (itemType: WorldItem['type'], key: string) => string[] | undefined;
|
||||||
openItemPropertyOptionSelect: (item: WorldItem, key: string) => void;
|
openItemPropertyOptionSelect: (item: WorldItem, key: string) => void;
|
||||||
describeItemPropertyHelp: (item: WorldItem, key: string) => string;
|
describeItemPropertyHelp: (item: WorldItem, key: string) => string;
|
||||||
getItemPropertyMetadata: (
|
getItemPropertyMetadata: (
|
||||||
@@ -98,7 +98,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
deps.sfxUiCancel();
|
deps.sfxUiCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const options = deps.getItemPropertyOptionValues(selectedKey);
|
const options = deps.getItemPropertyOptionValues(item.type, selectedKey);
|
||||||
if (options && options.length > 0) {
|
if (options && options.length > 0) {
|
||||||
const currentRaw = String(item.params[selectedKey] ?? '').trim().toLowerCase();
|
const currentRaw = String(item.params[selectedKey] ?? '').trim().toLowerCase();
|
||||||
const currentIndex = Math.max(
|
const currentIndex = Math.max(
|
||||||
@@ -177,7 +177,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
deps.sfxUiBlip();
|
deps.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (deps.getItemPropertyOptionValues(selectedKey)) {
|
if (deps.getItemPropertyOptionValues(item.type, selectedKey)) {
|
||||||
deps.openItemPropertyOptionSelect(item, selectedKey);
|
deps.openItemPropertyOptionSelect(item, selectedKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
} else if (valueType === 'number') {
|
} else if (valueType === 'number') {
|
||||||
if (!submitNumericParam(propertyKey)) return;
|
if (!submitNumericParam(propertyKey)) return;
|
||||||
} else if (valueType === 'list') {
|
} else if (valueType === 'list') {
|
||||||
const options = deps.getItemPropertyOptionValues(propertyKey) ?? [];
|
const options = deps.getItemPropertyOptionValues(item.type, propertyKey) ?? [];
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`);
|
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`);
|
||||||
deps.sfxUiCancel();
|
deps.sfxUiCancel();
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
|
|||||||
const stepText = metadata.range.step !== undefined ? ` step ${metadata.range.step}` : '';
|
const stepText = metadata.range.step !== undefined ? ` step ${metadata.range.step}` : '';
|
||||||
parts.push(`Range: ${metadata.range.min} to ${metadata.range.max}${stepText}.`);
|
parts.push(`Range: ${metadata.range.min} to ${metadata.range.max}${stepText}.`);
|
||||||
} else {
|
} else {
|
||||||
const options = getItemPropertyOptionValues(key);
|
const options = getItemPropertyOptionValues(item.type, key);
|
||||||
if (options && options.length > 0) {
|
if (options && options.length > 0) {
|
||||||
parts.push(`Options: ${options.join(', ')}.`);
|
parts.push(`Options: ${options.join(', ')}.`);
|
||||||
}
|
}
|
||||||
@@ -205,4 +205,3 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
|
|||||||
validateNumericItemPropertyInput,
|
validateNumericItemPropertyInput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'so
|
|||||||
|
|
||||||
export type ItemPropertyMetadata = {
|
export type ItemPropertyMetadata = {
|
||||||
valueType?: ItemPropertyValueType;
|
valueType?: ItemPropertyValueType;
|
||||||
|
label?: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
|
options?: string[];
|
||||||
visibleWhen?: Record<string, string | number | boolean>;
|
visibleWhen?: Record<string, string | number | boolean>;
|
||||||
range?: {
|
range?: {
|
||||||
min: number;
|
min: number;
|
||||||
@@ -20,8 +22,8 @@ type UiDefinitionsPayload = {
|
|||||||
type: ItemType;
|
type: ItemType;
|
||||||
label?: string;
|
label?: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
|
capabilities?: string[];
|
||||||
editableProperties?: string[];
|
editableProperties?: string[];
|
||||||
propertyOptions?: Record<string, string[]>;
|
|
||||||
propertyMetadata?: Record<string, unknown>;
|
propertyMetadata?: Record<string, unknown>;
|
||||||
globalProperties?: Record<string, unknown>;
|
globalProperties?: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
@@ -30,8 +32,8 @@ let itemTypeSequence: ItemType[] = [];
|
|||||||
let itemTypeLabels: Partial<Record<ItemType, string>> = {};
|
let itemTypeLabels: Partial<Record<ItemType, string>> = {};
|
||||||
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
|
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
|
||||||
let itemTypeEditableProperties: Partial<Record<ItemType, string[]>> = {};
|
let itemTypeEditableProperties: Partial<Record<ItemType, string[]>> = {};
|
||||||
|
let itemTypeCapabilities: Partial<Record<ItemType, string[]>> = {};
|
||||||
let itemTypeGlobalProperties: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
|
let itemTypeGlobalProperties: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
|
||||||
let optionItemPropertyValues: Partial<Record<string, string[]>> = {};
|
|
||||||
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
||||||
|
|
||||||
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
|
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
|
||||||
@@ -54,6 +56,9 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
|
|||||||
if (valueObj.valueType === 'boolean' || valueObj.valueType === 'text' || valueObj.valueType === 'number' || valueObj.valueType === 'list' || valueObj.valueType === 'sound') {
|
if (valueObj.valueType === 'boolean' || valueObj.valueType === 'text' || valueObj.valueType === 'number' || valueObj.valueType === 'list' || valueObj.valueType === 'sound') {
|
||||||
metadata.valueType = valueObj.valueType;
|
metadata.valueType = valueObj.valueType;
|
||||||
}
|
}
|
||||||
|
if (typeof valueObj.label === 'string' && valueObj.label.trim().length > 0) {
|
||||||
|
metadata.label = valueObj.label.trim();
|
||||||
|
}
|
||||||
if (typeof valueObj.tooltip === 'string' && valueObj.tooltip.trim().length > 0) {
|
if (typeof valueObj.tooltip === 'string' && valueObj.tooltip.trim().length > 0) {
|
||||||
metadata.tooltip = valueObj.tooltip.trim();
|
metadata.tooltip = valueObj.tooltip.trim();
|
||||||
}
|
}
|
||||||
@@ -63,6 +68,12 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
|
|||||||
metadata.maxLength = Math.floor(maxLength);
|
metadata.maxLength = Math.floor(maxLength);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(valueObj.options)) {
|
||||||
|
const options = valueObj.options.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||||
|
if (options.length > 0) {
|
||||||
|
metadata.options = options;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (valueObj.visibleWhen && typeof valueObj.visibleWhen === 'object') {
|
if (valueObj.visibleWhen && typeof valueObj.visibleWhen === 'object') {
|
||||||
const visibleWhen: Record<string, string | number | boolean> = {};
|
const visibleWhen: Record<string, string | number | boolean> = {};
|
||||||
for (const [conditionKey, conditionValue] of Object.entries(valueObj.visibleWhen as Record<string, unknown>)) {
|
for (const [conditionKey, conditionValue] of Object.entries(valueObj.visibleWhen as Record<string, unknown>)) {
|
||||||
@@ -95,7 +106,7 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
|
|||||||
|
|
||||||
/** Returns current timezone option list used by clock item properties. */
|
/** Returns current timezone option list used by clock item properties. */
|
||||||
export function getClockTimeZoneOptions(): string[] {
|
export function getClockTimeZoneOptions(): string[] {
|
||||||
return [...(optionItemPropertyValues.timeZone ?? [])];
|
return [...(getItemPropertyMetadata('clock', 'timeZone')?.options ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns default timezone used by clock items when no override is set. */
|
/** Returns default timezone used by clock items when no override is set. */
|
||||||
@@ -124,8 +135,8 @@ export function getItemPropertyMetadata(itemType: ItemType, key: string): ItemPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns option-list values for list-based properties, if defined. */
|
/** Returns option-list values for list-based properties, if defined. */
|
||||||
export function getItemPropertyOptionValues(key: string): string[] | undefined {
|
export function getItemPropertyOptionValues(itemType: ItemType, key: string): string[] | undefined {
|
||||||
return optionItemPropertyValues[key];
|
return itemTypePropertyMetadata[itemType]?.[key]?.options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns human-facing label for an item type. */
|
/** Returns human-facing label for an item type. */
|
||||||
@@ -133,8 +144,17 @@ export function itemTypeLabel(type: ItemType): string {
|
|||||||
return itemTypeLabels[type] ?? type;
|
return itemTypeLabels[type] ?? type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns server-defined capabilities for one item type, if provided. */
|
||||||
|
export function getItemTypeCapabilities(itemType: ItemType): string[] {
|
||||||
|
return [...(itemTypeCapabilities[itemType] ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns human-facing label for a property key. */
|
/** Returns human-facing label for a property key. */
|
||||||
export function itemPropertyLabel(key: string): string {
|
export function itemPropertyLabel(key: string): string {
|
||||||
|
const metadataLabel = Object.values(itemTypePropertyMetadata)
|
||||||
|
.map((entry) => entry?.[key]?.label)
|
||||||
|
.find((label) => typeof label === 'string' && label.trim().length > 0);
|
||||||
|
if (metadataLabel) return metadataLabel;
|
||||||
if (key === 'use24Hour') return 'use 24 hour format';
|
if (key === 'use24Hour') return 'use 24 hour format';
|
||||||
if (key === 'emitRange') return 'emit range';
|
if (key === 'emitRange') return 'emit range';
|
||||||
if (key === 'mediaVolume') return 'media volume';
|
if (key === 'mediaVolume') return 'media volume';
|
||||||
@@ -218,8 +238,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
|||||||
itemTypeLabels = {};
|
itemTypeLabels = {};
|
||||||
itemTypeTooltips = {};
|
itemTypeTooltips = {};
|
||||||
itemTypeEditableProperties = {};
|
itemTypeEditableProperties = {};
|
||||||
|
itemTypeCapabilities = {};
|
||||||
itemTypeGlobalProperties = {};
|
itemTypeGlobalProperties = {};
|
||||||
optionItemPropertyValues = {};
|
|
||||||
itemTypePropertyMetadata = {};
|
itemTypePropertyMetadata = {};
|
||||||
rebuildEditablePropertyKeySet();
|
rebuildEditablePropertyKeySet();
|
||||||
return false;
|
return false;
|
||||||
@@ -233,8 +253,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
|||||||
const nextLabels: Partial<Record<ItemType, string>> = {};
|
const nextLabels: Partial<Record<ItemType, string>> = {};
|
||||||
const nextTooltips: Partial<Record<ItemType, string>> = {};
|
const nextTooltips: Partial<Record<ItemType, string>> = {};
|
||||||
const nextEditable: Partial<Record<ItemType, string[]>> = {};
|
const nextEditable: Partial<Record<ItemType, string[]>> = {};
|
||||||
|
const nextCapabilities: Partial<Record<ItemType, string[]>> = {};
|
||||||
const nextGlobals: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
|
const nextGlobals: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
|
||||||
const nextOptions: Partial<Record<string, string[]>> = {};
|
|
||||||
const nextPropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
const nextPropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
||||||
|
|
||||||
for (const definition of uiDefinitions.itemTypes) {
|
for (const definition of uiDefinitions.itemTypes) {
|
||||||
@@ -249,6 +269,9 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
|||||||
if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) {
|
if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) {
|
||||||
nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string');
|
nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string');
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(definition.capabilities) && definition.capabilities.length > 0) {
|
||||||
|
nextCapabilities[itemType] = definition.capabilities.filter((entry) => typeof entry === 'string');
|
||||||
|
}
|
||||||
if (definition.propertyMetadata && typeof definition.propertyMetadata === 'object') {
|
if (definition.propertyMetadata && typeof definition.propertyMetadata === 'object') {
|
||||||
nextPropertyMetadata[itemType] = normalizePropertyMetadataRecord(definition.propertyMetadata);
|
nextPropertyMetadata[itemType] = normalizePropertyMetadataRecord(definition.propertyMetadata);
|
||||||
}
|
}
|
||||||
@@ -261,15 +284,6 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
|||||||
}
|
}
|
||||||
nextGlobals[itemType] = normalized;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const discoveredOrder: ItemType[] = [];
|
const discoveredOrder: ItemType[] = [];
|
||||||
@@ -281,8 +295,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
|||||||
itemTypeLabels = nextLabels;
|
itemTypeLabels = nextLabels;
|
||||||
itemTypeTooltips = nextTooltips;
|
itemTypeTooltips = nextTooltips;
|
||||||
itemTypeEditableProperties = nextEditable;
|
itemTypeEditableProperties = nextEditable;
|
||||||
|
itemTypeCapabilities = nextCapabilities;
|
||||||
itemTypeGlobalProperties = nextGlobals;
|
itemTypeGlobalProperties = nextGlobals;
|
||||||
optionItemPropertyValues = nextOptions;
|
|
||||||
itemTypePropertyMetadata = nextPropertyMetadata;
|
itemTypePropertyMetadata = nextPropertyMetadata;
|
||||||
itemTypeSequence = explicitOrder ?? discoveredOrder;
|
itemTypeSequence = explicitOrder ?? discoveredOrder;
|
||||||
rebuildEditablePropertyKeySet();
|
rebuildEditablePropertyKeySet();
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ function useItem(item: WorldItem): void {
|
|||||||
|
|
||||||
/** Opens option-list selection mode for list-based item properties. */
|
/** Opens option-list selection mode for list-based item properties. */
|
||||||
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
||||||
const options = getItemPropertyOptionValues(key);
|
const options = getItemPropertyOptionValues(item.type, key);
|
||||||
if (!options || options.length === 0) {
|
if (!options || options.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const itemSchema = z.object({
|
export const itemSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']),
|
type: z.string().min(1),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
x: z.number().int(),
|
x: z.number().int(),
|
||||||
y: z.number().int(),
|
y: z.number().int(),
|
||||||
@@ -42,21 +42,24 @@ export const welcomeMessageSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
uiDefinitions: z
|
uiDefinitions: z
|
||||||
.object({
|
.object({
|
||||||
itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano'])),
|
itemTypeOrder: z.array(z.string().min(1)),
|
||||||
itemTypes: z.array(
|
itemTypes: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']),
|
type: z.string().min(1),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
tooltip: z.string().optional(),
|
tooltip: z.string().optional(),
|
||||||
editableProperties: z.array(z.string()),
|
editableProperties: z.array(z.string()),
|
||||||
propertyOptions: z.record(z.string(), z.array(z.string())).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
propertyMetadata: z
|
propertyMetadata: z
|
||||||
.record(
|
.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
z.object({
|
z.object({
|
||||||
valueType: z.enum(['boolean', 'text', 'number', 'list', 'sound']).optional(),
|
valueType: z.enum(['boolean', 'text', 'number', 'list', 'sound']).optional(),
|
||||||
|
label: z.string().optional(),
|
||||||
tooltip: z.string().optional(),
|
tooltip: z.string().optional(),
|
||||||
maxLength: z.number().int().positive().optional(),
|
maxLength: z.number().int().positive().optional(),
|
||||||
|
options: z.array(z.string()).optional(),
|
||||||
|
visibleWhen: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
||||||
range: z
|
range: z
|
||||||
.object({
|
.object({
|
||||||
min: z.number(),
|
min: z.number(),
|
||||||
@@ -193,7 +196,7 @@ export type OutgoingMessage =
|
|||||||
| { type: 'update_nickname'; nickname: string }
|
| { type: 'update_nickname'; nickname: string }
|
||||||
| { type: 'chat_message'; message: string }
|
| { type: 'chat_message'; message: string }
|
||||||
| { type: 'ping'; clientSentAt: number }
|
| { type: 'ping'; clientSentAt: number }
|
||||||
| { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' | 'piano' }
|
| { type: 'item_add'; itemType: string }
|
||||||
| { type: 'item_pickup'; itemId: string }
|
| { type: 'item_pickup'; itemId: string }
|
||||||
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
||||||
| { type: 'item_delete'; itemId: string }
|
| { type: 'item_delete'; itemId: string }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const GRID_SIZE = 41;
|
|||||||
export const HEARING_RADIUS = 20;
|
export const HEARING_RADIUS = 20;
|
||||||
export const MOVE_COOLDOWN_MS = 200;
|
export const MOVE_COOLDOWN_MS = 200;
|
||||||
|
|
||||||
export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' | 'piano';
|
export type ItemType = string;
|
||||||
|
|
||||||
export type WorldItem = {
|
export type WorldItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ This is a behavior guide for packet semantics beyond raw schemas.
|
|||||||
- `welcome.uiDefinitions`: server-provided item UI definitions:
|
- `welcome.uiDefinitions`: server-provided item UI definitions:
|
||||||
- `itemTypeOrder`: add-item menu order
|
- `itemTypeOrder`: add-item menu order
|
||||||
- `itemTypes[].tooltip`: item-level tooltip/help text
|
- `itemTypes[].tooltip`: item-level tooltip/help text
|
||||||
|
- `itemTypes[].capabilities`: server-declared actions supported by the type
|
||||||
- `itemTypes[].editableProperties`: editable property keys by item type
|
- `itemTypes[].editableProperties`: editable property keys by item type
|
||||||
- `itemTypes[].propertyOptions`: menu options for property keys (for example clock `timeZone`)
|
- `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `label`, optional `range`, optional `tooltip`, optional `maxLength`, optional `options`, optional `visibleWhen`)
|
||||||
- `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `range`, optional `tooltip`, optional `maxLength`, optional `visibleWhen`)
|
|
||||||
- `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`)
|
- `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`)
|
||||||
- Client item UI requires this metadata from the server; there is no fallback item definition map.
|
- Client item UI requires this metadata from the server; there is no fallback item definition map.
|
||||||
|
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ Implementation:
|
|||||||
- Keep compile-time unions only where they materially improve safety and can be generated/centralized.
|
- Keep compile-time unions only where they materially improve safety and can be generated/centralized.
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- Adding a new item plugin does not require editing multiple type-literal lists in unrelated files.
|
- Adding a new item plugin does not require editing multiple type-literal lists in unrelated files.
|
||||||
- New type appears in add/edit flows after server metadata update with minimal client changes (or none for generic items).
|
- New type appears in add/edit flows after server metadata update with minimal client changes.
|
||||||
|
|
||||||
3. **Include `capabilities` in `welcome.uiDefinitions.itemTypes` (medium).**
|
3. **Include `capabilities` in `welcome.uiDefinitions.itemTypes` (medium).**
|
||||||
Scope:
|
Scope:
|
||||||
@@ -360,8 +360,7 @@ Problem today:
|
|||||||
Implementation:
|
Implementation:
|
||||||
- Server emits list options at `propertyMetadata[key].options`.
|
- Server emits list options at `propertyMetadata[key].options`.
|
||||||
- Client reads options from metadata first.
|
- Client reads options from metadata first.
|
||||||
- Deprecate/remove separate `propertyOptions` map after migration.
|
- Remove separate `propertyOptions` map.
|
||||||
- Keep transition shim only during one deploy window if needed.
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
- One canonical place (`propertyMetadata`) defines type, range, tooltip, options, and visibility for each property.
|
- One canonical place (`propertyMetadata`) defines type, range, tooltip, options, and visibility for each property.
|
||||||
- New list property requires server-only metadata changes for options.
|
- New list property requires server-only metadata changes for options.
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal, cast
|
from typing import TypeAlias, cast
|
||||||
|
|
||||||
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
|
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
|
||||||
|
|
||||||
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
ItemType: TypeAlias = str
|
||||||
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
|
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
|
||||||
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
|
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
|
||||||
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
||||||
@@ -75,15 +75,6 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
|||||||
for item_type in ITEM_TYPE_SEQUENCE
|
for item_type in ITEM_TYPE_SEQUENCE
|
||||||
}
|
}
|
||||||
|
|
||||||
ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
|
|
||||||
"mediaEffect": RADIO_EFFECT_OPTIONS,
|
|
||||||
"emitEffect": RADIO_EFFECT_OPTIONS,
|
|
||||||
"mediaChannel": RADIO_CHANNEL_OPTIONS,
|
|
||||||
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
|
|
||||||
"instrument": PIANO_INSTRUMENT_OPTIONS,
|
|
||||||
"voiceMode": PIANO_VOICE_MODE_OPTIONS,
|
|
||||||
}
|
|
||||||
|
|
||||||
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
|
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
|
||||||
item_type: ITEM_MODULES[item_type].TOOLTIP for item_type in ITEM_TYPE_SEQUENCE
|
item_type: ITEM_MODULES[item_type].TOOLTIP for item_type in ITEM_TYPE_SEQUENCE
|
||||||
}
|
}
|
||||||
@@ -125,6 +116,12 @@ def get_item_definition(item_type: ItemType) -> ItemDefinition:
|
|||||||
return ITEM_DEFINITIONS[item_type]
|
return ITEM_DEFINITIONS[item_type]
|
||||||
|
|
||||||
|
|
||||||
|
def is_known_item_type(item_type: str) -> bool:
|
||||||
|
"""Return whether a string item type id exists in discovered plugins."""
|
||||||
|
|
||||||
|
return item_type in ITEM_DEFINITIONS
|
||||||
|
|
||||||
|
|
||||||
def get_item_use_cooldown_ms(item_type: ItemType) -> int:
|
def get_item_use_cooldown_ms(item_type: ItemType) -> int:
|
||||||
"""Return validated global use cooldown in milliseconds for an item type."""
|
"""Return validated global use cooldown in milliseconds for an item type."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from .client import ClientConnection
|
from .client import ClientConnection
|
||||||
from .item_catalog import get_item_definition
|
from .item_catalog import get_item_definition
|
||||||
from .models import PersistedWorldItem, WorldItem
|
from .models import PersistedWorldItem, WorldItem
|
||||||
@@ -36,7 +34,7 @@ class ItemService:
|
|||||||
|
|
||||||
return int(time.time() * 1000)
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]) -> WorldItem:
|
def default_item(self, client: ClientConnection, item_type: str) -> WorldItem:
|
||||||
"""Create a new server-authoritative item at the caller's position."""
|
"""Create a new server-authoritative item at the caller's position."""
|
||||||
|
|
||||||
item_def = get_item_definition(item_type)
|
item_def = get_item_definition(item_type)
|
||||||
|
|||||||
@@ -60,6 +60,6 @@ PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
|
|||||||
|
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||||
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."},
|
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time.", "options": list(TIME_ZONE_OPTIONS)},
|
||||||
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
|
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] =
|
|||||||
|
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||||
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."},
|
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano.", "options": list(INSTRUMENT_OPTIONS)},
|
||||||
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords."},
|
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords.", "options": list(VOICE_MODE_OPTIONS)},
|
||||||
"octave": {
|
"octave": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Shifts played notes in octaves. -1 is one octave down.",
|
"tooltip": "Shifts played notes in octaves. -1 is one octave down.",
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
"tooltip": "Playback media volume percent for this radio.",
|
"tooltip": "Playback media volume percent for this radio.",
|
||||||
"range": {"min": 0, "max": 100, "step": 1},
|
"range": {"min": 0, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered.", "options": list(CHANNEL_OPTIONS)},
|
||||||
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect.", "options": list(EFFECT_OPTIONS)},
|
||||||
"mediaEffectValue": {
|
"mediaEffectValue": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Amount for the selected effect.",
|
"tooltip": "Amount for the selected effect.",
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
"tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
|
"tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
|
||||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||||
},
|
},
|
||||||
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."},
|
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound.", "options": list(EFFECT_OPTIONS)},
|
||||||
"emitEffectValue": {
|
"emitEffectValue": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Amount for emit effect.",
|
"tooltip": "Amount for emit effect.",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
|
|||||||
|
|
||||||
class ItemAddPacket(BasePacket):
|
class ItemAddPacket(BasePacket):
|
||||||
type: Literal["item_add"]
|
type: Literal["item_add"]
|
||||||
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
itemType: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class ItemPickupPacket(BasePacket):
|
class ItemPickupPacket(BasePacket):
|
||||||
@@ -173,7 +173,7 @@ class NicknameResultPacket(BasePacket):
|
|||||||
|
|
||||||
class WorldItem(BaseModel):
|
class WorldItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
type: str = Field(min_length=1)
|
||||||
title: str
|
title: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
@@ -191,7 +191,7 @@ class WorldItem(BaseModel):
|
|||||||
class PersistedWorldItem(BaseModel):
|
class PersistedWorldItem(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
id: str
|
id: str
|
||||||
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
|
type: str = Field(min_length=1)
|
||||||
title: str
|
title: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
|
|||||||
@@ -25,14 +25,15 @@ from .config import load_config
|
|||||||
from .item_catalog import (
|
from .item_catalog import (
|
||||||
CLOCK_DEFAULT_TIME_ZONE,
|
CLOCK_DEFAULT_TIME_ZONE,
|
||||||
CLOCK_TIME_ZONE_OPTIONS,
|
CLOCK_TIME_ZONE_OPTIONS,
|
||||||
ITEM_PROPERTY_OPTIONS,
|
|
||||||
ITEM_TYPE_EDITABLE_PROPERTIES,
|
ITEM_TYPE_EDITABLE_PROPERTIES,
|
||||||
ITEM_TYPE_LABELS,
|
ITEM_TYPE_LABELS,
|
||||||
ITEM_TYPE_PROPERTY_METADATA,
|
ITEM_TYPE_PROPERTY_METADATA,
|
||||||
ITEM_TYPE_SEQUENCE,
|
ITEM_TYPE_SEQUENCE,
|
||||||
ITEM_TYPE_TOOLTIPS,
|
ITEM_TYPE_TOOLTIPS,
|
||||||
|
get_item_definition,
|
||||||
get_item_global_properties,
|
get_item_global_properties,
|
||||||
get_item_use_cooldown_ms,
|
get_item_use_cooldown_ms,
|
||||||
|
is_known_item_type,
|
||||||
)
|
)
|
||||||
from .item_type_handlers import get_item_type_handler
|
from .item_type_handlers import get_item_type_handler
|
||||||
from .item_service import ItemService
|
from .item_service import ItemService
|
||||||
@@ -708,18 +709,13 @@ class SignalingServer:
|
|||||||
item_types: list[dict] = []
|
item_types: list[dict] = []
|
||||||
for item_type in ITEM_TYPE_SEQUENCE:
|
for item_type in ITEM_TYPE_SEQUENCE:
|
||||||
editable = list(ITEM_TYPE_EDITABLE_PROPERTIES.get(item_type, ("title",)))
|
editable = list(ITEM_TYPE_EDITABLE_PROPERTIES.get(item_type, ("title",)))
|
||||||
property_options: dict[str, list[str]] = {}
|
|
||||||
for key in editable:
|
|
||||||
options = ITEM_PROPERTY_OPTIONS.get(key)
|
|
||||||
if options:
|
|
||||||
property_options[key] = list(options)
|
|
||||||
item_types.append(
|
item_types.append(
|
||||||
{
|
{
|
||||||
"type": item_type,
|
"type": item_type,
|
||||||
"label": ITEM_TYPE_LABELS.get(item_type, item_type),
|
"label": ITEM_TYPE_LABELS.get(item_type, item_type),
|
||||||
"tooltip": ITEM_TYPE_TOOLTIPS.get(item_type),
|
"tooltip": ITEM_TYPE_TOOLTIPS.get(item_type),
|
||||||
|
"capabilities": list(get_item_definition(item_type).capabilities),
|
||||||
"editableProperties": editable,
|
"editableProperties": editable,
|
||||||
"propertyOptions": property_options,
|
|
||||||
"propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}),
|
"propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}),
|
||||||
"globalProperties": get_item_global_properties(item_type),
|
"globalProperties": get_item_global_properties(item_type),
|
||||||
}
|
}
|
||||||
@@ -897,6 +893,9 @@ class SignalingServer:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(packet, ItemAddPacket):
|
if isinstance(packet, ItemAddPacket):
|
||||||
|
if not is_known_item_type(packet.itemType):
|
||||||
|
await self._send_item_result(client, False, "add", "Unknown item type.")
|
||||||
|
return
|
||||||
item = self.item_service.default_item(client, packet.itemType)
|
item = self.item_service.default_item(client, packet.itemType)
|
||||||
self.item_service.add_item(item)
|
self.item_service.add_item(item)
|
||||||
await self._broadcast_item(item)
|
await self._broadcast_item(item)
|
||||||
|
|||||||
32
server/tests/test_item_plugin_contract.py
Normal file
32
server/tests/test_item_plugin_contract.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.items.registry import ITEM_PLUGINS
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_plugins_expose_expected_contract() -> None:
|
||||||
|
for plugin in ITEM_PLUGINS:
|
||||||
|
module = plugin.module
|
||||||
|
assert isinstance(plugin.type, str) and plugin.type
|
||||||
|
assert isinstance(plugin.order, int)
|
||||||
|
assert callable(getattr(module, "validate_update", None))
|
||||||
|
assert callable(getattr(module, "use_item", None))
|
||||||
|
assert isinstance(getattr(module, "LABEL", ""), str)
|
||||||
|
assert isinstance(getattr(module, "TOOLTIP", ""), str)
|
||||||
|
assert isinstance(getattr(module, "EDITABLE_PROPERTIES", ()), tuple)
|
||||||
|
assert isinstance(getattr(module, "CAPABILITIES", ()), tuple)
|
||||||
|
assert isinstance(getattr(module, "DEFAULT_PARAMS", {}), dict)
|
||||||
|
assert isinstance(getattr(module, "PROPERTY_METADATA", {}), dict)
|
||||||
|
|
||||||
|
|
||||||
|
def test_item_plugin_folders_have_required_files() -> None:
|
||||||
|
base_dir = Path(__file__).resolve().parents[1] / "app" / "items" / "types"
|
||||||
|
for plugin in ITEM_PLUGINS:
|
||||||
|
type_dir = base_dir / plugin.type
|
||||||
|
assert type_dir.is_dir()
|
||||||
|
assert (type_dir / "definition.py").is_file()
|
||||||
|
assert (type_dir / "validator.py").is_file()
|
||||||
|
assert (type_dir / "actions.py").is_file()
|
||||||
|
assert (type_dir / "module.py").is_file()
|
||||||
|
assert (type_dir / "plugin.py").is_file()
|
||||||
@@ -33,15 +33,16 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
|
|||||||
assert isinstance(entry.get("type"), str)
|
assert isinstance(entry.get("type"), str)
|
||||||
assert isinstance(entry.get("label"), str)
|
assert isinstance(entry.get("label"), str)
|
||||||
assert isinstance(entry.get("editableProperties"), list)
|
assert isinstance(entry.get("editableProperties"), list)
|
||||||
|
assert isinstance(entry.get("capabilities"), list)
|
||||||
assert isinstance(entry.get("propertyMetadata"), dict)
|
assert isinstance(entry.get("propertyMetadata"), dict)
|
||||||
assert isinstance(entry.get("propertyOptions"), dict)
|
|
||||||
assert isinstance(entry.get("globalProperties"), dict)
|
assert isinstance(entry.get("globalProperties"), dict)
|
||||||
|
|
||||||
editable_properties = entry["editableProperties"]
|
editable_properties = entry["editableProperties"]
|
||||||
|
capabilities = entry["capabilities"]
|
||||||
property_metadata = entry["propertyMetadata"]
|
property_metadata = entry["propertyMetadata"]
|
||||||
property_options = entry["propertyOptions"]
|
|
||||||
global_properties = entry["globalProperties"]
|
global_properties = entry["globalProperties"]
|
||||||
|
|
||||||
|
assert capabilities
|
||||||
assert required_global_keys.issubset(set(global_properties.keys()))
|
assert required_global_keys.issubset(set(global_properties.keys()))
|
||||||
for property_key in editable_properties:
|
for property_key in editable_properties:
|
||||||
if property_key == "title":
|
if property_key == "title":
|
||||||
@@ -50,7 +51,7 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
|
|||||||
metadata = property_metadata[property_key]
|
metadata = property_metadata[property_key]
|
||||||
assert isinstance(metadata, dict)
|
assert isinstance(metadata, dict)
|
||||||
if metadata.get("valueType") == "list":
|
if metadata.get("valueType") == "list":
|
||||||
options = property_options.get(property_key)
|
options = metadata.get("options")
|
||||||
assert isinstance(options, list)
|
assert isinstance(options, list)
|
||||||
assert options
|
assert options
|
||||||
|
|
||||||
|
|||||||
@@ -83,3 +83,24 @@ async def test_broadcast_fanout_is_concurrent(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
assert ws1 in send_started_at
|
assert ws1 in send_started_at
|
||||||
assert ws2 in send_started_at
|
assert ws2 in send_started_at
|
||||||
assert abs(send_started_at[ws1] - send_started_at[ws2]) < 0.02
|
assert abs(send_started_at[ws1] - send_started_at[ws2]) < 0.02
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_item_add_rejects_unknown_type(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
ws = _fake_ws()
|
||||||
|
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
|
||||||
|
server.clients[ws] = client
|
||||||
|
|
||||||
|
send_payloads: list[object] = []
|
||||||
|
|
||||||
|
async def fake_send(websocket: ServerConnection, packet: object) -> None:
|
||||||
|
send_payloads.append(packet)
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_send", fake_send)
|
||||||
|
|
||||||
|
await server._handle_message(client, json.dumps({"type": "item_add", "itemType": "not_a_type"}))
|
||||||
|
|
||||||
|
assert send_payloads
|
||||||
|
assert send_payloads[-1].ok is False
|
||||||
|
assert "unknown item type" in send_payloads[-1].message.lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user