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:
+31
-3
@@ -16,7 +16,9 @@
|
||||
* -m, --move-interval Idle time (seconds) before a sweep fires.
|
||||
* -c, --check-interval Cursor poll cadence (seconds).
|
||||
* -d, --step-delay Pause between synthetic steps (ms).
|
||||
* -n, --step-count Steps per sweep (pixels).
|
||||
* -n, --step-count Steps per sweep (count).
|
||||
* -s, --step-size Pixels moved per step.
|
||||
* -p, --pattern Movement strategy name (see strategies.ts).
|
||||
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
|
||||
* (`-V` capital because `-v` is `--version`.)
|
||||
*
|
||||
@@ -31,6 +33,7 @@ import { parseArgs } from "node:util";
|
||||
|
||||
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
|
||||
import { CliError } from "./errors.ts";
|
||||
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||
|
||||
/**
|
||||
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user
|
||||
@@ -45,7 +48,10 @@ export interface ParsedCliArgs {
|
||||
moveInterval: number | undefined; // seconds
|
||||
checkInterval: number | undefined; // seconds
|
||||
stepDelay: number | undefined; // milliseconds
|
||||
stepCount: number | undefined; // pixels
|
||||
stepCount: number | undefined; // count
|
||||
stepSize: number | undefined; // pixels
|
||||
/** Movement strategy name, validated against the registry. */
|
||||
pattern: string | undefined;
|
||||
/**
|
||||
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
|
||||
* `undefined` (not `false`) lets the layered resolver distinguish "user
|
||||
@@ -69,6 +75,20 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a CLI-supplied movement-pattern name. Returns `undefined` when
|
||||
* the flag was not supplied; throws `CliError` naming the valid patterns
|
||||
* when the value isn't a registered strategy.
|
||||
*/
|
||||
function parsePatternName(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const canonical: string | null = resolvePatternName(raw);
|
||||
if (canonical === null) {
|
||||
throw new CliError(`invalid value for --pattern: '${raw}' (valid: ${PATTERN_NAMES.join(", ")})`);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
|
||||
* `parseArgs` in strict mode so unknown flags and missing values surface
|
||||
@@ -88,6 +108,8 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
"check-interval": { type: "string", short: "c" },
|
||||
"step-delay": { type: "string", short: "d" },
|
||||
"step-count": { type: "string", short: "n" },
|
||||
"step-size": { type: "string", short: "s" },
|
||||
pattern: { type: "string", short: "p" },
|
||||
verbose: { type: "boolean", short: "V" },
|
||||
},
|
||||
strict: true,
|
||||
@@ -110,6 +132,8 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
|
||||
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
||||
stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined),
|
||||
stepSize: parsePositiveNumber("step-size", values["step-size"] as string | undefined),
|
||||
pattern: parsePatternName(values.pattern as string | undefined),
|
||||
verbose: values.verbose === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
@@ -152,7 +176,10 @@ Options:
|
||||
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
|
||||
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
|
||||
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
|
||||
-n, --step-count <pixels> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
|
||||
-n, --step-count <count> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}.
|
||||
-s, --step-size <pixels> Pixels moved per step. Default: ${DEFAULT_CONFIG.stepSize}.
|
||||
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
|
||||
One of: ${PATTERN_NAMES.join(", ")}.
|
||||
-V, --verbose Log every sweep, interrupt, and bounds event
|
||||
(default prints only the startup banner).
|
||||
|
||||
@@ -162,6 +189,7 @@ Examples:
|
||||
move
|
||||
move --move-interval 180 --check-interval 5
|
||||
move -m 300 -V
|
||||
move --pattern arc --step-size 3
|
||||
move --config ~/myprofile.json
|
||||
`);
|
||||
}
|
||||
|
||||
+27
-6
@@ -22,6 +22,7 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { CliError } from "./errors.ts";
|
||||
import { isPatternName, type PatternName } from "./strategies.ts";
|
||||
|
||||
// Single source of truth for default values. The same file ships in the
|
||||
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
|
||||
@@ -41,7 +42,12 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
||||
* - `stepDelay` — pause between individual synthetic mouse steps inside
|
||||
* a sweep. Also the window in which the user can
|
||||
* "interrupt" by moving the cursor. Milliseconds.
|
||||
* - `stepCount` — number of pixel-steps in a single sweep. Pixels.
|
||||
* - `stepCount` — number of steps in a single sweep. Count.
|
||||
* - `stepSize` — pixels moved per step. Decouples "how many steps"
|
||||
* from "how far each step travels" so non-linear
|
||||
* patterns can span meaningful distances. Pixels.
|
||||
* - `pattern` — name of the movement strategy to use (see
|
||||
* `strategies.ts`; e.g. `line`, `walk`, `arc`).
|
||||
* - `verbose` — whether per-sweep / interrupt / bounds events are
|
||||
* logged. The startup banner is always printed.
|
||||
*/
|
||||
@@ -50,6 +56,8 @@ export interface Config {
|
||||
readonly checkInterval: number;
|
||||
readonly stepDelay: number;
|
||||
readonly stepCount: number;
|
||||
readonly stepSize: number;
|
||||
readonly pattern: PatternName;
|
||||
readonly verbose: boolean;
|
||||
}
|
||||
|
||||
@@ -63,7 +71,9 @@ interface SeedShape {
|
||||
moveInterval: number; // seconds
|
||||
checkInterval: number; // seconds
|
||||
stepDelay: number; // milliseconds
|
||||
stepCount: number; // pixels
|
||||
stepCount: number; // count
|
||||
stepSize: number; // pixels
|
||||
pattern: string; // strategy name
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
@@ -72,12 +82,15 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
||||
throw new Error("scripts/config.default.json: root must be an object");
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
for (const key of ["moveInterval", "checkInterval", "stepDelay", "stepCount"] as const) {
|
||||
for (const key of ["moveInterval", "checkInterval", "stepDelay", "stepCount", "stepSize"] as const) {
|
||||
const v = r[key];
|
||||
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
|
||||
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
|
||||
}
|
||||
}
|
||||
if (typeof r.pattern !== "string" || !isPatternName(r.pattern)) {
|
||||
throw new Error(`scripts/config.default.json: 'pattern' must be a known strategy name (got ${JSON.stringify(r.pattern)})`);
|
||||
}
|
||||
if (typeof r.verbose !== "boolean") {
|
||||
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
|
||||
}
|
||||
@@ -98,6 +111,8 @@ export const DEFAULT_CONFIG: Config = {
|
||||
checkInterval: seed.checkInterval * 1000,
|
||||
stepDelay: seed.stepDelay,
|
||||
stepCount: seed.stepCount,
|
||||
stepSize: seed.stepSize,
|
||||
pattern: seed.pattern,
|
||||
verbose: seed.verbose,
|
||||
};
|
||||
|
||||
@@ -110,10 +125,12 @@ export const DEFAULT_CONFIG: Config = {
|
||||
* Numeric fields are in CLI / config-file units:
|
||||
* moveInterval, checkInterval — seconds
|
||||
* stepDelay — milliseconds
|
||||
* stepCount — pixels
|
||||
* stepCount — count
|
||||
* stepSize — pixels
|
||||
*
|
||||
* `verbose` is `boolean | undefined` like the numeric fields, so all five
|
||||
* fields share the same "first defined value wins" precedence logic.
|
||||
* `pattern` is a strategy name (`string | undefined`) and `verbose` is
|
||||
* `boolean | undefined`, so every field shares the same "first defined
|
||||
* value wins" precedence logic.
|
||||
*
|
||||
* For the CLI specifically, `verbose` is `undefined` when `-V/--verbose`
|
||||
* was not passed and `true` when it was. There is no CLI off-switch
|
||||
@@ -126,6 +143,8 @@ export interface ConfigOverrides {
|
||||
readonly checkInterval: number | undefined;
|
||||
readonly stepDelay: number | undefined;
|
||||
readonly stepCount: number | undefined;
|
||||
readonly stepSize: number | undefined;
|
||||
readonly pattern: string | undefined;
|
||||
readonly verbose: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -192,6 +211,8 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
|
||||
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
|
||||
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
||||
stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount),
|
||||
stepSize: pickRaw(cli.stepSize, file?.stepSize, DEFAULT_CONFIG.stepSize),
|
||||
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
|
||||
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
||||
};
|
||||
}
|
||||
|
||||
+24
-1
@@ -10,7 +10,9 @@
|
||||
* moveInterval number seconds, positive
|
||||
* checkInterval number seconds, positive
|
||||
* stepDelay number milliseconds, positive
|
||||
* stepCount number pixels, positive
|
||||
* stepCount number count, positive
|
||||
* stepSize number pixels, positive
|
||||
* pattern string a registered strategy name
|
||||
* verbose boolean
|
||||
*
|
||||
* Unknown keys, wrong types, and non-positive numerics are rejected with a
|
||||
@@ -29,12 +31,15 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
|
||||
import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
|
||||
import { CliError } from "./errors.ts";
|
||||
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
|
||||
|
||||
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
"moveInterval",
|
||||
"checkInterval",
|
||||
"stepDelay",
|
||||
"stepCount",
|
||||
"stepSize",
|
||||
"pattern",
|
||||
"verbose",
|
||||
]);
|
||||
|
||||
@@ -60,6 +65,16 @@ function requireBoolean(name: string, raw: unknown, path: string): boolean {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function requirePatternName(name: string, raw: unknown, path: string): string {
|
||||
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
|
||||
if (canonical === null) {
|
||||
throw new CliError(
|
||||
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${PATTERN_NAMES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate the config file. See module docstring for return
|
||||
* semantics.
|
||||
@@ -130,6 +145,14 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
|
||||
"stepCount" in parsed
|
||||
? requirePositiveNumber("stepCount", parsed.stepCount, path)
|
||||
: undefined,
|
||||
stepSize:
|
||||
"stepSize" in parsed
|
||||
? requirePositiveNumber("stepSize", parsed.stepSize, path)
|
||||
: undefined,
|
||||
pattern:
|
||||
"pattern" in parsed
|
||||
? requirePatternName("pattern", parsed.pattern, path)
|
||||
: undefined,
|
||||
verbose:
|
||||
"verbose" in parsed
|
||||
? requireBoolean("verbose", parsed.verbose, path)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* device.ts
|
||||
* ---------
|
||||
* The I/O seam between the movement machinery and the outside world.
|
||||
*
|
||||
* Everything that actually touches `@nut-tree-fork/nut-js` lives here and
|
||||
* nowhere else. The strategies (`strategies.ts`) and the execution driver
|
||||
* (`executor.ts`) are written against the `Device` interface, which makes
|
||||
* them pure and unit-testable without the nut.js native binary or a real
|
||||
* screen — a fake `Device` is enough.
|
||||
*
|
||||
* `Point` is deliberately a plain `{ x, y }` structure rather than nut.js's
|
||||
* `Point` class, so no module outside this one has to import nut.js just to
|
||||
* describe a coordinate. `createNutDevice` converts to nut.js's `Point`
|
||||
* when it commands the cursor.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A screen coordinate in pixels. Plain data (not nut.js's `Point` class) so
|
||||
* strategies, the executor, and tests never need a nut.js import.
|
||||
*/
|
||||
export interface Point {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The capabilities the movement machinery needs from the host system:
|
||||
* read/write the cursor, learn the screen size, and wait.
|
||||
*
|
||||
* The production implementation (`createNutDevice`) is backed by nut.js;
|
||||
* tests substitute a fake that records calls and returns scripted values.
|
||||
*/
|
||||
export interface Device {
|
||||
/** Current cursor position. */
|
||||
getPosition(): Promise<Point>;
|
||||
/** Move the cursor to `p`. */
|
||||
setPosition(p: Point): Promise<void>;
|
||||
/** Current primary-screen width in pixels. */
|
||||
width(): Promise<number>;
|
||||
/** Current primary-screen height in pixels. */
|
||||
height(): Promise<number>;
|
||||
/** Resolve after `ms` milliseconds. */
|
||||
sleep(ms: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise-based `setTimeout`. Shared default sleep used by the nut.js
|
||||
* device and available for reuse.
|
||||
*
|
||||
* @param ms - Duration to wait, in milliseconds.
|
||||
*/
|
||||
export const sleep = (ms: number): Promise<void> =>
|
||||
new Promise<void>((resolve: () => void): void => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
/**
|
||||
* Build the production `Device` backed by nut.js.
|
||||
*
|
||||
* Importing nut.js dlopens a sizeable native `.node` binary, so this is a
|
||||
* function (not a module-level singleton): callers that never move the
|
||||
* mouse (`--help`, `--version`) never pay for it, and `move.ts` already
|
||||
* defers the whole `keeper.ts` import behind those short-circuits.
|
||||
*
|
||||
* Side effect: sets `mouse.config.autoDelayMs = 0`. nut.js otherwise
|
||||
* inserts a 100ms delay after every action, which — with two cursor calls
|
||||
* per step — would silently more-than-double every sweep. We drive cadence
|
||||
* ourselves via `stepDelay`, so the implicit delay is disabled here, at the
|
||||
* single point where nut.js is actually wired up.
|
||||
*/
|
||||
export async function createNutDevice(): Promise<Device> {
|
||||
const { mouse, Point: NutPoint, screen } = await import("@nut-tree-fork/nut-js");
|
||||
|
||||
mouse.config.autoDelayMs = 0;
|
||||
|
||||
return {
|
||||
getPosition: async (): Promise<Point> => {
|
||||
const p = await mouse.getPosition();
|
||||
return { x: p.x, y: p.y };
|
||||
},
|
||||
setPosition: async (p: Point): Promise<void> => {
|
||||
await mouse.setPosition(new NutPoint(p.x, p.y));
|
||||
},
|
||||
width: (): Promise<number> => screen.width(),
|
||||
height: (): Promise<number> => screen.height(),
|
||||
sleep,
|
||||
};
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* editor.test.ts
|
||||
* --------------
|
||||
* Unit tests for the `--edit` helper. The spawn path is not exercised
|
||||
* (would actually launch $EDITOR); instead we test:
|
||||
* - the pure argv-construction helper, and
|
||||
* - the two refusal paths ($EDITOR unset, file missing).
|
||||
*
|
||||
* Run via `bun test`.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { editConfig, editorCommand } from "./editor.ts";
|
||||
import { CliError } from "./errors.ts";
|
||||
|
||||
describe("editorCommand", () => {
|
||||
test("builds 'sh -c <editor> \"$@\"' argv with -- placeholder and path", () => {
|
||||
const argv = editorCommand("vim", "/tmp/x.json");
|
||||
expect(argv).toEqual(["sh", "-c", 'vim "$@"', "--", "/tmp/x.json"]);
|
||||
});
|
||||
|
||||
test("interpolates the editor verbatim so shell word-splits multi-word values", () => {
|
||||
const argv = editorCommand("code --wait", "/path with space.json");
|
||||
expect(argv).toEqual([
|
||||
"sh",
|
||||
"-c",
|
||||
'code --wait "$@"',
|
||||
"--",
|
||||
"/path with space.json",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editConfig", () => {
|
||||
let TMP: string;
|
||||
let savedEditor: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
TMP = mkdtempSync(join(tmpdir(), "move-edit-test-"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
savedEditor = process.env.EDITOR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEditor === undefined) delete process.env.EDITOR;
|
||||
else process.env.EDITOR = savedEditor;
|
||||
});
|
||||
|
||||
test("throws CliError when $EDITOR is unset", () => {
|
||||
delete process.env.EDITOR;
|
||||
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
|
||||
});
|
||||
|
||||
test("throws CliError when $EDITOR is empty", () => {
|
||||
process.env.EDITOR = "";
|
||||
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
|
||||
});
|
||||
|
||||
test("throws CliError when the config file does not exist", () => {
|
||||
// Use a benign editor command that we never actually reach (the
|
||||
// existence check fires first).
|
||||
process.env.EDITOR = "true";
|
||||
const missing = join(TMP, "no-such-file.json");
|
||||
expect(() => editConfig(missing)).toThrow(/no config file at/);
|
||||
});
|
||||
|
||||
test("error message names the missing path", () => {
|
||||
process.env.EDITOR = "true";
|
||||
const missing = join(TMP, "missing.json");
|
||||
expect(() => editConfig(missing)).toThrow(new RegExp(missing.replace(/[.]/g, "\\.")));
|
||||
});
|
||||
|
||||
test("error message mentions 'reinstall' as a recovery hint", () => {
|
||||
process.env.EDITOR = "true";
|
||||
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/reinstall/);
|
||||
});
|
||||
|
||||
test("$EDITOR unset error explicitly mentions setting it", () => {
|
||||
delete process.env.EDITOR;
|
||||
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/export EDITOR/);
|
||||
});
|
||||
|
||||
// Success path: $EDITOR set, file exists. The editor IS spawned and we
|
||||
// then call process.exit() — which kills the test process. So we don't
|
||||
// exercise this code path in unit tests; the manual smoke test in
|
||||
// dev-setup verifies end-to-end behavior instead.
|
||||
test("placeholder: success path is verified via manual `EDITOR=true move -e` run", () => {
|
||||
// Intentionally empty assertion. See comment above.
|
||||
expect(true).toBe(true);
|
||||
// Ensure the fixture path is referenced so this test isn't seen
|
||||
// as truly empty if the fixture system ever needs assertion.
|
||||
writeFileSync(join(TMP, "exists.json"), "{}");
|
||||
});
|
||||
});
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* executor.ts
|
||||
* -----------
|
||||
* The single execution driver shared by every movement strategy.
|
||||
*
|
||||
* A strategy (`strategies.ts`) says *where* to go; this module owns
|
||||
* *everything else* about carrying a sweep out against a `Device`:
|
||||
*
|
||||
* - round each ideal target to whole pixels,
|
||||
* - keep it on-screen per the strategy's `BoundsPolicy`,
|
||||
* - command the cursor and pace it with `stepDelay`,
|
||||
* - detect real-user interruption after each step,
|
||||
* - restore the cursor to the origin on a clean run.
|
||||
*
|
||||
* Writing this once means new patterns inherit correct real-user-wins,
|
||||
* bounds, and restore semantics for free. It's pure with respect to I/O —
|
||||
* all side effects go through the injected `Device`, so it's unit-testable
|
||||
* with a fake.
|
||||
*
|
||||
* Interrupt detection compares the re-read cursor against the *last
|
||||
* commanded (rounded) point*, never the strategy's ideal (possibly
|
||||
* fractional) target. That's what lets curved/stochastic patterns work
|
||||
* without every rounded step being misread as "the user moved the mouse".
|
||||
*/
|
||||
|
||||
import type { Device, Point } from "./device.ts";
|
||||
import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts";
|
||||
|
||||
/**
|
||||
* Minimal log surface used by the executor and the keeper loop.
|
||||
*
|
||||
* - `info(msg)` prints unconditionally (startup banner, fatal notes).
|
||||
* - `event(msg)` prints only under `--verbose` / `verbose: true`.
|
||||
*/
|
||||
export interface Logger {
|
||||
info(msg: string): void;
|
||||
event(msg: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a sweep ended:
|
||||
* - `completed` — full path ran and the cursor was restored to start.
|
||||
* - `interrupted` — real user activity detected mid-sweep; aborted without
|
||||
* snapping back.
|
||||
* - `aborted` — an `abort`-policy target went out of bounds.
|
||||
*/
|
||||
export type SweepOutcome = "completed" | "interrupted" | "aborted";
|
||||
|
||||
/**
|
||||
* Slack, in pixels, allowed between the coordinate we commanded and the one
|
||||
* we read back before calling it real-user activity. Absorbs the sub-pixel
|
||||
* placement error the OS can introduce on scaled or multi-monitor setups; a
|
||||
* genuine user movement is far larger than this.
|
||||
*/
|
||||
const READBACK_TOLERANCE: number = 2;
|
||||
|
||||
/**
|
||||
* Pixels to inset the `clamp` / `reflect` travel range from each screen edge.
|
||||
* Keeps edge-seeking patterns off the literal first/last pixel, where DPI
|
||||
* scaling and multi-monitor boundaries most often make the OS place the
|
||||
* cursor a hair off what we commanded (which the readback check would then
|
||||
* misread as the user). `abort` (used by `line`) is deliberately left on the
|
||||
* full `[0, max - 1]` range, so its behavior is unchanged.
|
||||
*/
|
||||
const EDGE_MARGIN: number = 2;
|
||||
|
||||
/**
|
||||
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel
|
||||
* under the `clamp` / `reflect` policies: `[0, max - 1]` inset by
|
||||
* `EDGE_MARGIN` on each side. Screens too small to inset fall back to the
|
||||
* full range so the math never inverts.
|
||||
*/
|
||||
function travelRange(max: number): { lo: number; hi: number } {
|
||||
const hiEdge: number = max - 1;
|
||||
if (hiEdge - 2 * EDGE_MARGIN < 1) return { lo: 0, hi: Math.max(0, hiEdge) };
|
||||
return { lo: EDGE_MARGIN, hi: hiEdge - EDGE_MARGIN };
|
||||
}
|
||||
|
||||
/** Round to whole pixels and clamp into the inset travel range for `max`. */
|
||||
function clampInt(v: number, max: number): number {
|
||||
const { lo, hi } = travelRange(max);
|
||||
const r: number = Math.round(v);
|
||||
if (r < lo) return lo;
|
||||
if (r > hi) return hi;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
|
||||
* values past an edge bounce back inside instead of clamping flat against it.
|
||||
*/
|
||||
function reflectInt(v: number, max: number): number {
|
||||
const { lo, hi } = travelRange(max);
|
||||
const span: number = hi - lo;
|
||||
if (span <= 0) return lo;
|
||||
const period: number = 2 * span;
|
||||
const m: number = (((Math.round(v) - lo) % period) + period) % period;
|
||||
return lo + (m <= span ? m : period - m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a strategy's ideal target to an on-screen integer pixel under the
|
||||
* given policy. Returns `null` when policy is `abort` and the (rounded)
|
||||
* target lies outside the screen — the signal to stop the sweep.
|
||||
*/
|
||||
function resolveTarget(
|
||||
policy: BoundsPolicy,
|
||||
p: Point,
|
||||
width: number,
|
||||
height: number,
|
||||
): Point | null {
|
||||
if (policy === "reflect") {
|
||||
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
|
||||
}
|
||||
if (policy === "clamp") {
|
||||
return { x: clampInt(p.x, width), y: clampInt(p.y, height) };
|
||||
}
|
||||
// abort: round, then reject anything off-screen.
|
||||
const x: number = Math.round(p.x);
|
||||
const y: number = Math.round(p.y);
|
||||
if (x < 0 || x >= width || y < 0 || y >= height) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the current local time as `HH:MM:SS` for log lines.
|
||||
*/
|
||||
function timestamp(): string {
|
||||
const d: Date = new Date();
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one sweep: drive `strategy.path(ctx)` to completion (or early exit)
|
||||
* against `device`.
|
||||
*
|
||||
* Contract, per step:
|
||||
* 1. Resolve the ideal target to an on-screen integer (bounds policy).
|
||||
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`).
|
||||
* 2. Command the cursor there and sleep `stepDelay` — also the user's
|
||||
* interrupt window.
|
||||
* 3. Re-read the cursor. If it isn't at the point we just commanded, the
|
||||
* user moved it: return `interrupted` without restoring.
|
||||
*
|
||||
* On a clean run the cursor is restored to `ctx.start` so the next
|
||||
* idle-check sees no net movement, and `completed` is returned.
|
||||
*/
|
||||
export async function executePath(
|
||||
strategy: MovementStrategy,
|
||||
ctx: MoveContext,
|
||||
device: Device,
|
||||
log: Logger,
|
||||
): Promise<SweepOutcome> {
|
||||
const { start, width, height, config } = ctx;
|
||||
|
||||
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
|
||||
|
||||
for (const target of strategy.path(ctx)) {
|
||||
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
|
||||
if (point === null) {
|
||||
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
||||
return "aborted";
|
||||
}
|
||||
|
||||
await device.setPosition(point);
|
||||
await device.sleep(config.stepDelay);
|
||||
|
||||
const current: Point = await device.getPosition();
|
||||
if (
|
||||
Math.abs(current.x - point.x) > READBACK_TOLERANCE ||
|
||||
Math.abs(current.y - point.y) > READBACK_TOLERANCE
|
||||
) {
|
||||
// Cursor isn't where we last put it -> real user activity. Abort
|
||||
// without snapping back, so we don't yank it from under the user.
|
||||
//
|
||||
// The comparison allows a small tolerance rather than demanding an
|
||||
// exact match: on scaled (fractional-DPI) or multi-monitor setups
|
||||
// the OS can place the cursor a pixel off the coordinate we
|
||||
// commanded, and the edge-seeking patterns (clamp/reflect/arc)
|
||||
// reach exactly the coordinates where that's most likely. A real
|
||||
// user moves far more than a couple of pixels, so this doesn't
|
||||
// meaningfully weaken real-user-wins.
|
||||
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
|
||||
return "interrupted";
|
||||
}
|
||||
}
|
||||
|
||||
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
||||
log.event("Mouse moved.");
|
||||
return "completed";
|
||||
}
|
||||
+56
-120
@@ -1,63 +1,38 @@
|
||||
/**
|
||||
* keeper.ts
|
||||
* ---------
|
||||
* The actual "Teams Status Keeper" behavior: synthetic mouse activity with
|
||||
* real-user-wins semantics, plus the idle-watch loop that drives it.
|
||||
* The "Teams Status Keeper" behavior: the idle-watch loop plus the
|
||||
* per-sweep glue that ties a movement strategy to the execution driver.
|
||||
*
|
||||
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse +
|
||||
* screen). The nut.js auto-delay is disabled inside `runKeeper`, not at
|
||||
* module load, so importing this module is side-effect-free.
|
||||
* The mechanics are split across three seams so this file stays small and
|
||||
* the interesting parts stay testable:
|
||||
* - `device.ts` — the nut.js I/O boundary (injected here).
|
||||
* - `strategies.ts` — pure "where to move" pattern generators.
|
||||
* - `executor.ts` — the "how to move" driver (bounds, timing,
|
||||
* interrupt detection, restore).
|
||||
*
|
||||
* `runKeeper` takes an optional `Device` so tests can drive the loop with a
|
||||
* fake; production supplies the nut.js device. Importing this module is
|
||||
* side-effect-free: nut.js isn't touched until `createNutDevice()` runs.
|
||||
*
|
||||
* Logging policy:
|
||||
* - The startup banner in `runKeeper` is unconditional so the user always
|
||||
* sees confirmation that the process is alive.
|
||||
* - Every per-sweep / interrupt / bounds log is gated by `config.verbose`
|
||||
* so the default is quiet. Errors stay on `console.error` (unconditional,
|
||||
* raised by the entry point on unhandled rejection).
|
||||
* sees the process is alive.
|
||||
* - Per-sweep / interrupt / bounds lines are gated by `config.verbose`
|
||||
* (see `makeLogger`). Errors stay on `console.error`, raised by the
|
||||
* entry point on unhandled rejection.
|
||||
*/
|
||||
|
||||
import { mouse, Point, screen } from "@nut-tree-fork/nut-js";
|
||||
import { createNutDevice, type Device, type Point } from "./device.ts";
|
||||
import { executePath, type Logger } from "./executor.ts";
|
||||
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
|
||||
|
||||
import type { Config } from "./config.ts";
|
||||
|
||||
/**
|
||||
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics.
|
||||
*
|
||||
* @param ms - Duration to wait, in milliseconds.
|
||||
*/
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise<void>((resolve: () => void): void => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
/**
|
||||
* Format the current local time as `HH:MM:SS` (24-hour, zero-padded).
|
||||
* Used for human-readable log lines. Date is intentionally omitted.
|
||||
*/
|
||||
const timestamp = (): string => {
|
||||
const d: Date = new Date();
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal log surface used by `simulateActivity` and `runKeeper`. Named so
|
||||
* it can appear directly in function signatures (clearer than
|
||||
* `ReturnType<typeof makeLogger>`) and so a test could substitute a fake
|
||||
* implementation if needed.
|
||||
*
|
||||
* - `info(msg)` prints unconditionally.
|
||||
* - `event(msg)` prints only when `--verbose` / `verbose: true` is set.
|
||||
*/
|
||||
interface Logger {
|
||||
info(msg: string): void;
|
||||
event(msg: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only
|
||||
* fires when the caller asked for verbose output. Returning a small object
|
||||
* keeps `simulateActivity` free of `if (verbose)` noise at every log site.
|
||||
* keeps call sites free of `if (verbose)` noise at every log line.
|
||||
*/
|
||||
function makeLogger(verbose: boolean): Logger {
|
||||
return {
|
||||
@@ -73,61 +48,22 @@ function makeLogger(verbose: boolean): Logger {
|
||||
/**
|
||||
* Perform a single synthetic mouse-activity sweep.
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Snapshot the starting cursor position.
|
||||
* 2. Read current screen dimensions (re-read every call so monitor changes
|
||||
* are handled correctly).
|
||||
* 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen:
|
||||
* move right if there's room, otherwise move left. Vertical movement is
|
||||
* currently disabled (`dy = 0`) but the framework is in place for
|
||||
* richer patterns later.
|
||||
* 4. For each of `config.stepCount` steps:
|
||||
* - Compute the next target position.
|
||||
* - Defensive bounds check (belt-and-braces given the `dx` choice).
|
||||
* - Command nut.js to move the cursor there.
|
||||
* - Sleep `config.stepDelay` — also the user's interrupt window.
|
||||
* - Re-read the cursor. If it isn't where we put it, the user
|
||||
* touched the mouse: log (verbose) and return early, leaving the
|
||||
* cursor wherever the user moved it.
|
||||
* 5. On a clean full sweep, restore the cursor to the starting position
|
||||
* so the next idle-check sees "no movement" and doesn't misread the
|
||||
* synthetic activity as the user returning.
|
||||
* Snapshots the cursor and screen (re-read every call so monitor changes
|
||||
* are handled), selects the configured strategy from the registry, and
|
||||
* hands the resulting path to `executePath`, which owns bounds, pacing,
|
||||
* interrupt detection, and restore-on-clean. An unknown `config.pattern`
|
||||
* falls back to the default strategy defensively; validation at the CLI /
|
||||
* config-file boundary should prevent that from ever happening.
|
||||
*/
|
||||
async function simulateActivity(config: Config, log: Logger): Promise<void> {
|
||||
const start: Point = await mouse.getPosition();
|
||||
const screenWidth: number = await screen.width();
|
||||
const screenHeight: number = await screen.height();
|
||||
const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1;
|
||||
const dy: number = 0;
|
||||
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
|
||||
const start: Point = await device.getPosition();
|
||||
const width: number = await device.width();
|
||||
const height: number = await device.height();
|
||||
|
||||
log.event(`Simulating activity at ${timestamp()}...`);
|
||||
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
|
||||
const ctx: MoveContext = { start, width, height, config, rng: Math.random };
|
||||
|
||||
for (let i: number = 1; i <= config.stepCount; i++) {
|
||||
const expected: Point = new Point(start.x + i * dx, start.y + i * dy);
|
||||
|
||||
if (expected.x < 0 || expected.x >= screenWidth || expected.y < 0 || expected.y >= screenHeight) {
|
||||
// Safety net for future non-linear movement patterns. With the
|
||||
// current straight-line sweep + `dx` selection above, this branch
|
||||
// should never fire.
|
||||
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await mouse.setPosition(expected);
|
||||
await sleep(config.stepDelay);
|
||||
|
||||
const current: Point = await mouse.getPosition();
|
||||
if (current.x !== expected.x || current.y !== expected.y) {
|
||||
// Cursor isn't where we put it -> real user activity. Abort
|
||||
// without snapping back, so we don't yank the cursor out from
|
||||
// under the user.
|
||||
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await mouse.setPosition(start);
|
||||
log.event("Mouse moved.");
|
||||
await executePath(strategy, ctx, device, log);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,32 +81,26 @@ async function simulateActivity(config: Config, log: Logger): Promise<void> {
|
||||
* idleness clock so we wait another full `moveInterval` before
|
||||
* firing again.
|
||||
*
|
||||
* `simulateActivity` is designed so that its own synthetic movement never
|
||||
* counts as real activity: on a clean sweep it restores the cursor (so the
|
||||
* next position check matches), and on a user-interrupted sweep the next
|
||||
* iteration sees the user's new position and correctly resets the clock.
|
||||
* `simulateActivity` (via `executePath`) is designed so its own synthetic
|
||||
* movement never counts as real activity: on a clean sweep it restores the
|
||||
* cursor, and on a user-interrupted sweep the next iteration sees the
|
||||
* user's new position and correctly resets the clock.
|
||||
*
|
||||
* @param config - Resolved runtime config.
|
||||
* @param device - I/O device; defaults to the production nut.js device.
|
||||
*/
|
||||
export async function runKeeper(config: Config): Promise<void> {
|
||||
// nut.js inserts a configurable delay after every action (default 100ms).
|
||||
// That default would silently more-than-double the duration of every
|
||||
// setPosition and getPosition call. We drive cadence ourselves via
|
||||
// config.stepDelay, so disable nut.js's implicit delay entirely.
|
||||
//
|
||||
// Setting this here (rather than at module load) keeps `keeper.ts` free
|
||||
// of import-time side effects on the shared nut.js singleton — useful
|
||||
// for tests and any future code path that imports this module without
|
||||
// actually running the loop.
|
||||
mouse.config.autoDelayMs = 0;
|
||||
export async function runKeeper(config: Config, device?: Device): Promise<void> {
|
||||
const dev: Device = device ?? (await createNutDevice());
|
||||
|
||||
const log = makeLogger(config.verbose);
|
||||
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
|
||||
|
||||
let lastPos: Point = await mouse.getPosition();
|
||||
let lastPos: Point = await dev.getPosition();
|
||||
let lastActivity: number = Date.now();
|
||||
|
||||
while (true) {
|
||||
await sleep(config.checkInterval);
|
||||
const pos: Point = await mouse.getPosition();
|
||||
await dev.sleep(config.checkInterval);
|
||||
const pos: Point = await dev.getPosition();
|
||||
const now: number = Date.now();
|
||||
|
||||
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
|
||||
@@ -181,12 +111,18 @@ export async function runKeeper(config: Config): Promise<void> {
|
||||
}
|
||||
|
||||
if (now - lastActivity >= config.moveInterval) {
|
||||
await simulateActivity(config, log);
|
||||
// `simulateActivity` either returns the cursor to its start
|
||||
// (clean sweep) or leaves it where the user moved it (interrupt).
|
||||
// Either way we reset the clock and require another full
|
||||
// moveInterval of inactivity before firing again.
|
||||
await simulateActivity(config, log, dev);
|
||||
// The sweep either restored the cursor to its start (clean) or
|
||||
// left it where the user moved it (interrupt). Either way, reset
|
||||
// the clock and require another full moveInterval of inactivity
|
||||
// before firing again.
|
||||
lastActivity = Date.now();
|
||||
// Re-sync lastPos to where the cursor actually ended. After a
|
||||
// clean sweep this is a no-op (it was restored to start). After
|
||||
// an interrupt it snaps lastPos to the user's position, so the
|
||||
// next poll doesn't re-read that same displacement and count it a
|
||||
// second time as fresh activity.
|
||||
lastPos = await dev.getPosition();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ const cliOverrides: ConfigOverrides = {
|
||||
checkInterval: cliArgs.checkInterval,
|
||||
stepDelay: cliArgs.stepDelay,
|
||||
stepCount: cliArgs.stepCount,
|
||||
stepSize: cliArgs.stepSize,
|
||||
pattern: cliArgs.pattern,
|
||||
verbose: cliArgs.verbose,
|
||||
};
|
||||
|
||||
|
||||
@@ -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