Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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

View File

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

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