Initial Commit
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# enterprise-controller
|
||||
|
||||
**Package**: `@suaveplan/controller`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package (migration from `../suaveplan/packages/universal/controller/`)
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/controller/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis lacks a universal-tier input controller. The upstream `@suaveplan/controller` package
|
||||
(67 files, ~5000 LoC) provides a unified `ControllerInput` shape across gamepad, keyboard, mouse,
|
||||
touch, plus pattern recognition and input buffering. Games built on `@suaveplan/ecs` and the
|
||||
forthcoming `@suaveplan/game-server` need a deterministic, injectable input layer that does not
|
||||
read wall-clock time directly and can be exercised under a `FakeClock`.
|
||||
|
||||
The upstream code uses `Date.now()` throughout, mutates module-level state, and lacks structured
|
||||
errors, Zod boundary validation, and OTel instrumentation. This migration re-implements the
|
||||
public surface with genesis primitives (`Clock`/`IdGenerator` injection, `defineCodes`, OTel
|
||||
counter + histogram, Zod schemas) while preserving the `ControllerInput` shape, the
|
||||
`BaseController` lifecycle, the `ControllerMerger` semantics, and the device adapters.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `BaseController` — abstract base with state, thresholds, deadzone/threshold logic,
|
||||
pollRate gating, lifecycle, event emission, `Clock` injection.
|
||||
- ADDED: `KeyboardController` — DOM keyboard adapter with configurable key→input mapping.
|
||||
- ADDED: `GamepadController` — `navigator.getGamepads()` polling adapter with axis deadzone.
|
||||
- ADDED: `ControllerMerger` — merges N child controllers into a single `ControllerInput`
|
||||
state via max-value reduction with stable iteration order.
|
||||
- ADDED: `InputEventEmitter` — typed event dispatcher with `onInput`/`onStateChange`/`onCombo`
|
||||
and listener disposal.
|
||||
- ADDED: `ControllerError` + `CONTROLLER_CODES` — stable error-code registry via `defineCodes`.
|
||||
- ADDED: OTel `controller_inputs_total` counter (tags: `device`, `input`) and
|
||||
`controller_pattern_match_duration_ms` histogram, no-op without exporter.
|
||||
- ADDED: Zod-validated public input boundaries (options, snapshots, patterns).
|
||||
- ADDED: Sibling `.md` (≥200 words, 6 sections) for every implementation file.
|
||||
|
||||
## Out of scope (Priority 2 — Deferred 2026-05-24)
|
||||
|
||||
Per the migration brief's explicit allowance ("ship the input-device adapters + controller
|
||||
merger as Priority 1 and defer pattern-recognition + input-buffering"), the following upstream
|
||||
modules are deferred to a follow-up change:
|
||||
|
||||
- `TouchController` — touch event adapter (Priority 2; requires DOM `Touch` polyfills in test).
|
||||
- `MouseController` — 1296-line upstream module requires structural decomposition into
|
||||
≤300-line genesis modules per CLAUDE.md §3; deferred to Priority 2.
|
||||
- `InputBuffer` — circular buffer for combo sequences (Priority 2; pattern-recognition layer).
|
||||
- `InputStateMachine` — pattern-recognition state machine (Priority 2).
|
||||
- `ModalController` — input-mode router (Priority 2).
|
||||
- Default `defaultPatterns` constant (hadoken / shoryuken / charge-shot / rapid-fire) — moves
|
||||
to Priority 2 with the pattern engine.
|
||||
- `demo/`, `test-utils/`, `e2e/` — non-shipping; demo + integration harnesses are downstream
|
||||
concerns and not part of the published surface.
|
||||
|
||||
Justification for deferral (≤1 sentence per upstream §0a anti-patterns rule): every deferred
|
||||
module depends on a co-shipped component that is itself deferred or on a structural
|
||||
decomposition that requires its own design review — pattern recognition + input buffering
|
||||
form one cluster (combo grammar + buffer + state machine) that should land atomically, and
|
||||
the mouse rewrite needs its own 1296→multi-file split design.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/packages/universal/controller/`. There is no upstream
|
||||
`enterprise/controller.md` audit document; this section maps to upstream source files instead.
|
||||
|
||||
| Requirement | Source | Notes |
|
||||
|---|---|---|
|
||||
| ControllerInput shape | `src/types.ts` L1-L66 | verbatim 32-field shape |
|
||||
| BaseController lifecycle | `src/base/base-controller.ts` L1-L386 | re-authored to inject `Clock` instead of `Date.now()`; threshold logic preserved |
|
||||
| KeyboardController | `src/keyboard/keyboard-controller.ts` L1-L226 | re-authored; DOM event listeners + key map |
|
||||
| GamepadController | `src/gamepad/gamepad-controller.ts` L1-L315 | re-authored; `navigator.getGamepads()` polling |
|
||||
| ControllerMerger | `src/controller-merger.ts` L1-L298 | re-authored; max-value reduction preserved |
|
||||
| InputEventEmitter | `src/input/input-event-emitter.ts` L1-L115 | re-authored; typed listener registry |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `defineCodes` error registry | Genesis convention — upstream threw plain `Error` |
|
||||
| OTel instrumentation | Genesis convention — upstream had no observability |
|
||||
| Zod boundary schemas | Genesis convention — upstream had no runtime boundary validation |
|
||||
| `Clock` injection | Genesis convention — upstream read `Date.now()` directly |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/base/base-controller.test.ts` — lifecycle, threshold, deadzone, poll-rate gating.
|
||||
- `src/keyboard/keyboard-controller.test.ts` — key→input mapping, focus loss cleanup.
|
||||
- `src/gamepad/gamepad-controller.test.ts` — axis deadzone, multi-pad fanout.
|
||||
- `src/controller-merger/controller-merger.test.ts` — max-value reduction, add/remove.
|
||||
- `src/event-emitter/event-emitter.test.ts` — listener add/remove/dispose.
|
||||
- `src/errors/errors.test.ts` — every code in `CONTROLLER_CODES` reachable.
|
||||
- `src/observability/observability.test.ts` — counter + histogram emit under in-memory meter.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
None — additive, new package.
|
||||
@@ -0,0 +1,202 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: controller (controller)
|
||||
|
||||
**Package**: `@suaveplan/controller`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Unified ControllerInput shape
|
||||
|
||||
The package SHALL export a `ControllerInput` interface with exactly 32 numeric fields covering
|
||||
directional inputs (`up`/`down`/`left`/`right`), four face buttons (`a`/`b`/`x`/`y`), four
|
||||
triggers (`t1`-`t4`), four function buttons (`f1`-`f4`), two analog sticks (`lx`/`ly`/`rx`/`ry`),
|
||||
two stick buttons (`l3`/`r3`), two analog triggers (`lt`/`rt`), three gyro axes (`gx`/`gy`/`gz`),
|
||||
three motion axes (`mx`/`my`/`mz`), three touch fields (`tx`/`ty`/`tp`), and four special
|
||||
fields (`vl`/`vh`/`bt`/`pi`). Every field SHALL hold an integer in `[0, 255]` after
|
||||
normalisation.
|
||||
|
||||
#### Scenario: shape exposes 32 fields
|
||||
|
||||
- **WHEN** consumer constructs the literal default state returned by `BaseController.getState()`
|
||||
- **THEN** the returned object has exactly 32 own enumerable string keys, every value is `0`,
|
||||
and TypeScript narrows the result to `ControllerInput`
|
||||
|
||||
**Verified by:** `src/base/base-controller.test.ts::"getState returns 32-field zero-initialised ControllerInput"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: BaseController lifecycle and threshold normalisation
|
||||
|
||||
The package SHALL expose `BaseController` with an injectable `Clock` (defaults to system clock),
|
||||
a configurable `pollRate` (Hz, default 60), per-input thresholds (`{ threshold?, deadzone? }`),
|
||||
and a `destroy()` method that clears all state and listeners idempotently. Input updates
|
||||
SHALL be clamped to `[0, 255]`, rounded to integer, gated by deadzone (`|v| < deadzone → 0`)
|
||||
and threshold (`v < threshold → 0`), and emit `input` + `state-change` events only when the
|
||||
post-normalisation value differs from the previous value.
|
||||
|
||||
#### Scenario: pollRate gates update frequency
|
||||
|
||||
- **WHEN** `update()` is called twice within `1000/pollRate` ms with `needsUpdate === true`
|
||||
- **THEN** `updateState()` runs exactly once
|
||||
|
||||
**Verified by:** `src/base/base-controller.test.ts::"update respects pollRate gating"`
|
||||
|
||||
#### Scenario: deadzone zeroes sub-threshold values
|
||||
|
||||
- **WHEN** `setThreshold("lx", { deadzone: 30 })` is set and `setState({ lx: 20 })` is called
|
||||
- **THEN** `getValue("lx") === 0` and no `input` event fires for `lx`
|
||||
|
||||
**Verified by:** `src/base/base-controller.test.ts::"deadzone zeroes sub-threshold values"`
|
||||
|
||||
#### Scenario: destroy is idempotent
|
||||
|
||||
- **WHEN** `destroy()` is called twice in succession
|
||||
- **THEN** the second call is a no-op (no error, no double-cleanup)
|
||||
|
||||
**Verified by:** `src/base/base-controller.test.ts::"destroy is idempotent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: KeyboardController DOM adapter
|
||||
|
||||
The package SHALL expose `KeyboardController(target: EventTarget, options?)` where `options`
|
||||
includes an optional `keyMap: Record<string, keyof ControllerInput>` (defaults to a WASD +
|
||||
arrow + space + numeric map). It SHALL register `keydown` and `keyup` listeners on `target`,
|
||||
set the mapped input to `255` on `keydown` and `0` on `keyup`, and unregister both listeners
|
||||
on `destroy()`.
|
||||
|
||||
#### Scenario: keydown sets mapped input to 255
|
||||
|
||||
- **WHEN** `target.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }))` is fired
|
||||
- **THEN** `getValue("up") === 255`
|
||||
|
||||
**Verified by:** `src/keyboard/keyboard-controller.test.ts::"keydown sets mapped input to 255"`
|
||||
|
||||
#### Scenario: destroy unregisters DOM listeners
|
||||
|
||||
- **WHEN** `destroy()` is called then a `keydown` event is dispatched
|
||||
- **THEN** no state change occurs and no event fires
|
||||
|
||||
**Verified by:** `src/keyboard/keyboard-controller.test.ts::"destroy unregisters DOM listeners"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: GamepadController polling adapter
|
||||
|
||||
The package SHALL expose `GamepadController(options?)` where `options` includes an injectable
|
||||
`getGamepads: () => (Gamepad | null)[]` (defaults to `navigator.getGamepads`) and an
|
||||
`axisDeadzone` (default 0.15). On each `update()` it SHALL read the gamepad at `playerIndex`
|
||||
(default 0), map standard button indices to `ControllerInput` fields, scale axis values from
|
||||
`[-1, 1]` to `[0, 255]` (centred at 128) after applying `axisDeadzone`, and emit changes
|
||||
through the inherited `BaseController` pipeline.
|
||||
|
||||
#### Scenario: standard button maps to face button
|
||||
|
||||
- **WHEN** mocked `getGamepads()` returns `{ buttons: [{ value: 1, pressed: true }, ...] }`
|
||||
for button index 0
|
||||
- **THEN** `getValue("a") === 255`
|
||||
|
||||
**Verified by:** `src/gamepad/gamepad-controller.test.ts::"standard button 0 maps to face button a"`
|
||||
|
||||
#### Scenario: axis deadzone snaps to centre
|
||||
|
||||
- **WHEN** axis value `0.05` (below `axisDeadzone` 0.15) is read for `lx`
|
||||
- **THEN** `getValue("lx") === 128` (centred zero)
|
||||
|
||||
**Verified by:** `src/gamepad/gamepad-controller.test.ts::"axis deadzone snaps to centre"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ControllerMerger N-way max-reduction
|
||||
|
||||
The package SHALL expose `ControllerMerger(options?)` with `addController(c)`, `removeController(c)`,
|
||||
and `update()`. On each `update()`, every child controller's state SHALL be polled and the
|
||||
merged `ControllerInput` SHALL hold `Math.max(...children[input])` for each of the 32 fields.
|
||||
`removeController` SHALL be safe when the controller is not present.
|
||||
|
||||
#### Scenario: merged value is per-field max across children
|
||||
|
||||
- **WHEN** child A reports `{ up: 100, a: 0 }` and child B reports `{ up: 50, a: 200 }`
|
||||
- **THEN** merged state has `up === 100` AND `a === 200`
|
||||
|
||||
**Verified by:** `src/controller-merger/controller-merger.test.ts::"merged value is per-field max across children"`
|
||||
|
||||
#### Scenario: removeController is safe when absent
|
||||
|
||||
- **WHEN** `removeController(c)` is called for a controller never added
|
||||
- **THEN** the call returns without throwing and no listeners are disturbed
|
||||
|
||||
**Verified by:** `src/controller-merger/controller-merger.test.ts::"removeController is safe when absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: InputEventEmitter listener registry
|
||||
|
||||
The package SHALL expose `InputEventEmitter` with `onInput(input, cb)`, `onStateChange(cb)`,
|
||||
`onCombo(cb)` (all returning a disposer), and `emitInput`/`emitStateChange`/`emitCombo` methods.
|
||||
Disposers SHALL remove the exact listener registered without affecting others.
|
||||
|
||||
#### Scenario: disposer removes only the registered listener
|
||||
|
||||
- **WHEN** two listeners are registered for the same input and only one disposer is invoked
|
||||
- **THEN** the remaining listener still fires on `emitInput`
|
||||
|
||||
**Verified by:** `src/event-emitter/event-emitter.test.ts::"disposer removes only the registered listener"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ControllerError + stable code registry
|
||||
|
||||
The package SHALL export `CONTROLLER_CODES = defineCodes("CONTROLLER", [...] as const)` and a
|
||||
`ControllerError extends SuaveplanError<ControllerCode>` class. Every code in the registry
|
||||
SHALL be thrown from at least one production path; consumers SHALL be able to discriminate
|
||||
errors via `error.code` without `instanceof`.
|
||||
|
||||
#### Scenario: every registered code is reachable
|
||||
|
||||
- **WHEN** the conformance test enumerates `CONTROLLER_CODES` and walks every production
|
||||
call site that uses `defineCodes` membership
|
||||
- **THEN** every code is referenced by a `throw new ControllerError(code, …)` or carried on
|
||||
a `Report.code` field
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"every CONTROLLER_CODES entry is thrown by a real call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel instrumentation
|
||||
|
||||
The package SHALL register two instruments via `@opentelemetry/api`:
|
||||
`controller_inputs_total` (counter, attributes `device` and `input`) incremented on every
|
||||
emitted `input` event, and `controller_state_changes_total` (counter, attribute `device`)
|
||||
incremented on every `state-change` event. Both SHALL be no-ops when no `MeterProvider` is
|
||||
registered.
|
||||
|
||||
#### Scenario: counter increments on input event
|
||||
|
||||
- **WHEN** an in-memory `MeterProvider` is registered and a `KeyboardController` fires three
|
||||
`keydown` events
|
||||
- **THEN** `controller_inputs_total{device="keyboard"}` reports a cumulative value of 3
|
||||
|
||||
**Verified by:** `src/observability/observability.test.ts::"controller_inputs_total increments on input event"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod-validated public boundaries
|
||||
|
||||
The package SHALL define Zod schemas for `BaseControllerOptionsSchema`, `KeyboardOptionsSchema`,
|
||||
`GamepadOptionsSchema`, `ControllerMergerOptionsSchema`, and `ControllerInputSchema`, and
|
||||
SHALL parse every constructor `options` argument through the matching schema; failures
|
||||
SHALL throw `ControllerError(CONTROLLER_CODES.INVALID_OPTIONS, …)` with the Zod issue list
|
||||
in `error.context.issues`.
|
||||
|
||||
#### Scenario: invalid options throw a typed error
|
||||
|
||||
- **WHEN** `new KeyboardController(target, { pollRate: -1 })` is constructed
|
||||
- **THEN** a `ControllerError` is thrown with `code === CONTROLLER_CODES.INVALID_OPTIONS`
|
||||
and `context.issues` is a non-empty array
|
||||
|
||||
**Verified by:** `src/keyboard/keyboard-controller.test.ts::"invalid options throw ControllerError.INVALID_OPTIONS"`
|
||||
67
openspec/archive/2026-05-24-enterprise-controller/tasks.md
Normal file
67
openspec/archive/2026-05-24-enterprise-controller/tasks.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Tasks — enterprise-controller
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [x] 0.1 Confirm clean working tree and worktree-isolation verified (`pwd` is `.claude/worktrees/agent-…`)
|
||||
- [x] 0.2 Worktree branch renamed to `feat/controller`
|
||||
- [x] 0.3 Confirm `STATUS.md` lists `@suaveplan/controller` as `–` (pending migration)
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read upstream source at `../suaveplan/packages/universal/controller/src/`
|
||||
- [x] 1.2 Draft `proposal.md` with Source mapping + deferred Out-of-scope section
|
||||
- [x] 1.3 Draft `specs/controller/spec.md` with 9 requirements + `**Verified by:**` lines
|
||||
- [x] 1.4 Confirm no `openspec/specs/controller/` exists yet (forbidden until archive)
|
||||
|
||||
## 2. Implementation (Priority 1)
|
||||
|
||||
- [ ] 2.1 Scaffold `packages/gaming/games/controller/` with `package.json`, `vite.config.ts`,
|
||||
`vitest.config.ts`, `tsconfig.lib.json`, `README.md`
|
||||
- [ ] 2.2 `src/types/controller.types.ts` + `.md` — `ControllerInput`, `Vector2D`, callback aliases
|
||||
- [ ] 2.3 `src/schemas/options.schemas.ts` + `.md` — Zod schemas for every options bundle
|
||||
- [ ] 2.4 `src/errors/controller-error.ts` + `.md` — `defineCodes` + `ControllerError`
|
||||
- [ ] 2.5 `src/observability/otel.ts` + `.md` — counter + attribute helpers
|
||||
- [ ] 2.6 `src/event-emitter/event-emitter.ts` + `.md` + `.test.ts`
|
||||
- [ ] 2.7 `src/base/base-controller.ts` + `.md` + `.test.ts` — Clock-injected, threshold/deadzone
|
||||
- [ ] 2.8 `src/keyboard/keyboard-controller.ts` + `.md` + `.test.ts`
|
||||
- [ ] 2.9 `src/gamepad/gamepad-controller.ts` + `.md` + `.test.ts`
|
||||
- [ ] 2.10 `src/controller-merger/controller-merger.ts` + `.md` + `.test.ts`
|
||||
- [ ] 2.11 `src/index.ts` — explicit named re-exports per CLAUDE.md rule 10
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 Every requirement's `**Verified by:**` test exists and passes
|
||||
- [ ] 3.2 Errors test enumerates `CONTROLLER_CODES` and asserts each is thrown
|
||||
- [ ] 3.3 OTel test uses real in-memory `MeterProvider` (no `vi.mock`)
|
||||
- [ ] 3.4 Every `it(...)` has `{ timeout: 5000 }` (30000 for async)
|
||||
- [ ] 3.5 All test files import runner primitives from `@suaveplan/testing/runner`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` ≥200 words for every implementation `.ts`
|
||||
- [ ] 4.2 `README.md` links every co-located `.md` under `## Modules`
|
||||
- [ ] 4.3 Update root `CLAUDE.md` to list `@suaveplan/controller` under `### Games (universal-tier)`
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0/0/0 across monorepo
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/controller` green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/controller` 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/controller` exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/controller` green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [ ] 6.1 Generics threaded; no `any`
|
||||
- [ ] 6.2 Zod schemas wired at every constructor `options` boundary
|
||||
- [ ] 6.3 Every `CONTROLLER_CODES` entry thrown by a real path
|
||||
- [ ] 6.4 Strict-mode/destroyed-state behaviour tested both on and off
|
||||
- [ ] 6.5 OTel asserted with in-memory `MeterProvider`
|
||||
- [ ] 6.6 `destroy()` dedup tested
|
||||
- [ ] 6.7 No retroactive ticks
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold `specs/controller/spec.md` into `openspec/specs/controller/spec.md`
|
||||
- [ ] 7.2 Move change folder to `openspec/archive/2026-05-24-enterprise-controller/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
Reference in New Issue
Block a user