Files
Move/src/executor.ts
T
nokeo08 c8942bb380 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.
2026-08-17 15:53:49 -05:00

198 lines
7.8 KiB
TypeScript

/**
* 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 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,
* 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.
*
* 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 { Config } from "./config.ts";
import type { Device, Point } from "./device.ts";
import type { 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; the sweep stopped
* without snapping back.
*/
export type SweepOutcome = "completed" | "interrupted";
/**
* Per-call knobs for `executePath`. All optional; the defaults reproduce the
* original single-sweep behavior exactly, so every existing caller and test
* is unaffected.
*
* - `restore` — restore the cursor to `ctx.start` after a clean sweep.
* Default `true`. Loop (`--loop`) mode passes `false`:
* chained cycles must not snap back between iterations, and an
* infinite `loopPath` never reaches the restore anyway.
* - `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
* unconditionally.
*/
export interface ExecuteOptions {
readonly restore?: boolean;
readonly loop?: boolean;
}
/**
* 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 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:
* `[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 };
}
/**
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
* 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);
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 (possibly fractional, possibly off-screen) target
* to an on-screen integer pixel by reflecting each axis into its travel range.
*/
function resolveTarget(p: Point, width: number, height: number): Point {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
}
/**
* 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 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
* 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 — unless
* `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: `loop` selects the strategy's infinite `loopPath`, and
* `restore` suppresses the snap-back. Omitting `options` reproduces the
* original single-sweep contract exactly.
*
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
* entirely self-contained, so the path itself needs nothing from it.
*/
export async function executePath(
strategy: MovementStrategy,
ctx: MoveContext,
device: Device,
log: Logger,
config: Config,
options?: ExecuteOptions,
): Promise<SweepOutcome> {
const { start, width, height } = ctx;
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 = resolveTarget(target, width, height);
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. 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 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";
}
}
if (options?.restore !== false) {
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
}
return "completed";
}