/** * 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 { const { start, width, height } = ctx; const path: Iterable = 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"; }