Collapse bounds policies to reflect-only; drop abort and clamp

The executor kept every commanded point on-screen via a per-strategy
BoundsPolicy of abort / clamp / reflect. Measured against the real
strategies, the other two earned nothing: abort truncated a sweep at the
first edge (line on a narrow screen ran only 90 of 250 steps), and clamp
could park the cursor against an edge (a monotonic ramp stalled 162 steps
in a row) -- both counter to the program's whole purpose of keeping the
cursor moving. reflect bounces off the edge and keeps going, and is
already what line/diagonal need in loop mode. arc's declared clamp was
provably dead code (it clamps its own endpoint, so no sample ever leaves
the screen).

Collapse to reflect-only:
- strategies.ts: remove the BoundsPolicy type and the `bounds` field from
  the interface and all six strategies. Keep the local clamp() helper --
  it's arc's endpoint geometry, not an on-screen policy; docstring says so.
- executor.ts: resolveTarget loses its policy parameter and its null
  return and just reflects both axes; delete clampInt; SweepOutcome drops
  "aborted"; ExecuteOptions drops `bounds`; remove the Out of bounds log.
- keeper.ts: loopOpts is now { restore: false, loop: true } -- the
  reflect override added with loop mode is redundant.
- tests: drop the abort-outcome, clamp, and bounds-override tests; simplify
  fixed() to take no policy; add a regression test that a monotonic ramp
  past an edge never yields two identical points in a row (the guarantee
  that motivated removing clamp).

Behavior is unchanged for every pattern at normal cursor positions
(verified: line's normal sweep is byte-identical). The only differences
are at a screen edge, where motion now bounces instead of stopping. No
config keys, flags, or pattern names changed.

Docs updated to match, including in-code comments, the README strategies
table (Bounds column removed) and verbose description, the sequence
diagram (resolveTarget signature + getPosition/width ordering + a loop-mode
note), and a CHANGELOG Changed entry.
This commit is contained in:
2026-08-17 15:53:49 -05:00
parent 7e632b3e9d
commit c8942bb380
9 changed files with 197 additions and 233 deletions
+2 -2
View File
@@ -17,7 +17,7 @@
* -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms).
* -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
* -V, --verbose Enable per-sweep / interrupt logging.
* (`-V` capital because `-v` is `--version`.)
* -l, --loop Loop mode: once triggered, keep moving
* until the user moves the mouse (or Ctrl+C).
@@ -180,7 +180,7 @@ Options:
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}.
Each pattern defines its own size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event
-V, --verbose Log every sweep and interrupt
(default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
keep moving until you move the mouse (or
+2 -2
View File
@@ -45,8 +45,8 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
* pattern owns its own size and step count.
* - `verbose` — whether per-sweep / interrupt / bounds events are
* logged. The startup banner is always printed.
* - `verbose` — whether per-sweep / interrupt events are logged. The
* startup banner is always printed.
* - `loop` — loop mode: once a sweep is triggered, keep
* repeating the movement until the user moves the mouse
* (or Ctrl+C), rather than firing a single sweep. See
+31 -70
View File
@@ -7,13 +7,13 @@
* *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`,
* - keep it on-screen by reflecting coordinates that fall past an edge,
* - 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 —
* on-screen, 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.
*
@@ -25,7 +25,7 @@
import type { Config } from "./config.ts";
import type { Device, Point } from "./device.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts";
import type { MoveContext, MovementStrategy } from "./strategies.ts";
/**
* Minimal log surface used by the executor and the keeper loop.
@@ -41,11 +41,10 @@ export interface Logger {
/**
* 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.
* - `interrupted` — real user activity detected mid-sweep; the sweep stopped
* without snapping back.
*/
export type SweepOutcome = "completed" | "interrupted" | "aborted";
export type SweepOutcome = "completed" | "interrupted";
/**
* Per-call knobs for `executePath`. All optional; the defaults reproduce the
@@ -56,11 +55,6 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted";
* Default `true`. Loop (`--loop`) mode passes `false`:
* chained cycles must not snap back between iterations, and an
* infinite `loopPath` never reaches the restore anyway.
* - `bounds` — override the strategy's declared `BoundsPolicy`. Loop mode
* forces `"reflect"` for every pattern so edge-seeking paths
* bounce off the screen instead of aborting (`line`) or
* sticking in a corner (`clamp`). Absent, the strategy's own
* `bounds` is used, so single-sweep behavior is unchanged.
* - `loop` — prefer the strategy's infinite `loopPath` when it defines
* one. Falls back to `path` when the strategy has no
* `loopPath`, so a plain chained-repeat caller can pass this
@@ -68,7 +62,6 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted";
*/
export interface ExecuteOptions {
readonly restore?: boolean;
readonly bounds?: BoundsPolicy;
readonly loop?: boolean;
}
@@ -81,20 +74,17 @@ export interface ExecuteOptions {
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.
* Pixels to inset the 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).
*/
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.
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel:
* `[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;
@@ -102,18 +92,11 @@ function travelRange(max: number): { lo: number; hi: number } {
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.
* values past an edge bounce back inside instead of running off it. This is
* the sole on-screen policy: a coordinate that overshoots an edge reflects
* back in, so a pattern keeps moving instead of parking against the boundary.
*/
function reflectInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
@@ -125,27 +108,11 @@ function reflectInt(v: number, max: number): number {
}
/**
* 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.
* Resolve a strategy's ideal (possibly fractional, possibly off-screen) target
* to an on-screen integer pixel by reflecting each axis into its travel range.
*/
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 };
function resolveTarget(p: Point, width: number, height: number): Point {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
}
/**
@@ -162,8 +129,8 @@ function timestamp(): string {
* 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`).
* 1. Resolve the ideal target to an on-screen integer by reflecting it
* into the travel range.
* 2. Command the cursor there and sleep `config.stepDelay` — also the
* user's interrupt window.
* 3. Re-read the cursor. If it isn't at the point we just commanded, the
@@ -174,9 +141,8 @@ function timestamp(): string {
* `options.restore === false` (loop mode), in which case the cursor is
* left where the last step put it.
*
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse
* this same driver: `bounds` overrides the strategy's policy (loop mode
* forces `reflect`), `loop` selects the strategy's infinite `loopPath`, and
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse this
* same driver: `loop` selects the strategy's infinite `loopPath`, and
* `restore` suppresses the snap-back. Omitting `options` reproduces the
* original single-sweep contract exactly.
*
@@ -192,18 +158,13 @@ export async function executePath(
options?: ExecuteOptions,
): Promise<SweepOutcome> {
const { start, width, height } = ctx;
const policy: BoundsPolicy = options?.bounds ?? strategy.bounds;
const path: Iterable<Point> =
options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx);
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of path) {
const point: Point | null = resolveTarget(policy, target, width, height);
if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted";
}
const point: Point = resolveTarget(target, width, height);
await device.setPosition(point);
await device.sleep(config.stepDelay);
@@ -213,17 +174,17 @@ export async function executePath(
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
// Cursor isn't where we last put it -> real user activity. Stop
// 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.`);
// commanded, and edge-seeking patterns 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()}; stopping simulation.`);
return "interrupted";
}
}
+14 -17
View File
@@ -8,8 +8,8 @@
* 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).
* - `executor.ts` — the "how to move" driver (on-screen reflection,
* 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
@@ -18,9 +18,9 @@
* Logging policy:
* - The startup banner in `runKeeper` is unconditional so the user always
* 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.
* - Per-sweep / interrupt lines are gated by `config.verbose` (see
* `makeLogger`). Errors stay on `console.error`, raised by the entry
* point on unhandled rejection.
*/
import { createNutDevice, type Device, type Point } from "./device.ts";
@@ -55,19 +55,16 @@ function makeLogger(verbose: boolean): Logger {
* at the CLI / config-file boundary should prevent that from ever happening.
*
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
* `executePath`, which owns bounds, pacing, interrupt detection, and
* restore-on-clean — unchanged from before loop mode existed.
* `executePath`, which owns on-screen reflection, pacing, interrupt
* detection, and restore-on-clean — unchanged from before loop mode existed.
*
* Loop mode (`config.loop === true`) keeps the cursor moving until the
* user moves the mouse (or Ctrl+C). Two things change for every pattern:
* the cursor is never restored between iterations (`restore: false`), and the
* bounds policy is forced to `reflect` so edge-seeking paths bounce off the
* screen instead of aborting (`line`) or sticking in a corner (`clamp`).
* Patterns that define an infinite `loopPath` (`line`, `diagonal`) run it once
* and are stopped only by interruption; the rest have their finite `path`
* chained, re-read from the cursor's current position each cycle. Per-cycle
* event logs are suppressed to avoid unbounded output — one line brackets the
* run at each end.
* user moves the mouse (or Ctrl+C). The cursor is never restored between
* iterations (`restore: false`). Patterns that define an infinite `loopPath`
* (`line`, `diagonal`) run it once and are stopped only by interruption; the
* rest have their finite `path` chained, re-read from the cursor's current
* position each cycle. Per-cycle event logs are suppressed to avoid unbounded
* output — one line brackets the run at each end.
*/
async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
const width: number = await device.width();
@@ -83,7 +80,7 @@ async function simulateActivity(config: Config, log: Logger, device: Device): Pr
log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`);
const cycleLog: Logger = { info: log.info, event: (): void => {} };
const loopOpts = { restore: false, bounds: "reflect" as const, loop: true };
const loopOpts = { restore: false, loop: true };
let cycles = 0;
let outcome: SweepOutcome;
+30 -46
View File
@@ -10,9 +10,10 @@
* 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.
* Coordinates emitted here may be fractional and may fall past a screen
* edge; the executor rounds to whole pixels and reflects any out-of-range
* coordinate back inside, so a pattern bounces off the edges and keeps
* moving. Strategies never need to bound their own output.
*
* Each pattern owns its own geometry — how many steps it takes, how far it
* reaches, how tight its radius is — as module-private constants below. Those
@@ -25,19 +26,6 @@
import type { Point } from "./device.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
@@ -59,7 +47,6 @@ export interface MoveContext {
*
* - `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`.
* - `loopPath` — optional infinite variant for loop mode (`--loop`).
@@ -68,23 +55,26 @@ export interface MoveContext {
* direction from the cursor's position every cycle, so chained
* repetition oscillates in a band near an edge instead of
* crossing the screen. An infinite generator picks its
* direction once and ramps forever; the executor's `reflect`
* policy (forced on in loop mode) folds the monotonic ramp
* into an edge-to-edge bounce. Absent this, loop mode simply
* chains `path` — correct for patterns whose finite path is a
* self-contained cyclic unit (`jitter`, `walk`, `arc`,
* `figureEight`). The executor stops either kind on real user
* activity; an infinite `loopPath` therefore only ever ends
* by interruption.
* direction once and ramps forever; the executor reflects the
* monotonic ramp into an edge-to-edge bounce. Absent this,
* loop mode simply chains `path` — correct for patterns whose
* finite path is a self-contained cyclic unit (`jitter`,
* `walk`, `arc`, `figureEight`). The executor stops either
* kind on real user activity; an infinite `loopPath` therefore
* only ever ends by interruption.
*/
export interface MovementStrategy {
readonly name: string;
readonly bounds: BoundsPolicy;
path(ctx: MoveContext): Iterable<Point>;
loopPath?(ctx: MoveContext): Iterable<Point>;
}
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
/**
* Clamp `v` into the inclusive pixel range `[0, max - 1]`. This is a geometry
* helper for `arc` (choosing a well-formed on-screen endpoint and control
* point), NOT an on-screen bounds policy — the executor keeps every commanded
* point on-screen by reflecting, uniformly for all patterns.
*/
function clamp(v: number, max: number): number {
if (v < 0) return 0;
if (v > max - 1) return max - 1;
@@ -97,20 +87,20 @@ function clamp(v: number, max: number): number {
* Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left) and walk `LINE_STEPS` single-pixel steps with no
* vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* keeper produced before movement patterns existed, which is why its bounds
* policy is `abort` (the direction choice guarantees it never triggers).
* keeper produced before movement patterns existed. The direction choice
* keeps the finite sweep on-screen, so the executor's reflection never
* actually engages for it.
*
* In loop mode `loopPath` ramps x in one direction forever (loop mode
* forces `reflect`, so the direction never matters and the ramp bounces edge
* to edge). `LINE_LOOP_STEP` is several pixels per step rather than one so a
* screen crossing takes seconds, not minutes, at the default cadence.
* In loop mode `loopPath` ramps x in one direction forever; the direction
* never matters because the executor reflects the ramp edge to edge.
* `LINE_LOOP_STEP` is several pixels per step rather than one so a screen
* crossing takes seconds, not minutes, at the default cadence.
*/
const LINE_STEPS = 250;
const LINE_LOOP_STEP = 4;
export const line: MovementStrategy = {
name: "line",
bounds: "abort",
*path(ctx: MoveContext): Generator<Point> {
const { start, width } = ctx;
const dx: number = start.x + LINE_STEPS < width ? 1 : -1;
@@ -134,18 +124,16 @@ export const line: MovementStrategy = {
* roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude.
*
* In loop mode `loopPath` ramps both axes forever under the forced
* `reflect` policy. Because the x and y travel ranges have different spans,
* their triangle waves have different periods, so the path precesses across
* the whole screen — the roaming-DVD bounce — rather than retracing one 45°
* line.
* In loop mode `loopPath` ramps both axes forever, and the executor reflects
* them. Because the x and y travel ranges have different spans, their
* triangle waves have different periods, so the path precesses across the
* whole screen — the roaming-DVD bounce — rather than retracing one 45° line.
*/
const DIAGONAL_STEPS = 250;
const DIAGONAL_LOOP_STEP = 4;
export const diagonal: MovementStrategy = {
name: "diagonal",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height } = ctx;
const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
@@ -178,7 +166,6 @@ const JITTER_RADIUS = 30;
export const jitter: MovementStrategy = {
name: "jitter",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
for (let i = 1; i <= JITTER_STEPS; i++) {
@@ -194,15 +181,14 @@ export const jitter: MovementStrategy = {
* per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is
* deliberately several pixels so the walk actually roams — a ±1px walk over
* this many steps would drift only ~√N pixels net. The generator 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.
* position drift freely; the executor mirrors it back on-screen, so the
* cursor bounces off the edges instead of escaping.
*/
const WALK_STEPS = 200;
const WALK_STEP = 4;
export const walk: MovementStrategy = {
name: "walk",
bounds: "reflect",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
let x: number = start.x;
@@ -226,7 +212,6 @@ const ARC_REACH = 300;
export const arc: MovementStrategy = {
name: "arc",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height, rng } = ctx;
@@ -268,7 +253,6 @@ const FIG8_AMP = 125;
export const figureEight: MovementStrategy = {
name: "figureEight",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
for (let i = 1; i <= FIG8_STEPS; i++) {