Add loop mode (--loop): repeat movement until user activity

Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.

- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
  and `diagonal` infinite loop generators that pick a direction once and
  ramp forever (4px/step). Their finite `path` and declared `bounds` are
  unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
  options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
  interruption) or chain a finite path cycle after cycle; force `reflect`
  bounds for every pattern and suppress the between-cycle restore, so
  line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
  config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
  the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
  Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
  selection, restore suppression), config/configFile loop plumbing, and
  keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
  cycles). 79 pass.
This commit is contained in:
2026-08-17 14:36:18 -05:00
parent 1ad724cd33
commit 7e632b3e9d
15 changed files with 397 additions and 31 deletions
+48 -12
View File
@@ -24,7 +24,7 @@
*/
import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger } from "./executor.ts";
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
import type { Config } from "./config.ts";
@@ -46,24 +46,60 @@ function makeLogger(verbose: boolean): Logger {
}
/**
* Perform a single synthetic mouse-activity sweep.
* Perform synthetic mouse activity once the keeper decides the cursor is
* idle.
*
* 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.
* Snapshots the screen (re-read every call so monitor changes are handled)
* and selects the configured strategy from the registry. An unknown
* `config.pattern` falls back to the default strategy defensively; validation
* 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.
*
* 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.
*/
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();
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config);
if (!config.loop) {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
await executePath(strategy, ctx, device, log, config);
return;
}
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 };
let cycles = 0;
let outcome: SweepOutcome;
do {
const start: Point = await device.getPosition();
const ctx: MoveContext = { start, width, height, rng: Math.random };
outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts);
cycles++;
// Spin guard for the chained-repeat path: a finite strategy that
// yielded nothing would otherwise return "completed" instantly in a
// tight loop. Sleeping one stepDelay makes that harmless. An infinite
// loopPath never returns "completed", so this branch is skipped there.
if (outcome === "completed") await device.sleep(config.stepDelay);
} while (outcome === "completed");
log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`);
}
/**