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 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. */

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

View File

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

View File

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

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

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 ..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?"

View File

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

View File

@@ -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."

View File

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

View File

@@ -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."

View File

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

View File

@@ -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."

View File

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

View File

@@ -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."

View File

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

View File

@@ -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."

View File

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