refactor: collapse item modules into plugins and remove no-op client item behaviors
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { clockDefinition, CLOCK_TIME_ZONE_OPTIONS } from './definition';
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { diceDefinition } from './definition';
|
||||
|
||||
@@ -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);
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { pianoDefinition } from './definition';
|
||||
export { createPianoBehavior } from './behavior';
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { radioStationDefinition } from './definition';
|
||||
|
||||
@@ -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<string, string | number | boolean>;
|
||||
propertyOptions?: Record<string, string[]>;
|
||||
propertyMetadata?: Record<string, ItemPropertyMetadata>;
|
||||
};
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { wheelDefinition } from './definition';
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { widgetDefinition } from './definition';
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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/<name>.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/<item_type>/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/<item_type>/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/<item_type>/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,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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/<item_type>/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/<item_type>/behavior.ts` only if custom client runtime is needed.
|
||||
5. Client runtime behavior: add `client/src/items/types/<item_type>/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
|
||||
|
||||
302
plans/item-architecture-refactor-plan.md
Normal file
302
plans/item-architecture-refactor-plan.md
Normal file
@@ -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/<item_type>/`
|
||||
|
||||
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/<new_type>/`
|
||||
- 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/<newType>/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.
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
"""Per-item modules containing item-specific schema and behavior."""
|
||||
|
||||
"""Item subsystem package (shared helpers + type-plugin loader)."""
|
||||
|
||||
@@ -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?"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user