diff --git a/packages/core/README.md b/packages/core/README.md index 85456d3..a83bd73 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -119,6 +119,11 @@ bridge state, and mapped into XInput/HID trigger bytes for trigger-like buttons. Plain binary presses on trigger-like buttons, such as `press("RT", 0)`, map to a full analog pull. +Timed actions reject negative `durationMs` values. A zero duration remains +persistent where the action API uses zero-duration input, while positive values +request a timed hold. The requested SDK duration is not a guarantee of how long +the target game observes the input or how many frames it spans. + Direct D-pad button presses also keep the structured D-pad state synchronized: ```ts diff --git a/packages/core/src/__tests__/negative-duration-validation.test.ts b/packages/core/src/__tests__/negative-duration-validation.test.ts new file mode 100644 index 0000000..13de5b9 --- /dev/null +++ b/packages/core/src/__tests__/negative-duration-validation.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { createActionMap, createController, DryRunAdapter } from "../index"; + +describe("negative action duration validation", () => { + test("rejects negative durations before adapter output across command paths", async () => { + const adapter = new DryRunAdapter(); + const controller = await createController({ + profile: "xbox", + adapter, + replay: false, + }); + const initialHistoryLength = adapter.history.length; + const initialStateHistoryLength = adapter.stateHistory.length; + const neutral = spyOn(adapter, "neutral"); + + await expect(controller.press("A", -1)).rejects.toThrow( + "must not be negative", + ); + await expect(controller.trigger("RT", 1, -1)).rejects.toThrow( + "must not be negative", + ); + + const negativeCommands = [ + { type: "press", button: "A", durationMs: -1 }, + { type: "stick", stick: "LEFT", x: 1, y: 0, durationMs: -1 }, + { type: "trigger", trigger: "RT", value: 1, durationMs: -1 }, + { type: "dpad", direction: "UP", durationMs: -1 }, + { type: "combo", buttons: ["A", "B"], durationMs: -1 }, + { type: "touchpad", pressed: true, durationMs: -1 }, + { type: "motion", durationMs: -1 }, + ] as const; + + for (const command of negativeCommands) { + await expect(controller.sequence([command])).rejects.toThrow( + "must not be negative", + ); + } + + await expect( + controller.sequence([ + { type: "press", button: "A", durationMs: 0 }, + { + type: "sequence", + commands: [ + { type: "trigger", trigger: "RT", value: 1, durationMs: -1 }, + ], + }, + ]), + ).rejects.toThrow("must not be negative"); + + const actions = createActionMap(controller, { + attack: [{ type: "press", button: "A", durationMs: 50 }], + }); + await expect(actions.run("attack", { durationMs: -1 })).rejects.toThrow( + "must not be negative", + ); + + expect(adapter.history).toHaveLength(initialHistoryLength); + expect(adapter.stateHistory).toHaveLength(initialStateHistoryLength); + expect(neutral).not.toHaveBeenCalled(); + neutral.mockRestore(); + await controller.disconnect(); + }); + + test("keeps zero-duration input persistent and positive durations timed", async () => { + const adapter = new DryRunAdapter(); + const controller = await createController({ + profile: "xbox", + adapter, + replay: false, + }); + + await controller.press("A", 0); + expect(controller.getState().buttons.A).toBe(true); + + await controller.trigger("RT", 0.5, 0); + expect(controller.getState().analogButtons.RT).toBe(0.5); + + await controller.trigger("RT", 1, 1); + expect(controller.getState().analogButtons.RT).toBe(0); + expect(adapter.history.map((entry) => entry.command.type)).toEqual([ + "press", + "trigger", + "trigger", + "trigger", + ]); + + await controller.disconnect(); + }); +}); diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 1042fa4..2c29b8a 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -73,6 +73,7 @@ export class ControllerRuntime { context: CommandContext = {}, signal?: AbortSignal, ): Promise { + this.safety.assertNonNegativeDurations(command); const timedPressSignal = isPositiveTimedPress(command) ? signal : undefined; if (timedPressSignal?.aborted) { throw new TimedPressAbortError({ diff --git a/packages/core/src/safety.ts b/packages/core/src/safety.ts index ceed16c..6f85f3c 100644 --- a/packages/core/src/safety.ts +++ b/packages/core/src/safety.ts @@ -58,12 +58,31 @@ export class SafetyGuard { } assert(command: ControllerCommand): void { + this.assertNonNegativeDurations(command); this.assertRateLimit(); this.assertDurations(command); this.assertDisabledControls(command); this.assertInputLoop(command); } + assertNonNegativeDurations(command: ControllerCommand): void { + if ( + "durationMs" in command && + command.durationMs !== undefined && + command.durationMs < 0 + ) { + throw new SafetyError( + `Action duration ${command.durationMs}ms must not be negative`, + ); + } + + if (command.type === "sequence") { + for (const child of command.commands) { + this.assertNonNegativeDurations(child); + } + } + } + private assertRateLimit(): void { const now = Date.now(); this.commandTimestamps.push(now); @@ -88,7 +107,7 @@ export class SafetyGuard { (command.type === "press" || command.type === "combo" || command.type === "dpad") && - command.durationMs + command.durationMs !== undefined ) { if (command.durationMs > this.config.maxButtonHoldMs) { throw new SafetyError( @@ -101,7 +120,7 @@ export class SafetyGuard { (command.type === "stick" || command.type === "touchpad" || command.type === "motion") && - command.durationMs + command.durationMs !== undefined ) { if (command.durationMs > this.config.maxStickHoldMs) { throw new SafetyError(