refactor: collapse item modules into plugins and remove no-op client item behaviors

This commit is contained in:
Jage9
2026-02-24 03:00:30 -05:00
parent d4dbb807da
commit 7903bab131
38 changed files with 375 additions and 345 deletions

View File

@@ -1,26 +1,14 @@
import { type IncomingMessage } from '../../network/protocol'; import { type IncomingMessage } from '../../network/protocol';
import { type GameMode, type WorldItem } from '../../state/gameState'; import { type GameMode, type WorldItem } from '../../state/gameState';
import { createClockBehavior } from './clock/behavior';
import { createDiceBehavior } from './dice/behavior';
import { createPianoBehavior } from './piano/behavior'; import { createPianoBehavior } from './piano/behavior';
import { createRadioStationBehavior } from './radioStation/behavior';
import { type ItemBehavior, type ItemBehaviorDeps } from './runtimeShared'; 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. */ /** Runtime registry that composes all per-item client behavior modules. */
export class ItemBehaviorRegistry { export class ItemBehaviorRegistry {
private readonly behaviors: ItemBehavior[]; private readonly behaviors: ItemBehavior[];
constructor(deps: ItemBehaviorDeps) { constructor(deps: ItemBehaviorDeps) {
this.behaviors = [ this.behaviors = [createPianoBehavior(deps)];
createClockBehavior(deps),
createDiceBehavior(deps),
createPianoBehavior(deps),
createRadioStationBehavior(deps),
createWheelBehavior(deps),
createWidgetBehavior(deps),
];
} }
/** Runs per-item initialization hooks after app bootstrap. */ /** Runs per-item initialization hooks after app bootstrap. */

View File

@@ -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 {};
}

View File

@@ -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],
},
};

View File

@@ -1,2 +0,0 @@
export { clockDefinition, CLOCK_TIME_ZONE_OPTIONS } from './definition';

View File

@@ -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 {};
}

View File

@@ -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,
},
};

View File

@@ -1,2 +0,0 @@
export { diceDefinition } from './definition';

View File

@@ -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);

View File

@@ -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'],
},
};

View File

@@ -1,2 +0,0 @@
export { pianoDefinition } from './definition';
export { createPianoBehavior } from './behavior';

View File

@@ -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 {};
}

View File

@@ -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],
},
};

View File

@@ -1,2 +0,0 @@
export { radioStationDefinition } from './definition';

View File

@@ -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>;
};

View File

@@ -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 {};
}

View File

@@ -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,
},
};

View File

@@ -1,2 +0,0 @@
export { wheelDefinition } from './definition';

View File

@@ -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 {};
}

View File

@@ -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),
},
};

View File

@@ -1,2 +0,0 @@
export { widgetDefinition } from './definition';

View File

@@ -49,7 +49,7 @@
- Persisted state stores only instance data. - Persisted state stores only instance data.
- Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`. - 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). - 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`. - End-to-end add-item template: `docs/item-type-template.md`.

View File

@@ -1,50 +1,54 @@
# Item Type Template # 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 ## 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`) 1. Server item module
- Define item metadata constants: - Add `server/app/items/types/<item_type>/module.py`.
- label/tooltip - Define metadata/constants:
- editable properties - `LABEL`, `TOOLTIP`
- defaults/capabilities/sounds/cooldown/range/directional - `EDITABLE_PROPERTIES`
- property metadata - `CAPABILITIES`
- `USE_SOUND`, `EMIT_SOUND`
- `USE_COOLDOWN_MS`, `EMIT_RANGE`, `DIRECTIONAL`
- `DEFAULT_TITLE`, `DEFAULT_PARAMS`
- `PROPERTY_METADATA`
- Implement behavior: - Implement behavior:
- `validate_update(item, next_params)` - `validate_update(item, next_params)`
- `use_item(item, nickname, clock_formatter)` - `use_item(item, nickname, clock_formatter)`
2. Server registry (`server/app/items/registry.py`) 2. Server plugin file
- Add one module entry in `ITEM_MODULES`. - Add `server/app/items/types/<item_type>/plugin.py` exporting:
- Update `ITEM_TYPE_ORDER` if needed. - `type`
- `order`
- `module`
3. Shared item type unions 3. Shared item-type unions
- Add the type in: - Add the type in:
- `server/app/models.py` - `server/app/models.py`
- `client/src/network/protocol.ts` - `client/src/network/protocol.ts`
- `client/src/state/gameState.ts` - `client/src/state/gameState.ts`
4. Client fallback metadata 4. Client runtime behavior (optional)
- Add defaults in `client/src/items/itemRegistry.ts`: - Default: no item-specific client module needed.
- `DEFAULT_ITEM_TYPE_SEQUENCE` - Add `client/src/items/types/<item_type>/behavior.ts` only if this item needs custom client runtime UX/audio logic (for example piano mode).
- `DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES`
- `DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES`
That is enough for a first working item type. That is enough for a first working item type.
## Minimal Server Module Example: `counter` ## Minimal Server Module Example: `counter`
`server/app/items/counter.py`: `server/app/items/types/counter/module.py`:
```py ```py
from __future__ import annotations from __future__ import annotations
from typing import Callable from typing import Callable
from ..item_types import ItemUseResult from ...item_types import ItemUseResult
from ..models import WorldItem from ...models import WorldItem
LABEL = "counter" LABEL = "counter"
TOOLTIP = "Counts up each time you use it." 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 ```py
from . import clock, counter, dice, radio, wheel from . import module
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "counter", "dice", "radio_station", "wheel") ITEM_TYPE_PLUGIN = {
"type": "counter",
ITEM_MODULES: dict[str, ItemModule] = { "order": 25,
"clock": clock, "module": module,
"counter": counter,
"dice": dice,
"radio_station": radio,
"wheel": wheel,
} }
``` ```

View File

@@ -42,7 +42,7 @@ This is behavior-focused documentation for item types and their defaults.
- `mediaVolume`: integer `0..100` - `mediaVolume`: integer `0..100`
- `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off` - `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off`
- `mediaEffectValue`: number `0..100` with `0.1` precision - `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` - `emitRange`: integer `5..20`
## `dice` ## `dice`
@@ -141,7 +141,7 @@ This is behavior-focused documentation for item types and their defaults.
### Validation ### Validation
- `enabled`: boolean or on/off style input - `enabled`: boolean or on/off style input
- `directional`: 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` - `emitRange`: integer `1..20`
- `emitVolume`: integer `0..100` - `emitVolume`: integer `0..100`
- `emitSoundSpeed`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`) for speed/pitch - `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`. 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 - defaults/capabilities
- property metadata/options - property metadata/options
- `validate_update` and `use_item` - `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. 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. 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`. 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. 6. Tests: add or update server tests under `server/tests/` for use/update validation, unknown-key stripping, and `uiDefinitions` completeness.
### Example Shape ### Example Shape

View 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.

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal, cast from typing import Literal, cast
from .items import clock, piano, radio
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 = 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 item_type: ITEM_MODULES[item_type].EDITABLE_PROPERTIES for item_type in ITEM_TYPE_SEQUENCE
} }
CLOCK_DEFAULT_TIME_ZONE = clock.DEFAULT_TIME_ZONE CLOCK_DEFAULT_TIME_ZONE = cast(str, ITEM_MODULES["clock"].DEFAULT_TIME_ZONE)
CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS CLOCK_TIME_ZONE_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["clock"].TIME_ZONE_OPTIONS)
RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS RADIO_EFFECT_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["radio_station"].EFFECT_OPTIONS)
RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS RADIO_CHANNEL_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["radio_station"].CHANNEL_OPTIONS)
PIANO_INSTRUMENT_OPTIONS = piano.INSTRUMENT_OPTIONS PIANO_INSTRUMENT_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["piano"].INSTRUMENT_OPTIONS)
PIANO_VOICE_MODE_OPTIONS = piano.VOICE_MODE_OPTIONS PIANO_VOICE_MODE_OPTIONS = cast(tuple[str, ...], ITEM_MODULES["piano"].VOICE_MODE_OPTIONS)
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@@ -1,2 +1 @@
"""Per-item modules containing item-specific schema and behavior.""" """Item subsystem package (shared helpers + type-plugin loader)."""

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
from typing import Callable from typing import Callable
from ..item_types import ItemUseResult from ....item_types import ItemUseResult
from ..models import WorldItem from ....models import WorldItem
from .helpers import keep_only_known_params, parse_bool_like_or_none from ...helpers import keep_only_known_params, parse_bool_like_or_none
LABEL = "clock" LABEL = "clock"
TOOLTIP = "It tells the time. What did you think it did?" TOOLTIP = "It tells the time. What did you think it did?"

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from ... import clock from . import module
ITEM_TYPE_PLUGIN = { ITEM_TYPE_PLUGIN = {
"type": "clock", "type": "clock",
"order": 10, "order": 10,
"module": clock, "module": module,
} }

View File

@@ -5,9 +5,9 @@ from __future__ import annotations
import random import random
from typing import Callable from typing import Callable
from ..item_types import ItemUseResult from ....item_types import ItemUseResult
from ..models import WorldItem from ....models import WorldItem
from .helpers import keep_only_known_params from ...helpers import keep_only_known_params
LABEL = "dice" LABEL = "dice"
TOOLTIP = "Great for drinking games or boredom." TOOLTIP = "Great for drinking games or boredom."

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from ... import dice from . import module
ITEM_TYPE_PLUGIN = { ITEM_TYPE_PLUGIN = {
"type": "dice", "type": "dice",
"order": 20, "order": 20,
"module": dice, "module": module,
} }

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
from typing import Callable from typing import Callable
from ..item_types import ItemUseResult from ....item_types import ItemUseResult
from ..models import WorldItem from ....models import WorldItem
from .helpers import keep_only_known_params from ...helpers import keep_only_known_params
LABEL = "piano" LABEL = "piano"
TOOLTIP = "Playable keyboard instrument with multiple synth voices." TOOLTIP = "Playable keyboard instrument with multiple synth voices."

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from ... import piano from . import module
ITEM_TYPE_PLUGIN = { ITEM_TYPE_PLUGIN = {
"type": "piano", "type": "piano",
"order": 30, "order": 30,
"module": piano, "module": module,
} }

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
from typing import Callable from typing import Callable
from ..item_types import ItemUseResult from ....item_types import ItemUseResult
from ..models import WorldItem from ....models import WorldItem
from .helpers import keep_only_known_params, toggle_bool_param from ...helpers import keep_only_known_params, toggle_bool_param
LABEL = "radio" LABEL = "radio"
TOOLTIP = "Can play stations from the Internet. Tune multiple to the same station and they will sync up." TOOLTIP = "Can play stations from the Internet. Tune multiple to the same station and they will sync up."

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from ... import radio from . import module
ITEM_TYPE_PLUGIN = { ITEM_TYPE_PLUGIN = {
"type": "radio_station", "type": "radio_station",
"order": 40, "order": 40,
"module": radio, "module": module,
} }

View File

@@ -5,9 +5,9 @@ from __future__ import annotations
import random import random
from typing import Callable from typing import Callable
from ..item_types import ItemUseResult from ....item_types import ItemUseResult
from ..models import WorldItem from ....models import WorldItem
from .helpers import keep_only_known_params from ...helpers import keep_only_known_params
LABEL = "wheel" LABEL = "wheel"
TOOLTIP = "Spin to win fabulous prizes." TOOLTIP = "Spin to win fabulous prizes."

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from ... import wheel from . import module
ITEM_TYPE_PLUGIN = { ITEM_TYPE_PLUGIN = {
"type": "wheel", "type": "wheel",
"order": 50, "order": 50,
"module": wheel, "module": module,
} }

View File

@@ -4,9 +4,9 @@ from __future__ import annotations
from typing import Callable from typing import Callable
from ..item_types import ItemUseResult from ....item_types import ItemUseResult
from ..models import WorldItem from ....models import WorldItem
from .helpers import keep_only_known_params, parse_bool_like, toggle_bool_param from ...helpers import keep_only_known_params, parse_bool_like, toggle_bool_param
LABEL = "widget" LABEL = "widget"
TOOLTIP = "A basic item. Make it a beacon or whatever you want." TOOLTIP = "A basic item. Make it a beacon or whatever you want."

View File

@@ -2,10 +2,10 @@
from __future__ import annotations from __future__ import annotations
from ... import widget from . import module
ITEM_TYPE_PLUGIN = { ITEM_TYPE_PLUGIN = {
"type": "widget", "type": "widget",
"order": 60, "order": 60,
"module": widget, "module": module,
} }