Add random pattern selection (-r / --pattern random)

Pick a different movement pattern every time a sweep is triggered, so the
motion varies across the day instead of repeating one shape.

- strategies.ts: add the `random` sentinel, `SELECTABLE_PATTERN_NAMES`,
  `isSelectablePattern`, and `createRandomPicker`. `random` is deliberately
  NOT a registry entry: it has no path of its own, so `STRATEGIES` stays a
  total lookup and `PATTERN_NAMES` keeps listing only real generators. The
  picker is a closure over `last`, giving a uniform draw that never returns
  the same pattern twice in a row. Building CANONICAL_PATTERNS from the
  selectable list makes both validation boundaries accept `random` (and
  loose spellings) for free, and extends the normalization-collision
  assertion to cover the sentinel.
- cli.ts: add `-r`/`--random` plus an exported `selectPattern` holding the
  conflict rule. `-r` is sugar for `--pattern random`, so the two agreeing
  is a no-op while `-r -p arc` is rejected as contradictory. The flag folds
  into `pattern`, so ConfigOverrides, resolveConfig, and move.ts are
  untouched. `parseCliArgs` now takes its argv as an optional parameter so
  the flag surface is testable without process.argv.
- keeper.ts: resolve `random` via the picker once per trigger, before the
  loop-mode branch, so a pick holds for a whole loop run rather than
  changing mid-run. runKeeper builds one picker for the process, so the
  no-repeat memory spans sweeps minutes apart. Because the pick is a real
  strategy, --verbose logs the concrete pattern name and a pick with an
  infinite loopPath still bounces edge-to-edge under --loop.
- config.ts / configFile.ts: accept the sentinel where a pattern is valid,
  and quote the selectable list in errors. No `random` boolean config key —
  the file spells it "pattern": "random".

executor.ts and move.ts needed no changes.

Tests: new tests/cli.test.ts (the file had no coverage before) covering the
flag surface and the conflict rule; picker tests pinning the no-repeat and
full-registry-coverage properties; keeper tests pinning once-per-trigger and
once-per-loop-run.
This commit is contained in:
2026-08-18 14:36:29 -05:00
parent b019f25a42
commit d38949edb4
12 changed files with 594 additions and 47 deletions
+49 -10
View File
@@ -119,8 +119,12 @@ Options:
-d, --step-delay <ms> Pause between synthetic steps. Default: 50.
-p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc,
figureEight. Each pattern defines its own
size and speed.
figureEight, random. Each pattern defines
its own size and speed.
-r, --random Shorthand for --pattern random. Picks a
different pattern for each sweep, never the
same one twice in a row. In loop mode the
pick holds for the whole loop run.
-V, --verbose Log every sweep and interrupt
(default prints only the startup banner).
-l, --loop Loop mode: once a sweep is triggered,
@@ -191,7 +195,7 @@ doesn't set.
All keys are optional; supply only the ones you want to override. Keys
and units mirror the CLI flags exactly: `moveInterval` and
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
movement strategy name, `verbose` and `loop` are booleans.
movement strategy name (or `"random"`), `verbose` and `loop` are booleans.
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
> tolerated for backward compatibility: they're ignored with a one-line
@@ -223,9 +227,11 @@ The loader is strict:
- Root must be a JSON object.
- Unknown keys are rejected (catches typos like `"movInterval"`).
- Numeric values must be finite and strictly positive.
- `pattern` must resolve to a registered strategy name. Matching ignores
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
are equivalent.
- `pattern` must resolve to a registered strategy name, or to `random`.
Matching ignores case and separators (`-`, `_`, spaces), so `figure-eight`
and `figureEight` are equivalent. There is no `random` boolean key — the
CLI's `-r` is sugar for `--pattern random`, and the file spells it the
same way.
- `verbose` must be a boolean.
- `loop` must be a boolean.
@@ -287,7 +293,8 @@ and everything but the raw nut.js call is unit-testable:
injectable, so tests drive the loop and executor with a fake.
- `src/strategies.ts` holds the pure movement patterns — each a generator
of target points given a start, screen size, config, and RNG — plus the
registry and name validation. Adding a pattern is one pure function.
registry, name validation, and the `random` picker. Adding a pattern is
one pure function.
- `src/executor.ts` is the single `executePath` driver: it rounds targets,
reflects any off-screen coordinate back inside, paces steps, detects
real-user interruption, and restores the cursor on a clean sweep.
@@ -299,7 +306,7 @@ Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
| `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. |
| `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. |
| `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. |
| `pattern` | `"line"` | `-p`, `--pattern` | Movement strategy name (see Movement strategies below). |
| `pattern` | `"line"` | `-p`, `--pattern`, `-r` | Movement strategy name, or `random` (see Movement strategies below). |
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
to milliseconds before handing the resolved `Config` to `runKeeper`.
@@ -319,6 +326,8 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
1. `simulateActivity` snapshots the starting position and current screen
dimensions (re-read every sweep so monitor changes are handled), looks
up `config.pattern` in the strategy registry, and builds a `MoveContext`.
When `config.pattern` is `random` — the one name the registry doesn't
contain — the strategy comes from the picker instead, once per trigger.
2. It hands the strategy and context to `executePath`, which drives the
sweep. For each target the strategy yields:
- Round to whole pixels and reflect any off-screen coordinate back inside
@@ -346,7 +355,8 @@ or multi-monitor displays isn't misread as the user grabbing the mouse.
### Movement strategies
`config.pattern` selects one of the generators in `src/strategies.ts`:
`config.pattern` selects one of the generators in `src/strategies.ts` (or
`random`, which picks one for you):
| Name | Motion | Steps | Size |
| ------------- | ------------------------------------------------------------- | ----- | ----------- |
@@ -356,6 +366,35 @@ or multi-monitor displays isn't misread as the user grabbing the mouse.
| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide |
| `random` | Meta-selection: a different one of the above per sweep. | — | — |
### Random (`-r` / `--pattern random`)
`random` isn't a movement pattern of its own — it's a selection that resolves
to one of the real patterns above each time a sweep fires:
```sh
move -r # a different pattern every sweep
move --pattern random -V # verbose names the pattern each sweep picked
move -r --loop # one random pick, looped until you move the mouse
```
Two rules make it predictable:
- **Never twice in a row.** Consecutive sweeps always use different patterns,
so the motion visibly varies instead of occasionally repeating itself.
- **One pick per trigger.** In loop mode a single trigger runs many cycles;
the pattern is chosen once and holds for that whole run rather than
changing mid-run.
Because the pick is a real strategy, it behaves exactly as if you'd named it:
`--verbose` logs the concrete pattern (`Simulating activity (arc)...`), and a
pick with an infinite loop path (`line`, `diagonal`) bounces edge-to-edge
under `--loop` just as selecting it directly would.
`-r` and `--pattern` state the same setting two ways, so passing both is
rejected (exit `2`) unless they agree — `move -r -p arc` is an error, while
`move -r -p random` is a harmless no-op.
Every pattern is kept on-screen the same way: the executor reflects any
coordinate that would fall past a screen edge back inside, so motion bounces
@@ -423,7 +462,7 @@ move --help
| `src/errors.ts` | Shared error types (`CliError`). |
| `src/keeper.ts` | Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). |
| `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. |
| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. |
| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, name validation, and the `random` picker. |
| `src/executor.ts` | `executePath` driver: on-screen reflection, pacing, interrupt detection, restore. |
| `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |