diff --git a/client/src/items/types/behaviorRegistry.ts b/client/src/items/types/behaviorRegistry.ts index 17a1f50..037bc0e 100644 --- a/client/src/items/types/behaviorRegistry.ts +++ b/client/src/items/types/behaviorRegistry.ts @@ -1,26 +1,14 @@ import { type IncomingMessage } from '../../network/protocol'; import { type GameMode, type WorldItem } from '../../state/gameState'; -import { createClockBehavior } from './clock/behavior'; -import { createDiceBehavior } from './dice/behavior'; import { createPianoBehavior } from './piano/behavior'; -import { createRadioStationBehavior } from './radioStation/behavior'; import { type ItemBehavior, type ItemBehaviorDeps } from './runtimeShared'; -import { createWheelBehavior } from './wheel/behavior'; -import { createWidgetBehavior } from './widget/behavior'; /** Runtime registry that composes all per-item client behavior modules. */ export class ItemBehaviorRegistry { private readonly behaviors: ItemBehavior[]; constructor(deps: ItemBehaviorDeps) { - this.behaviors = [ - createClockBehavior(deps), - createDiceBehavior(deps), - createPianoBehavior(deps), - createRadioStationBehavior(deps), - createWheelBehavior(deps), - createWidgetBehavior(deps), - ]; + this.behaviors = [createPianoBehavior(deps)]; } /** Runs per-item initialization hooks after app bootstrap. */ diff --git a/client/src/items/types/clock/behavior.ts b/client/src/items/types/clock/behavior.ts deleted file mode 100644 index b2484e5..0000000 --- a/client/src/items/types/clock/behavior.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; - -/** Creates runtime behavior hooks for clock items. */ -export function createClockBehavior(_deps: ItemBehaviorDeps): ItemBehavior { - return {}; -} - diff --git a/client/src/items/types/clock/definition.ts b/client/src/items/types/clock/definition.ts deleted file mode 100644 index 5ec042b..0000000 --- a/client/src/items/types/clock/definition.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { type ItemTypeClientDefinition } from '../shared'; - -export const CLOCK_TIME_ZONE_OPTIONS = [ - '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; - -/** Default client-side UI definition for clock items. */ -export const clockDefinition: ItemTypeClientDefinition = { - type: 'clock', - label: 'clock', - editableProperties: ['title', 'timeZone', 'use24Hour'], - globalProperties: { - useSound: 'none', - emitSound: 'sounds/clock.ogg', - useCooldownMs: 1000, - emitRange: 10, - directional: false, - emitSoundSpeed: 50, - emitSoundTempo: 50, - }, - propertyOptions: { - timeZone: [...CLOCK_TIME_ZONE_OPTIONS], - }, -}; diff --git a/client/src/items/types/clock/index.ts b/client/src/items/types/clock/index.ts deleted file mode 100644 index 27de3d4..0000000 --- a/client/src/items/types/clock/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { clockDefinition, CLOCK_TIME_ZONE_OPTIONS } from './definition'; - diff --git a/client/src/items/types/dice/behavior.ts b/client/src/items/types/dice/behavior.ts deleted file mode 100644 index 60ec35b..0000000 --- a/client/src/items/types/dice/behavior.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; - -/** Creates runtime behavior hooks for dice items. */ -export function createDiceBehavior(_deps: ItemBehaviorDeps): ItemBehavior { - return {}; -} - diff --git a/client/src/items/types/dice/definition.ts b/client/src/items/types/dice/definition.ts deleted file mode 100644 index 3bd11df..0000000 --- a/client/src/items/types/dice/definition.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type ItemTypeClientDefinition } from '../shared'; - -/** Default client-side UI definition for dice items. */ -export const diceDefinition: ItemTypeClientDefinition = { - type: 'dice', - label: 'dice', - editableProperties: ['title', 'sides', 'number'], - globalProperties: { - useSound: 'sounds/roll.ogg', - emitSound: 'none', - useCooldownMs: 1000, - emitRange: 15, - directional: false, - emitSoundSpeed: 50, - emitSoundTempo: 50, - }, -}; diff --git a/client/src/items/types/dice/index.ts b/client/src/items/types/dice/index.ts deleted file mode 100644 index 578fc14..0000000 --- a/client/src/items/types/dice/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { diceDefinition } from './definition'; - diff --git a/client/src/items/types/index.ts b/client/src/items/types/index.ts deleted file mode 100644 index 290a05b..0000000 --- a/client/src/items/types/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type ItemType } from '../../state/gameState'; -import { clockDefinition } from './clock'; -import { diceDefinition } from './dice'; -import { pianoDefinition } from './piano'; -import { radioStationDefinition } from './radioStation'; -import { wheelDefinition } from './wheel'; -import { widgetDefinition } from './widget'; -import { type ItemTypeClientDefinition } from './shared'; - -/** Ordered default client item definitions used before server UI definitions arrive. */ -export const DEFAULT_ITEM_TYPE_DEFINITIONS: ItemTypeClientDefinition[] = [ - clockDefinition, - diceDefinition, - pianoDefinition, - radioStationDefinition, - wheelDefinition, - widgetDefinition, -]; - -/** Default add-item menu ordering derived from local item definitions. */ -export const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = DEFAULT_ITEM_TYPE_DEFINITIONS.map((definition) => definition.type); diff --git a/client/src/items/types/piano/definition.ts b/client/src/items/types/piano/definition.ts deleted file mode 100644 index 2f5bf98..0000000 --- a/client/src/items/types/piano/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { PIANO_INSTRUMENT_OPTIONS } from '../../../audio/pianoSynth'; -import { type ItemTypeClientDefinition } from '../shared'; - -/** Default client-side UI definition for piano items. */ -export const pianoDefinition: ItemTypeClientDefinition = { - type: 'piano', - label: 'piano', - editableProperties: ['title', 'instrument', 'voiceMode', 'octave', 'attack', 'decay', 'release', 'brightness', 'emitRange'], - globalProperties: { - useSound: 'none', - emitSound: 'none', - useCooldownMs: 1000, - emitRange: 15, - directional: false, - emitSoundSpeed: 50, - emitSoundTempo: 50, - }, - propertyOptions: { - instrument: [...PIANO_INSTRUMENT_OPTIONS], - voiceMode: ['poly', 'mono'], - }, -}; diff --git a/client/src/items/types/piano/index.ts b/client/src/items/types/piano/index.ts deleted file mode 100644 index 10c9275..0000000 --- a/client/src/items/types/piano/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { pianoDefinition } from './definition'; -export { createPianoBehavior } from './behavior'; diff --git a/client/src/items/types/radioStation/behavior.ts b/client/src/items/types/radioStation/behavior.ts deleted file mode 100644 index 1b30836..0000000 --- a/client/src/items/types/radioStation/behavior.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; - -/** Creates runtime behavior hooks for radio_station items. */ -export function createRadioStationBehavior(_deps: ItemBehaviorDeps): ItemBehavior { - return {}; -} - diff --git a/client/src/items/types/radioStation/definition.ts b/client/src/items/types/radioStation/definition.ts deleted file mode 100644 index 76cb78c..0000000 --- a/client/src/items/types/radioStation/definition.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { RADIO_CHANNEL_OPTIONS } from '../../../audio/radioStationRuntime'; -import { EFFECT_SEQUENCE } from '../../../audio/effects'; -import { type ItemTypeClientDefinition } from '../shared'; - -/** Default client-side UI definition for radio_station items. */ -export const radioStationDefinition: ItemTypeClientDefinition = { - type: 'radio_station', - label: 'radio', - editableProperties: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'], - globalProperties: { - useSound: 'none', - emitSound: 'none', - useCooldownMs: 1000, - emitRange: 20, - directional: true, - emitSoundSpeed: 50, - emitSoundTempo: 50, - }, - propertyOptions: { - mediaEffect: EFFECT_SEQUENCE.map((effect) => effect.id), - mediaChannel: [...RADIO_CHANNEL_OPTIONS], - }, -}; diff --git a/client/src/items/types/radioStation/index.ts b/client/src/items/types/radioStation/index.ts deleted file mode 100644 index ae80d4f..0000000 --- a/client/src/items/types/radioStation/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { radioStationDefinition } from './definition'; - diff --git a/client/src/items/types/shared.ts b/client/src/items/types/shared.ts deleted file mode 100644 index d58bc7a..0000000 --- a/client/src/items/types/shared.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type ItemType } from '../../state/gameState'; -import { type ItemPropertyMetadata } from '../itemRegistry'; - -/** Static client-side definition for one item type's UI/config defaults. */ -export type ItemTypeClientDefinition = { - type: ItemType; - label: string; - tooltip?: string; - editableProperties: string[]; - globalProperties: Record; - propertyOptions?: Record; - propertyMetadata?: Record; -}; - diff --git a/client/src/items/types/wheel/behavior.ts b/client/src/items/types/wheel/behavior.ts deleted file mode 100644 index 0f57435..0000000 --- a/client/src/items/types/wheel/behavior.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; - -/** Creates runtime behavior hooks for wheel items. */ -export function createWheelBehavior(_deps: ItemBehaviorDeps): ItemBehavior { - return {}; -} - diff --git a/client/src/items/types/wheel/definition.ts b/client/src/items/types/wheel/definition.ts deleted file mode 100644 index adad777..0000000 --- a/client/src/items/types/wheel/definition.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type ItemTypeClientDefinition } from '../shared'; - -/** Default client-side UI definition for wheel items. */ -export const wheelDefinition: ItemTypeClientDefinition = { - type: 'wheel', - label: 'wheel', - editableProperties: ['title', 'spaces'], - globalProperties: { - useSound: 'sounds/spin.ogg', - emitSound: 'none', - useCooldownMs: 4000, - emitRange: 15, - directional: false, - emitSoundSpeed: 50, - emitSoundTempo: 50, - }, -}; diff --git a/client/src/items/types/wheel/index.ts b/client/src/items/types/wheel/index.ts deleted file mode 100644 index 08db340..0000000 --- a/client/src/items/types/wheel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { wheelDefinition } from './definition'; - diff --git a/client/src/items/types/widget/behavior.ts b/client/src/items/types/widget/behavior.ts deleted file mode 100644 index 98e7ec0..0000000 --- a/client/src/items/types/widget/behavior.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type ItemBehavior, type ItemBehaviorDeps } from '../runtimeShared'; - -/** Creates runtime behavior hooks for widget items. */ -export function createWidgetBehavior(_deps: ItemBehaviorDeps): ItemBehavior { - return {}; -} - diff --git a/client/src/items/types/widget/definition.ts b/client/src/items/types/widget/definition.ts deleted file mode 100644 index 37afe26..0000000 --- a/client/src/items/types/widget/definition.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { EFFECT_SEQUENCE } from '../../../audio/effects'; -import { type ItemTypeClientDefinition } from '../shared'; - -/** Default client-side UI definition for widget items. */ -export const widgetDefinition: ItemTypeClientDefinition = { - type: 'widget', - label: 'widget', - editableProperties: [ - 'title', - 'enabled', - 'directional', - 'facing', - 'emitRange', - 'emitVolume', - 'emitSoundSpeed', - 'emitSoundTempo', - 'emitEffect', - 'emitEffectValue', - 'useSound', - 'emitSound', - ], - globalProperties: { - useSound: 'none', - emitSound: 'none', - useCooldownMs: 1000, - emitRange: 15, - directional: false, - emitSoundSpeed: 50, - emitSoundTempo: 50, - }, - propertyOptions: { - emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id), - }, -}; diff --git a/client/src/items/types/widget/index.ts b/client/src/items/types/widget/index.ts deleted file mode 100644 index 7c28e49..0000000 --- a/client/src/items/types/widget/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { widgetDefinition } from './definition'; - diff --git a/docs/item-schema.md b/docs/item-schema.md index 1e7b2d5..149b1ef 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -49,7 +49,7 @@ - Persisted state stores only instance data. - Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`. -- Per-type use/update validation and message behavior are implemented in per-item modules under `server/app/items/`, discovered via plugins in `server/app/items/types/*/plugin.py`. +- Per-type use/update validation and message behavior are implemented in per-item modules under `server/app/items/types/*/module.py`, discovered via plugins in `server/app/items/types/*/plugin.py`. - Client-side add/edit metadata is consumed from `welcome.uiDefinitions` via `client/src/items/itemRegistry.ts` (no local fallback definitions). - End-to-end add-item template: `docs/item-type-template.md`. diff --git a/docs/item-type-template.md b/docs/item-type-template.md index 21d62db..71f2f78 100644 --- a/docs/item-type-template.md +++ b/docs/item-type-template.md @@ -1,50 +1,54 @@ # Item Type Template -This page is a practical template for adding a new item type with the current per-item module + single registry system. +This page is the practical template for the current plugin-driven item architecture. ## Plain-English Flow -When a new item type is added, wire it in these places: +When adding a new item type: -1. Server item module (`server/app/items/.py`) -- Define item metadata constants: - - label/tooltip - - editable properties - - defaults/capabilities/sounds/cooldown/range/directional - - property metadata +1. Server item module +- Add `server/app/items/types//module.py`. +- Define metadata/constants: + - `LABEL`, `TOOLTIP` + - `EDITABLE_PROPERTIES` + - `CAPABILITIES` + - `USE_SOUND`, `EMIT_SOUND` + - `USE_COOLDOWN_MS`, `EMIT_RANGE`, `DIRECTIONAL` + - `DEFAULT_TITLE`, `DEFAULT_PARAMS` + - `PROPERTY_METADATA` - Implement behavior: - `validate_update(item, next_params)` - `use_item(item, nickname, clock_formatter)` -2. Server registry (`server/app/items/registry.py`) -- Add one module entry in `ITEM_MODULES`. -- Update `ITEM_TYPE_ORDER` if needed. +2. Server plugin file +- Add `server/app/items/types//plugin.py` exporting: + - `type` + - `order` + - `module` -3. Shared item type unions +3. Shared item-type unions - Add the type in: - `server/app/models.py` - `client/src/network/protocol.ts` - `client/src/state/gameState.ts` -4. Client fallback metadata -- Add defaults in `client/src/items/itemRegistry.ts`: - - `DEFAULT_ITEM_TYPE_SEQUENCE` - - `DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES` - - `DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES` +4. Client runtime behavior (optional) +- Default: no item-specific client module needed. +- Add `client/src/items/types//behavior.ts` only if this item needs custom client runtime UX/audio logic (for example piano mode). That is enough for a first working item type. ## Minimal Server Module Example: `counter` -`server/app/items/counter.py`: +`server/app/items/types/counter/module.py`: ```py from __future__ import annotations from typing import Callable -from ..item_types import ItemUseResult -from ..models import WorldItem +from ...item_types import ItemUseResult +from ...models import WorldItem LABEL = "counter" TOOLTIP = "Counts up each time you use it." @@ -84,19 +88,15 @@ def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], ) ``` -Then register it in `server/app/items/registry.py`: +Then add plugin registration in `server/app/items/types/counter/plugin.py`: ```py -from . import clock, counter, dice, radio, wheel +from . import module -ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "counter", "dice", "radio_station", "wheel") - -ITEM_MODULES: dict[str, ItemModule] = { - "clock": clock, - "counter": counter, - "dice": dice, - "radio_station": radio, - "wheel": wheel, +ITEM_TYPE_PLUGIN = { + "type": "counter", + "order": 25, + "module": module, } ``` diff --git a/docs/item-types.md b/docs/item-types.md index 9f9184c..61d8eb8 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -42,7 +42,7 @@ This is behavior-focused documentation for item types and their defaults. - `mediaVolume`: integer `0..100` - `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off` - `mediaEffectValue`: number `0..100` with `0.1` precision -- `facing`: number `0..360` with `0.1` precision +- `facing`: number `0..360` with step `1` - `emitRange`: integer `5..20` ## `dice` @@ -141,7 +141,7 @@ This is behavior-focused documentation for item types and their defaults. ### Validation - `enabled`: boolean or on/off style input - `directional`: boolean or on/off style input -- `facing`: number `0..360` with `0.1` precision +- `facing`: number `0..360` with step `1` - `emitRange`: integer `1..20` - `emitVolume`: integer `0..100` - `emitSoundSpeed`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`) for speed/pitch @@ -193,7 +193,7 @@ Server is the source of truth for item type definitions and metadata. The client For a full copy/paste example with plain-English explanation, see `docs/item-type-template.md`. -1. Server item module: add a new file under `server/app/items/` with: +1. Server item module: add a new file under `server/app/items/types//module.py` with: - defaults/capabilities - property metadata/options - `validate_update` and `use_item` @@ -204,7 +204,7 @@ For a full copy/paste example with plain-English explanation, see `docs/item-typ The server auto-discovers plugins at boot, so no central registry edit is needed. 3. Server models: extend `ItemType` literals in `server/app/models.py` and any packet enums that list item types. 4. Client protocol/state types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`. -5. Client runtime behavior: add `client/src/items/types//behavior.ts` only if custom client runtime is needed. +5. Client runtime behavior: add `client/src/items/types//behavior.ts` only if custom client runtime is needed (for example piano mode). 6. Tests: add or update server tests under `server/tests/` for use/update validation, unknown-key stripping, and `uiDefinitions` completeness. ### Example Shape diff --git a/plans/item-architecture-refactor-plan.md b/plans/item-architecture-refactor-plan.md new file mode 100644 index 0000000..396168b --- /dev/null +++ b/plans/item-architecture-refactor-plan.md @@ -0,0 +1,302 @@ +# Server-First Item Architecture Refactor Plan + +## Goal +Make the **server the only source of truth** for item definitions, schema, defaults, options, validation rules, and editable behavior. The client should consume server definitions and provide UX/rendering/audio only. + +This plan removes client fallback definitions and introduces a repeatable, consistent item authoring structure so adding new item types is low-risk and uniform. + +## Target End State + +### 1) Source of truth +- Server owns, for each item type: + - Type id, label, tooltip + - Full property schema (value type, required/optional, min/max/step, maxLength, enum options) + - Defaults (global + per-item initial params) + - Editability and read-only behavior + - Validation, normalization, migration policy (if any) + - Runtime actions (`use`, optional custom actions) + - Capability list +- Server sends this as canonical `uiDefinitions` + schema metadata on `welcome` (or equivalent bootstrap). + +### 2) Client model +- Client has no static fallback item definitions. +- If schema payload is missing/invalid, item features are unavailable (explicit error/status), not silently guessed. +- Client property editor and item menus are metadata-driven. +- Client runtime behavior modules remain for UX/audio only (e.g., piano local mode), keyed by server type ids. + +### 3) Repeatable item authoring +Adding an item type uses one standard server folder/template and a short checklist, with **auto-discovery** on server boot (no manual registry edits). + +--- + +## Proposed Architecture + +## A) Server: Item Type Package Contract (Auto-Discovered) +Create/standardize per-item server packages under something like: +- `server/app/items/types//` + +Each item type package exports the same contract: +- `definition.py` + - `type_id`, `label`, `tooltip` + - `schema` (properties + metadata) + - `defaults` + - `editable_properties` + - `capabilities` +- `validator.py` + - `validate_create(params) -> normalized_params` + - `validate_update(existing, patch) -> normalized_params` + - Must drop unknown keys by default. +- `actions.py` + - `use(context, item, client, payload?) -> result` + - optional `custom_actions` handlers +- `ui.py` (optional if definition is enough) + - transforms schema -> `uiDefinitions` payload fragments + +A central loader in server scans `server/app/items/types/*` at boot and imports one plugin entrypoint per folder (for example `plugin.py` with `ITEM_TYPE_PLUGIN` export). + +The discovered plugins are then assembled into an in-memory registry object exposing: +- validation hooks +- defaults +- ui definitions +- capabilities +- action dispatch + +This means: +- adding a new item folder + plugin file is sufficient for server registration +- no hand-edited master list is required + +## B) Server: Strict Params Hygiene +In update flow: +- Build next params by applying patch into current params. +- Run through type validator that: + - strips unknown keys + - normalizes known keys + - enforces types/ranges/options +- Persist only validated output. + +No raw client params should persist. + +## C) Server: Save Strategy +Replace synchronous `save_state()` every mutation with coalesced writes: +- mark dirty on mutation +- debounce write (e.g., 100-300ms) +- cap max delay (e.g., 1-2s) +- flush on shutdown/signal + +This preserves durability while reducing event-loop blocking. + +## D) Client: Schema-Driven UI Runtime +Refactor client item registry/editor to consume server schema only. + +Client keeps: +- Presentation helpers +- Generic item behavior path driven only by schema/metadata +- Optional per-item UX runtime modules only where needed (example: piano key mode) + +Client removes: +- static defaults/options/editability lists as authority +- fallback-driven assumptions +- requirement for per-item client modules when behavior is generic + +Property editor logic becomes generic: +- `valueType: boolean` -> toggle +- `valueType: list` + `options` -> list select +- `valueType: number` + `range` -> numeric editor/stepper +- `valueType: text/sound` + `maxLength` -> text editor +- `readonly` -> blocked edit with status + +Special-case handlers only for UX extras (e.g., live preview for certain fields). + +### D.1) Dependent Property Rules +Add dependency metadata to server schema so client can hide dependent fields generically. + +Recommended metadata fields per property: +- `visibleWhen`: simple predicate (for example `{ directional: true }`) + +Example: +- `facing` has `visibleWhen: { directional: true }` +- when `directional` is `false`, `facing` is hidden +- when a controlling property changes, the property menu is recomputed immediately so visibility updates live + +## E) Protocol Shape (Recommended) +Ensure `welcome.uiDefinitions` includes enough to be complete: +- `itemTypes[]` + - `type`, `label`, `tooltip` + - `editableProperties[]` + - `propertyMetadata{ key -> { valueType, tooltip, range, maxLength, options?, readonly?, visibleWhen? } }` + - `globalProperties` + - `capabilities` +- `itemTypeOrder[]` + +Optional future: +- `schemaVersion` for compatibility checks. + +--- + +## Phased Implementation Plan + +## Phase 0: Preconditions and guardrails +1. Document canonical schema contract in `docs/item-schema.md`. +2. Add tests asserting unknown keys are rejected/stripped per type. +3. Add tests asserting `uiDefinitions` completeness for all registered types. + +Deliverable: +- Locked schema contract and tests before heavy refactor. + +## Phase 1: Server type package standardization + auto-discovery +1. Standardize all existing item types to same package contract. +2. Move any remaining type-specific logic out of generic server paths into per-type packages. +3. Add auto-discovery loader APIs: +- `get_type_definition(type_id)` +- `validate_update(type_id, existing, patch)` +- `build_ui_definitions()` +4. Loader scans item folders at startup and registers plugins automatically. + +Deliverable: +- Uniform server-side item modules for all current item types. + +## Phase 2: Strict validation and unknown-key stripping +1. Enforce strict allowed-key filtering in per-type validators. +2. Fail/strip behavior decision: +- recommended: strip unknown keys on load/update, optionally log at debug level. +3. Backfill tests for each type. + +Deliverable: +- No unsupported params can persist. + +## Phase 3: Client removes authority/fallback definitions +1. Remove client hardcoded item defaults/options as authoritative data. +2. Keep only bootstrap guards: +- if schema missing/invalid, fail item UX with explicit status (no fallback behavior). +3. Refactor `itemRegistry` to be a runtime cache of server definitions. + +Deliverable: +- Client item UI driven entirely by server payload. + +## Phase 4: Metadata-driven property editor + visibility dependencies +1. Replace key-specific submit/toggle/list branches with generic metadata-based handlers. +2. Keep a small optional hook map: +- `onPropertyPreviewChange(type,key,value)` for UX preview. +3. Implement `visibleWhen` semantics with live menu recompute when controlling values change. +4. Verify all current item properties work with generic editor. + +Deliverable: +- Adding a new field usually requires server changes only. + +## Phase 5: Behavior registry completion (optional modules) +1. Keep one generic behavior path that works for items with no special runtime. +2. Keep per-item behavior modules only when UX/audio runtime is truly custom. +3. Ensure all runtime hooks are accessed via registry interfaces. +4. Remove any remaining type checks in `main.ts` and shared handlers. + +Deliverable: +- `main.ts` stays orchestration-only. + +## Phase 6: Coalesced persistence +1. Implement debounced save queue in server item service. +2. Add durability tests (flush on shutdown). +3. Add config knobs (debounce ms, max delay). + +Deliverable: +- lower save overhead under bursty updates. + +--- + +## Repeatable New Item Template + +When adding a new item type: + +1. Server +- Create `server/app/items/types//` +- Implement: + - `definition.py` + - `validator.py` + - `actions.py` + - `plugin.py` (entrypoint export for auto-discovery) +- Add tests: + - create defaults + - update validation (valid + invalid + unknown keys) + - `use` behavior + - `uiDefinitions` fields present + +2. Client +- Add `client/src/items/types//behavior.ts` only if custom UX runtime exists. +- Prefer zero client type-specific code for generic items. +- If behavior module is needed, register via behavior loader pattern. +- No hardcoded property logic in editor. + +3. Docs +- Update `docs/item-types.md` +- Update `docs/item-schema.md` +- Update controls docs if keybindings changed. + +--- + +## Risks and Mitigations + +1. Risk: temporary client breakage when fallback removed. +- Mitigation: explicit schema-required startup check and clear status error. + +2. Risk: inconsistent schema during deploy rollover. +- Mitigation: include `schemaVersion` and reject incompatible client/server combinations with clear reconnect message. + +3. Risk: over-generalized editor misses edge-case UX. +- Mitigation: keep small per-item preview hooks while generic editor handles core commit logic. + +4. Risk: debounced persistence data loss on crash. +- Mitigation: short debounce + max-delay + flush on shutdown. + +--- + +## Suggested Execution Order for Your Repo (Practical) +1. Implement strict unknown-key stripping on server (highest impact, lowest UX risk). +2. Implement server plugin auto-discovery for item type folders. +3. Convert client item registry to require server schema payload (remove fallback authority). +4. Make item property editor fully metadata-driven with dependency rules. +5. Finalize optional client behavior modules (only for custom UX items like piano). +6. Add coalesced persistence. + +--- + +## Definition of Done +- Server item validators fully define accepted params and drop unknowns. +- Server item types are boot-loaded from folder plugins (no manual master registry edits). +- `uiDefinitions` is complete and authoritative for all item UI config. +- Client contains no authoritative item defaults/options/editability outside server payload. +- Client has no fallback schema path. +- New item addition follows one template with predictable files/tests. +- `main.ts` has no item-type-specific runtime branches. + +--- + +## Implementation Update (2026-02-24) + +### Completed +- Phase 0: + - Added server-side contract coverage for `uiDefinitions` completeness. + - Added/kept tests for unknown-key stripping and validation behavior. +- Phase 1: + - Server item plugins are auto-discovered from `server/app/items/types/*/plugin.py`. + - Registry now builds type order/modules from discovered plugins. +- Phase 2: + - Unknown params are stripped by validators and use-path updates are revalidated before persist. +- Phase 3: + - Client item registry now requires server `uiDefinitions`; no fallback item-definition authority. + - Missing/invalid schema now disables item menus with explicit status. +- Phase 4: + - Property editor behavior is metadata-driven by `valueType/range/options/maxLength`. + - `visibleWhen` is supported and item property rows recompute live after updates. +- Phase 5: + - Client runtime behavior remains modular per item via behavior registry; `main.ts` orchestration no longer carries item-specific business branches. +- Phase 6: + - Coalesced/debounced state saving implemented. + - Flush-on-shutdown implemented. + - Save timing now configurable via: + - `storage.state_save_debounce_ms` + - `storage.state_save_max_delay_ms` + +### Notes +- Client item-specific runtime is now reduced to only `piano`; simple items (`dice`, `wheel`, `clock`, `radio_station`, `widget`) run through generic client flows with no custom behavior module. +- Server item implementations now live inside per-type folders (`server/app/items/types/*/module.py`) and plugins point directly to those modules. +- Remaining optional future work: + - split server type modules into `definition.py`/`validator.py`/`actions.py` files per type if we want finer-grained plugin internals. diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index bb3213f..00cbfce 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -5,7 +5,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal, cast -from .items import clock, piano, radio from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] @@ -15,12 +14,12 @@ ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = { item_type: ITEM_MODULES[item_type].EDITABLE_PROPERTIES for item_type in ITEM_TYPE_SEQUENCE } -CLOCK_DEFAULT_TIME_ZONE = clock.DEFAULT_TIME_ZONE -CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS -RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS -RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS -PIANO_INSTRUMENT_OPTIONS = piano.INSTRUMENT_OPTIONS -PIANO_VOICE_MODE_OPTIONS = piano.VOICE_MODE_OPTIONS +CLOCK_DEFAULT_TIME_ZONE = cast(str, ITEM_MODULES["clock"].DEFAULT_TIME_ZONE) +CLOCK_TIME_ZONE_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["clock"].TIME_ZONE_OPTIONS) +RADIO_EFFECT_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["radio_station"].EFFECT_OPTIONS) +RADIO_CHANNEL_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["radio_station"].CHANNEL_OPTIONS) +PIANO_INSTRUMENT_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["piano"].INSTRUMENT_OPTIONS) +PIANO_VOICE_MODE_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["piano"].VOICE_MODE_OPTIONS) @dataclass(frozen=True) diff --git a/server/app/items/__init__.py b/server/app/items/__init__.py index 37d3683..d2375e7 100644 --- a/server/app/items/__init__.py +++ b/server/app/items/__init__.py @@ -1,2 +1 @@ -"""Per-item modules containing item-specific schema and behavior.""" - +"""Item subsystem package (shared helpers + type-plugin loader).""" diff --git a/server/app/items/clock.py b/server/app/items/types/clock/module.py similarity index 95% rename from server/app/items/clock.py rename to server/app/items/types/clock/module.py index 8018789..f886406 100644 --- a/server/app/items/clock.py +++ b/server/app/items/types/clock/module.py @@ -4,9 +4,9 @@ from __future__ import annotations from typing import Callable -from ..item_types import ItemUseResult -from ..models import WorldItem -from .helpers import keep_only_known_params, parse_bool_like_or_none +from ....item_types import ItemUseResult +from ....models import WorldItem +from ...helpers import keep_only_known_params, parse_bool_like_or_none LABEL = "clock" TOOLTIP = "It tells the time. What did you think it did?" diff --git a/server/app/items/types/clock/plugin.py b/server/app/items/types/clock/plugin.py index 10026e2..52d5b6b 100644 --- a/server/app/items/types/clock/plugin.py +++ b/server/app/items/types/clock/plugin.py @@ -2,10 +2,10 @@ from __future__ import annotations -from ... import clock +from . import module ITEM_TYPE_PLUGIN = { "type": "clock", "order": 10, - "module": clock, + "module": module, } diff --git a/server/app/items/dice.py b/server/app/items/types/dice/module.py similarity index 95% rename from server/app/items/dice.py rename to server/app/items/types/dice/module.py index 0b283ba..2e85929 100644 --- a/server/app/items/dice.py +++ b/server/app/items/types/dice/module.py @@ -5,9 +5,9 @@ from __future__ import annotations import random from typing import Callable -from ..item_types import ItemUseResult -from ..models import WorldItem -from .helpers import keep_only_known_params +from ....item_types import ItemUseResult +from ....models import WorldItem +from ...helpers import keep_only_known_params LABEL = "dice" TOOLTIP = "Great for drinking games or boredom." diff --git a/server/app/items/types/dice/plugin.py b/server/app/items/types/dice/plugin.py index cdfa9c6..241bddc 100644 --- a/server/app/items/types/dice/plugin.py +++ b/server/app/items/types/dice/plugin.py @@ -2,10 +2,10 @@ from __future__ import annotations -from ... import dice +from . import module ITEM_TYPE_PLUGIN = { "type": "dice", "order": 20, - "module": dice, + "module": module, } diff --git a/server/app/items/piano.py b/server/app/items/types/piano/module.py similarity index 98% rename from server/app/items/piano.py rename to server/app/items/types/piano/module.py index 8f9e07d..11523c4 100644 --- a/server/app/items/piano.py +++ b/server/app/items/types/piano/module.py @@ -4,9 +4,9 @@ from __future__ import annotations from typing import Callable -from ..item_types import ItemUseResult -from ..models import WorldItem -from .helpers import keep_only_known_params +from ....item_types import ItemUseResult +from ....models import WorldItem +from ...helpers import keep_only_known_params LABEL = "piano" TOOLTIP = "Playable keyboard instrument with multiple synth voices." diff --git a/server/app/items/types/piano/plugin.py b/server/app/items/types/piano/plugin.py index 9824d2f..cd829d2 100644 --- a/server/app/items/types/piano/plugin.py +++ b/server/app/items/types/piano/plugin.py @@ -2,10 +2,10 @@ from __future__ import annotations -from ... import piano +from . import module ITEM_TYPE_PLUGIN = { "type": "piano", "order": 30, - "module": piano, + "module": module, } diff --git a/server/app/items/radio.py b/server/app/items/types/radio_station/module.py similarity index 97% rename from server/app/items/radio.py rename to server/app/items/types/radio_station/module.py index 6be9c9b..8cc5385 100644 --- a/server/app/items/radio.py +++ b/server/app/items/types/radio_station/module.py @@ -4,9 +4,9 @@ from __future__ import annotations from typing import Callable -from ..item_types import ItemUseResult -from ..models import WorldItem -from .helpers import keep_only_known_params, toggle_bool_param +from ....item_types import ItemUseResult +from ....models import WorldItem +from ...helpers import keep_only_known_params, toggle_bool_param LABEL = "radio" TOOLTIP = "Can play stations from the Internet. Tune multiple to the same station and they will sync up." diff --git a/server/app/items/types/radio_station/plugin.py b/server/app/items/types/radio_station/plugin.py index 382fdd5..487a012 100644 --- a/server/app/items/types/radio_station/plugin.py +++ b/server/app/items/types/radio_station/plugin.py @@ -2,10 +2,10 @@ from __future__ import annotations -from ... import radio +from . import module ITEM_TYPE_PLUGIN = { "type": "radio_station", "order": 40, - "module": radio, + "module": module, } diff --git a/server/app/items/wheel.py b/server/app/items/types/wheel/module.py similarity index 95% rename from server/app/items/wheel.py rename to server/app/items/types/wheel/module.py index d7dc2ce..8009d1d 100644 --- a/server/app/items/wheel.py +++ b/server/app/items/types/wheel/module.py @@ -5,9 +5,9 @@ from __future__ import annotations import random from typing import Callable -from ..item_types import ItemUseResult -from ..models import WorldItem -from .helpers import keep_only_known_params +from ....item_types import ItemUseResult +from ....models import WorldItem +from ...helpers import keep_only_known_params LABEL = "wheel" TOOLTIP = "Spin to win fabulous prizes." diff --git a/server/app/items/types/wheel/plugin.py b/server/app/items/types/wheel/plugin.py index 1e413fd..8fbc863 100644 --- a/server/app/items/types/wheel/plugin.py +++ b/server/app/items/types/wheel/plugin.py @@ -2,10 +2,10 @@ from __future__ import annotations -from ... import wheel +from . import module ITEM_TYPE_PLUGIN = { "type": "wheel", "order": 50, - "module": wheel, + "module": module, } diff --git a/server/app/items/widget.py b/server/app/items/types/widget/module.py similarity index 98% rename from server/app/items/widget.py rename to server/app/items/types/widget/module.py index 2eda0ad..5d4d931 100644 --- a/server/app/items/widget.py +++ b/server/app/items/types/widget/module.py @@ -4,9 +4,9 @@ from __future__ import annotations from typing import Callable -from ..item_types import ItemUseResult -from ..models import WorldItem -from .helpers import keep_only_known_params, parse_bool_like, toggle_bool_param +from ....item_types import ItemUseResult +from ....models import WorldItem +from ...helpers import keep_only_known_params, parse_bool_like, toggle_bool_param LABEL = "widget" TOOLTIP = "A basic item. Make it a beacon or whatever you want." diff --git a/server/app/items/types/widget/plugin.py b/server/app/items/types/widget/plugin.py index 25a435a..f0f7fb3 100644 --- a/server/app/items/types/widget/plugin.py +++ b/server/app/items/types/widget/plugin.py @@ -2,10 +2,10 @@ from __future__ import annotations -from ... import widget +from . import module ITEM_TYPE_PLUGIN = { "type": "widget", "order": 60, - "module": widget, + "module": module, }