Add pluggable movement strategies (v1.3.0)
Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:
- src/device.ts: injectable Device seam over nut.js (autoDelayMs lives
here now); the only module that touches the native lib.
- src/strategies.ts: pure per-pattern path generators + registry + lenient
name resolution. Ships line, diagonal, jitter, walk,
arc, figureEight.
- src/executor.ts: single executePath driver owning bounds policy
(abort/clamp/reflect), pacing, interrupt detection, and
restore-on-clean.
keeper.ts's simulateActivity now selects a strategy and delegates to the
executor; the default `line` pattern is byte-for-byte the previous behavior.
New config surface, layered CLI > file > default with strict validation:
- -p/--pattern <name> movement strategy (names matched case/-/_-insensitive)
- -s/--step-size <px> pixels per step; stepCount is now a step *count*
Robustness for the new edge-seeking patterns: interrupt detection compares
against the last commanded (rounded) point with a 2px tolerance, and
clamp/reflect stay a couple pixels off the screen edge, so sub-pixel cursor
placement on scaled/multi-monitor displays isn't misread as user activity.
jitter's radius scales with sweep length so it moves at the default stepSize.
Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* strategies.ts
|
||||
* -------------
|
||||
* The movement-pattern seam: pure generators of cursor targets.
|
||||
*
|
||||
* A `MovementStrategy` describes *where* the cursor should go, as an
|
||||
* iterable of ideal `Point`s starting from the sweep's origin. It performs
|
||||
* no I/O, no timing, and no interrupt handling — that all belongs to the
|
||||
* executor (`executor.ts`). This split is what makes patterns trivial to
|
||||
* add (write one pure generator) and trivial to test (feed a deterministic
|
||||
* `rng`, assert the emitted points).
|
||||
*
|
||||
* Coordinates emitted here may be fractional; the executor rounds to whole
|
||||
* pixels before commanding the cursor and applies the strategy's declared
|
||||
* `BoundsPolicy` to keep everything on-screen.
|
||||
*
|
||||
* `Config` is imported type-only so that `config.ts` can import the value
|
||||
* exports here (the registry, name list, and validator) without creating a
|
||||
* runtime import cycle.
|
||||
*/
|
||||
|
||||
import type { Point } from "./device.ts";
|
||||
import type { Config } from "./config.ts";
|
||||
|
||||
/**
|
||||
* How the executor keeps a strategy's targets on-screen:
|
||||
*
|
||||
* - `abort` — stop the sweep the moment a target falls out of bounds.
|
||||
* Used by `line`, whose direction is chosen so this never
|
||||
* actually fires; preserves the original straight-line
|
||||
* semantics exactly.
|
||||
* - `clamp` — pin each out-of-bounds coordinate to the nearest edge.
|
||||
* - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming
|
||||
* pattern bounces off the screen edges instead of sticking.
|
||||
*/
|
||||
export type BoundsPolicy = "abort" | "clamp" | "reflect";
|
||||
|
||||
/**
|
||||
* Everything a strategy needs to generate a path. Screen dimensions and the
|
||||
* start point are snapshotted per sweep by the caller; `rng` is injected so
|
||||
* stochastic strategies are deterministic under test.
|
||||
*/
|
||||
export interface MoveContext {
|
||||
/** Cursor position at the start of the sweep. */
|
||||
readonly start: Point;
|
||||
/** Primary-screen width in pixels. */
|
||||
readonly width: number;
|
||||
/** Primary-screen height in pixels. */
|
||||
readonly height: number;
|
||||
/** Resolved runtime config (supplies `stepCount`, `stepSize`, ...). */
|
||||
readonly config: Config;
|
||||
/** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */
|
||||
readonly rng: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named movement pattern.
|
||||
*
|
||||
* - `name` — registry key, also the value accepted by `--pattern` / the
|
||||
* `pattern` config key.
|
||||
* - `bounds` — how the executor confines this pattern to the screen.
|
||||
* - `path` — pure generator of ideal (possibly fractional) targets,
|
||||
* emitted in visiting order. Should not re-emit `start`.
|
||||
*/
|
||||
export interface MovementStrategy {
|
||||
readonly name: string;
|
||||
readonly bounds: BoundsPolicy;
|
||||
path(ctx: MoveContext): Iterable<Point>;
|
||||
}
|
||||
|
||||
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
|
||||
function clamp(v: number, max: number): number {
|
||||
if (v < 0) return 0;
|
||||
if (v > max - 1) return max - 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total pixel reach of a sweep: number of steps times pixels per step.
|
||||
* Strategies use this to size themselves relative to the configured sweep
|
||||
* length regardless of `stepSize`.
|
||||
*/
|
||||
function reachOf(config: Config): number {
|
||||
return config.stepCount * config.stepSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* `line` — the original behavior, preserved exactly.
|
||||
*
|
||||
* Pick a horizontal direction that keeps the sweep on-screen (right if
|
||||
* there's room, else left); walk `stepCount` steps of `stepSize` pixels
|
||||
* with no vertical movement. With the default `stepSize` of 1 this emits
|
||||
* the identical integer 1px-per-step path the keeper used before the
|
||||
* strategy refactor, which is why its bounds policy is `abort` (the
|
||||
* direction choice guarantees it never triggers).
|
||||
*/
|
||||
export const line: MovementStrategy = {
|
||||
name: "line",
|
||||
bounds: "abort",
|
||||
*path(ctx: MoveContext): Generator<Point> {
|
||||
const { start, width, config } = ctx;
|
||||
const dx: number = start.x + reachOf(config) < width ? 1 : -1;
|
||||
for (let i = 1; i <= config.stepCount; i++) {
|
||||
yield { x: start.x + i * dx * config.stepSize, y: start.y };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `diagonal` — straight line on both axes at once. Each axis's direction is
|
||||
* chosen independently by available room, so the sweep heads toward the
|
||||
* roomiest corner and stays on-screen.
|
||||
*/
|
||||
export const diagonal: MovementStrategy = {
|
||||
name: "diagonal",
|
||||
bounds: "clamp",
|
||||
*path(ctx: MoveContext): Generator<Point> {
|
||||
const { start, width, height, config } = ctx;
|
||||
const reach: number = reachOf(config);
|
||||
const dx: number = start.x + reach < width ? 1 : -1;
|
||||
const dy: number = start.y + reach < height ? 1 : -1;
|
||||
for (let i = 1; i <= config.stepCount; i++) {
|
||||
yield {
|
||||
x: start.x + i * dx * config.stepSize,
|
||||
y: start.y + i * dy * config.stepSize,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `jitter` — many small random hops within a local radius of the start.
|
||||
* Subtle "fidget" activity rather than a broad sweep. The radius scales off
|
||||
* the sweep length (like the other patterns) so every hop is a real,
|
||||
* distinct pixel move rather than rounding onto the pixel the cursor is
|
||||
* already on. The executor restores the cursor to `start` after a clean
|
||||
* run, so the net displacement is zero.
|
||||
*/
|
||||
export const jitter: MovementStrategy = {
|
||||
name: "jitter",
|
||||
bounds: "clamp",
|
||||
*path(ctx: MoveContext): Generator<Point> {
|
||||
const { start, config, rng } = ctx;
|
||||
const radius: number = Math.max(4, reachOf(config) / 8);
|
||||
for (let i = 1; i <= config.stepCount; i++) {
|
||||
const angle: number = rng() * 2 * Math.PI;
|
||||
const r: number = rng() * radius;
|
||||
yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `walk` — an unbounded cumulative random walk: each step adds a random
|
||||
* per-axis delta in `[-stepSize, +stepSize]`. The generator itself lets the
|
||||
* position drift freely; the executor's `reflect` policy mirrors it back
|
||||
* on-screen, so the cursor bounces off the edges instead of escaping.
|
||||
*/
|
||||
export const walk: MovementStrategy = {
|
||||
name: "walk",
|
||||
bounds: "reflect",
|
||||
*path(ctx: MoveContext): Generator<Point> {
|
||||
const { start, config, rng } = ctx;
|
||||
let x: number = start.x;
|
||||
let y: number = start.y;
|
||||
for (let i = 1; i <= config.stepCount; i++) {
|
||||
x += (rng() * 2 - 1) * config.stepSize;
|
||||
y += (rng() * 2 - 1) * config.stepSize;
|
||||
yield { x, y };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `arc` — a smooth quadratic Bézier curve from the start to a random
|
||||
* on-screen endpoint roughly `reach` pixels away, bowed out by a control
|
||||
* point offset perpendicular to the straight path. Produces natural,
|
||||
* hand-like curved motion.
|
||||
*/
|
||||
export const arc: MovementStrategy = {
|
||||
name: "arc",
|
||||
bounds: "clamp",
|
||||
*path(ctx: MoveContext): Generator<Point> {
|
||||
const { start, width, height, config, rng } = ctx;
|
||||
const reach: number = reachOf(config);
|
||||
|
||||
// Endpoint: a random direction, `reach` away, clamped on-screen.
|
||||
const angle: number = rng() * 2 * Math.PI;
|
||||
const endX: number = clamp(start.x + Math.cos(angle) * reach, width);
|
||||
const endY: number = clamp(start.y + Math.sin(angle) * reach, height);
|
||||
|
||||
// Control point: midpoint pushed along the perpendicular so the path
|
||||
// bows rather than running straight. Direction/magnitude randomized.
|
||||
const midX: number = (start.x + endX) / 2;
|
||||
const midY: number = (start.y + endY) / 2;
|
||||
const perpX: number = -(endY - start.y);
|
||||
const perpY: number = endX - start.x;
|
||||
const perpLen: number = Math.hypot(perpX, perpY) || 1;
|
||||
const bow: number = (rng() * 2 - 1) * reach * 0.5;
|
||||
const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width);
|
||||
const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height);
|
||||
|
||||
for (let i = 1; i <= config.stepCount; i++) {
|
||||
const t: number = i / config.stepCount;
|
||||
const u: number = 1 - t;
|
||||
yield {
|
||||
x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX,
|
||||
y: u * u * start.y + 2 * u * t * ctrlY + t * t * endY,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `figureEight` — traces a Gerono lemniscate (a figure-eight) around the
|
||||
* start point over one full period, so it returns to the origin. Amplitude
|
||||
* scales with `reach`.
|
||||
*/
|
||||
export const figureEight: MovementStrategy = {
|
||||
name: "figureEight",
|
||||
bounds: "clamp",
|
||||
*path(ctx: MoveContext): Generator<Point> {
|
||||
const { start, config } = ctx;
|
||||
const amp: number = reachOf(config) / 2;
|
||||
for (let i = 1; i <= config.stepCount; i++) {
|
||||
const t: number = (2 * Math.PI * i) / config.stepCount;
|
||||
yield {
|
||||
x: start.x + amp * Math.sin(t),
|
||||
y: start.y + amp * Math.sin(t) * Math.cos(t),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The registry of every selectable movement pattern, keyed by name. Adding
|
||||
* a strategy is a one-line addition here plus its definition above.
|
||||
*/
|
||||
export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
|
||||
line,
|
||||
diagonal,
|
||||
jitter,
|
||||
walk,
|
||||
arc,
|
||||
figureEight,
|
||||
};
|
||||
|
||||
/** Pattern used when neither the CLI nor the config file selects one. */
|
||||
export const DEFAULT_PATTERN = "line";
|
||||
|
||||
/** All valid pattern names, for validation messages and help text. */
|
||||
export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES);
|
||||
|
||||
/**
|
||||
* The set of valid `--pattern` / `pattern` values as a string-literal-ish
|
||||
* type. Kept as `string` at the type level (the registry is the runtime
|
||||
* source of truth); `isPatternName` is the guard callers use.
|
||||
*/
|
||||
export type PatternName = string;
|
||||
|
||||
/** True when `name` is an exact, registered strategy key. */
|
||||
export function isPatternName(name: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(STRATEGIES, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pattern name for lenient user-facing matching: lowercase and
|
||||
* strip separators (`-`, `_`, whitespace) so `figure-eight`, `figure_eight`,
|
||||
* and `FIGUREEIGHT` all collapse onto the same key as `figureEight`.
|
||||
*/
|
||||
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
|
||||
|
||||
/**
|
||||
* Map of normalized name -> canonical registry key. Built once at module
|
||||
* load. The assertion below guards against two registered names collapsing
|
||||
* to the same normalized form (e.g. a future `"figure_eight"` alongside
|
||||
* `"figureEight"`), which would otherwise let one silently shadow the other.
|
||||
*/
|
||||
const CANONICAL_PATTERNS: ReadonlyMap<string, string> = new Map(
|
||||
PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
|
||||
);
|
||||
|
||||
if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
|
||||
throw new Error(
|
||||
"strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve loose user input to the canonical registry key, or `null` when no
|
||||
* registered strategy matches. Used at the CLI and config-file validation
|
||||
* boundaries so `Config.pattern` is always a canonical key and the keeper's
|
||||
* direct `STRATEGIES[pattern]` lookup needs no normalization of its own.
|
||||
*/
|
||||
export function resolvePatternName(name: string): string | null {
|
||||
return CANONICAL_PATTERNS.get(normalizePattern(name)) ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user