From 91277a68458228be058e39630188903936a56fb2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 11 Jul 2026 03:16:42 +0100 Subject: [PATCH] feat(input): define semantic gesture contracts --- docs/input-capabilities.md | 15 ++++ packages/eve-browser-lowering/dist/index.d.ts | 1 + packages/eve-browser-lowering/dist/index.js | 3 +- .../dist/input-gestures.d.ts | 23 ++++++ .../dist/input-gestures.js | 44 ++++++++++++ packages/eve-browser-lowering/src/index.ts | 1 + .../src/input-gestures.ts | 58 +++++++++++++++ .../test/input-gestures.test.mjs | 18 +++++ ...gamecult.eve.input_bindings.v1.schema.json | 16 +++++ ...mecult.eve.input_capability.v1.schema.json | 70 +++++++++++++++++++ 10 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 docs/input-capabilities.md create mode 100644 packages/eve-browser-lowering/dist/input-gestures.d.ts create mode 100644 packages/eve-browser-lowering/dist/input-gestures.js create mode 100644 packages/eve-browser-lowering/src/input-gestures.ts create mode 100644 packages/eve-browser-lowering/test/input-gestures.test.mjs create mode 100644 schemas/gamecult.eve.input_bindings.v1.schema.json create mode 100644 schemas/gamecult.eve.input_capability.v1.schema.json diff --git a/docs/input-capabilities.md b/docs/input-capabilities.md new file mode 100644 index 0000000..5f2fba3 --- /dev/null +++ b/docs/input-capabilities.md @@ -0,0 +1,15 @@ +# Input capabilities + +Providers publish semantic actions and suggested profiles. Clients own physical +device discovery, user bindings, exposed action-bar controls, gesture timing, +and override persistence. + +Every binding maps one gesture to one semantic action. Direct controls, +keyboard chords, axes, and ordered gamepad D-pad sequences are gesture kinds, +not separate execution systems. The action bar is a compact editor over the +same binding document; its controls are chosen by the player. + +Actions may be derived from live state such as equipped modules, weapon groups, +cargo consumables, role, docking state, or mode. A binding whose action is not +currently available remains dormant so it can recover when that semantic action +returns. diff --git a/packages/eve-browser-lowering/dist/index.d.ts b/packages/eve-browser-lowering/dist/index.d.ts index 7fea5b3..951c59d 100644 --- a/packages/eve-browser-lowering/dist/index.d.ts +++ b/packages/eve-browser-lowering/dist/index.d.ts @@ -150,3 +150,4 @@ export declare function createWorldActionIntent(command: string, action: Record< export declare function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body?: HTMLElement): void; export declare function createEveCommandIntent(commandId: string, props?: Record, options?: EveBrowserLoweringOptions): EveCommandIntent; export declare function emptyState(message: string): HTMLElement; +export * from "./input-gestures.js"; diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index ea85e97..041f250 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -231,7 +231,7 @@ export function renderEveComponent(node, options = currentOptions) { resolveAssetUrl, }); } - if (kind === "world.scene3d") { + if (kind === "world.scene3d" || kind === "world.scene2d") { return renderWorldScene(node, props, layout, style, options); } if (kind === "image.background") { @@ -1541,3 +1541,4 @@ function el(tag, className = "", text = "") { element.textContent = text; return element; } +export * from "./input-gestures.js"; diff --git a/packages/eve-browser-lowering/dist/input-gestures.d.ts b/packages/eve-browser-lowering/dist/input-gestures.d.ts new file mode 100644 index 0000000..db6fb8d --- /dev/null +++ b/packages/eve-browser-lowering/dist/input-gestures.d.ts @@ -0,0 +1,23 @@ +export type EveInputGestureKind = "direct" | "chord" | "sequence" | "axis"; +export type EveInputGesture = { + kind: EveInputGestureKind; + controls: string[]; + maxStepIntervalMs?: number; + completionControl?: string; +}; +export type EveInputBinding = { + bindingId: string; + actionId: string; + gesture: EveInputGesture; + actionBar?: boolean; +}; +export declare class EveInputGestureResolver { + private readonly bindings; + private readonly pressed; + private readonly sequences; + constructor(bindings: EveInputBinding[]); + press(control: string, at?: number): string[]; + release(control: string): void; + reset(): void; + private advanceSequence; +} diff --git a/packages/eve-browser-lowering/dist/input-gestures.js b/packages/eve-browser-lowering/dist/input-gestures.js new file mode 100644 index 0000000..43a64ed --- /dev/null +++ b/packages/eve-browser-lowering/dist/input-gestures.js @@ -0,0 +1,44 @@ +export class EveInputGestureResolver { + bindings; + pressed = new Set(); + sequences = new Map(); + constructor(bindings) { + this.bindings = bindings; + } + press(control, at = Date.now()) { + this.pressed.add(control); + const actions = []; + for (const binding of this.bindings) { + const gesture = binding.gesture; + if (gesture.kind === "direct" && gesture.controls[0] === control) + actions.push(binding.actionId); + if (gesture.kind === "chord" && gesture.controls.every(candidate => this.pressed.has(candidate))) + actions.push(binding.actionId); + if (gesture.kind === "sequence" && this.advanceSequence(binding, control, at)) + actions.push(binding.actionId); + } + return [...new Set(actions)]; + } + release(control) { + this.pressed.delete(control); + } + reset() { + this.pressed.clear(); + this.sequences.clear(); + } + advanceSequence(binding, control, at) { + const controls = binding.gesture.controls; + if (controls.length === 0) + return false; + const previous = this.sequences.get(binding.bindingId) || { index: 0, at: 0 }; + const timeout = binding.gesture.maxStepIntervalMs || 650; + const index = previous.index > 0 && at - previous.at <= timeout ? previous.index : 0; + const next = controls[index] === control ? index + 1 : controls[0] === control ? 1 : 0; + if (next === controls.length) { + this.sequences.delete(binding.bindingId); + return !binding.gesture.completionControl; + } + this.sequences.set(binding.bindingId, { index: next, at }); + return false; + } +} diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 7a3d0b2..aa65949 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -1731,3 +1731,4 @@ function el(tag: string, className = "", text = ""): HTMLElement { if (text) element.textContent = text; return element; } +export * from "./input-gestures.js"; diff --git a/packages/eve-browser-lowering/src/input-gestures.ts b/packages/eve-browser-lowering/src/input-gestures.ts new file mode 100644 index 0000000..a6f0f57 --- /dev/null +++ b/packages/eve-browser-lowering/src/input-gestures.ts @@ -0,0 +1,58 @@ +export type EveInputGestureKind = "direct" | "chord" | "sequence" | "axis"; + +export type EveInputGesture = { + kind: EveInputGestureKind; + controls: string[]; + maxStepIntervalMs?: number; + completionControl?: string; +}; + +export type EveInputBinding = { + bindingId: string; + actionId: string; + gesture: EveInputGesture; + actionBar?: boolean; +}; + +export class EveInputGestureResolver { + private readonly pressed = new Set(); + private readonly sequences = new Map(); + + constructor(private readonly bindings: EveInputBinding[]) {} + + press(control: string, at = Date.now()): string[] { + this.pressed.add(control); + const actions: string[] = []; + for (const binding of this.bindings) { + const gesture = binding.gesture; + if (gesture.kind === "direct" && gesture.controls[0] === control) actions.push(binding.actionId); + if (gesture.kind === "chord" && gesture.controls.every(candidate => this.pressed.has(candidate))) actions.push(binding.actionId); + if (gesture.kind === "sequence" && this.advanceSequence(binding, control, at)) actions.push(binding.actionId); + } + return [...new Set(actions)]; + } + + release(control: string): void { + this.pressed.delete(control); + } + + reset(): void { + this.pressed.clear(); + this.sequences.clear(); + } + + private advanceSequence(binding: EveInputBinding, control: string, at: number): boolean { + const controls = binding.gesture.controls; + if (controls.length === 0) return false; + const previous = this.sequences.get(binding.bindingId) || { index: 0, at: 0 }; + const timeout = binding.gesture.maxStepIntervalMs || 650; + const index = previous.index > 0 && at - previous.at <= timeout ? previous.index : 0; + const next = controls[index] === control ? index + 1 : controls[0] === control ? 1 : 0; + if (next === controls.length) { + this.sequences.delete(binding.bindingId); + return !binding.gesture.completionControl; + } + this.sequences.set(binding.bindingId, { index: next, at }); + return false; + } +} diff --git a/packages/eve-browser-lowering/test/input-gestures.test.mjs b/packages/eve-browser-lowering/test/input-gestures.test.mjs new file mode 100644 index 0000000..b262b55 --- /dev/null +++ b/packages/eve-browser-lowering/test/input-gestures.test.mjs @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EveInputGestureResolver } from "../dist/input-gestures.js"; + +test("resolves keyboard chords", () => { + const resolver = new EveInputGestureResolver([{ bindingId: "boost", actionId: "drive.boost", gesture: { kind: "chord", controls: ["keyboard.shift", "keyboard.w"] } }]); + assert.deepEqual(resolver.press("keyboard.shift", 0), []); + assert.deepEqual(resolver.press("keyboard.w", 1), ["drive.boost"]); +}); + +test("resolves ordered d-pad strings and resets wrong input", () => { + const resolver = new EveInputGestureResolver([{ bindingId: "shield", actionId: "shield.overcharge", gesture: { kind: "sequence", controls: ["gamepad.dpad.up", "gamepad.dpad.right", "gamepad.dpad.down"], maxStepIntervalMs: 500 } }]); + assert.deepEqual(resolver.press("gamepad.dpad.up", 0), []); + assert.deepEqual(resolver.press("gamepad.dpad.left", 100), []); + assert.deepEqual(resolver.press("gamepad.dpad.up", 200), []); + assert.deepEqual(resolver.press("gamepad.dpad.right", 300), []); + assert.deepEqual(resolver.press("gamepad.dpad.down", 400), ["shield.overcharge"]); +}); diff --git a/schemas/gamecult.eve.input_bindings.v1.schema.json b/schemas/gamecult.eve.input_bindings.v1.schema.json new file mode 100644 index 0000000..c8ab1dd --- /dev/null +++ b/schemas/gamecult.eve.input_bindings.v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.input_bindings.v1", + "title": "Eve User Input Bindings", + "type": "object", + "required": ["schema", "providerId", "capabilityId", "bindings", "actionBarControls"], + "properties": { + "schema": { "const": "gamecult.eve.input_bindings.v1" }, + "providerId": { "type": "string", "minLength": 1 }, + "capabilityId": { "type": "string", "minLength": 1 }, + "bindings": { "type": "array", "items": { "$ref": "gamecult.eve.input_capability.v1#/$defs/binding" } }, + "actionBarControls": { "type": "array", "items": { "$ref": "gamecult.eve.input_capability.v1#/$defs/gesture" } }, + "updatedAtUtc": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/schemas/gamecult.eve.input_capability.v1.schema.json b/schemas/gamecult.eve.input_capability.v1.schema.json new file mode 100644 index 0000000..bfcc237 --- /dev/null +++ b/schemas/gamecult.eve.input_capability.v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.input_capability.v1", + "title": "Eve Input Capability", + "type": "object", + "required": ["schema", "providerId", "capabilityId", "actions", "defaultProfiles"], + "properties": { + "schema": { "const": "gamecult.eve.input_capability.v1" }, + "providerId": { "type": "string", "minLength": 1 }, + "capabilityId": { "type": "string", "minLength": 1 }, + "version": { "type": "integer", "minimum": 0 }, + "actions": { + "type": "array", + "items": { + "type": "object", + "required": ["actionId", "label", "operation", "availability"], + "properties": { + "actionId": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "operation": { "type": "string", "minLength": 1 }, + "context": { "type": "string" }, + "category": { "type": "string" }, + "iconRef": { "type": "string" }, + "availability": { "enum": ["available", "dormant", "unavailable"] }, + "sourceRef": { "type": "string" }, + "payload": { "type": "object", "additionalProperties": { "type": "string" } } + }, + "additionalProperties": false + } + }, + "defaultProfiles": { + "type": "array", + "items": { + "type": "object", + "required": ["profileId", "deviceClass", "bindings"], + "properties": { + "profileId": { "type": "string", "minLength": 1 }, + "deviceClass": { "enum": ["keyboard-mouse", "gamepad"] }, + "bindings": { "type": "array", "items": { "$ref": "#/$defs/binding" } } + }, + "additionalProperties": false + } + } + }, + "$defs": { + "gesture": { + "type": "object", + "required": ["kind", "controls"], + "properties": { + "kind": { "enum": ["direct", "chord", "sequence", "axis"] }, + "controls": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "maxStepIntervalMs": { "type": "integer", "minimum": 50 }, + "completionControl": { "type": "string" } + }, + "additionalProperties": false + }, + "binding": { + "type": "object", + "required": ["bindingId", "actionId", "gesture"], + "properties": { + "bindingId": { "type": "string", "minLength": 1 }, + "actionId": { "type": "string", "minLength": 1 }, + "gesture": { "$ref": "#/$defs/gesture" }, + "actionBar": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +}