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.
104 lines
4.4 KiB
Markdown
104 lines
4.4 KiB
Markdown
# Execution: the happy path
|
|
|
|
This traces one full idle-triggered sweep that completes cleanly — the
|
|
"happy path" where the machine is idle long enough to fire, the configured
|
|
pattern runs to exhaustion, and no real user activity interrupts it.
|
|
|
|
For the module breakdown and the three seams (`device` / `strategies` /
|
|
`executor`), see the "How it works" section of the [README](../README.md).
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
autonumber
|
|
participant Entry as move.ts
|
|
participant Keeper as runKeeper
|
|
participant Sim as simulateActivity
|
|
participant Strat as Strategy<br/>(e.g. line)
|
|
participant Exec as executePath
|
|
participant Dev as Device<br/>(nut.js)
|
|
|
|
Note over Entry: startup (args → config)
|
|
Entry->>Entry: parseCliArgs()
|
|
Entry->>Entry: loadConfigFile()
|
|
Entry->>Entry: resolveConfig(file, cli)
|
|
Entry->>Keeper: runKeeper(config)
|
|
|
|
Keeper->>Dev: createNutDevice()
|
|
Note right of Dev: sets mouse.config.autoDelayMs = 0
|
|
Keeper->>Keeper: log.info(banner)
|
|
Keeper->>Dev: getPosition()
|
|
Dev-->>Keeper: lastPos
|
|
Note over Keeper: lastActivity = now
|
|
|
|
loop every checkInterval (until idle long enough)
|
|
Keeper->>Dev: sleep(checkInterval)
|
|
Keeper->>Dev: getPosition()
|
|
Dev-->>Keeper: pos
|
|
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
|
|
end
|
|
|
|
Keeper->>Sim: simulateActivity(config, log, dev, pickRandom)
|
|
Sim->>Dev: width()
|
|
Dev-->>Sim: width
|
|
Sim->>Dev: height()
|
|
Dev-->>Sim: height
|
|
Sim->>Dev: getPosition()
|
|
Dev-->>Sim: start
|
|
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>(or pickRandom() when pattern is "random")<br/>ctx = { start, width, height, rng }
|
|
Sim->>Exec: executePath(strategy, ctx, dev, log, config)
|
|
|
|
Exec->>Strat: path(ctx)
|
|
Strat-->>Exec: iterable of Points
|
|
|
|
loop for each target point (clean run)
|
|
Exec->>Exec: resolveTarget(target) → point (reflected on-screen)
|
|
Exec->>Dev: setPosition(point)
|
|
Exec->>Dev: sleep(stepDelay)
|
|
Exec->>Dev: getPosition()
|
|
Dev-->>Exec: current
|
|
Note over Exec: |current - point| ≤ 2px → not the user, continue
|
|
end
|
|
|
|
Note over Exec: path exhausted, no interruption
|
|
Exec->>Dev: setPosition(round(start))
|
|
Note right of Exec: restore cursor to origin
|
|
Exec-->>Sim: "completed"
|
|
Sim-->>Keeper: (done)
|
|
|
|
Keeper->>Dev: getPosition()
|
|
Dev-->>Keeper: lastPos (equals start, re-synced)
|
|
Note over Keeper: lastActivity = now<br/>loop continues
|
|
```
|
|
|
|
## Invariants this path relies on
|
|
|
|
- **`createNutDevice()` is the only nut.js touchpoint.** It disables nut.js's
|
|
100ms auto-delay so `executePath` owns cadence via `stepDelay`.
|
|
- **The strategy is pure.** `path(ctx)` yields ideal points from geometry
|
|
alone (`start` / `width` / `height` / `rng`); it never touches the device,
|
|
which is what makes every pattern unit-testable without a screen.
|
|
- **Every step re-reads the cursor** and compares it against the *commanded*
|
|
point (not the strategy's ideal, possibly fractional target) within a 2px
|
|
tolerance. On the happy path each check passes, so the loop runs to
|
|
exhaustion. A mismatch beyond tolerance is real user activity and returns
|
|
`"interrupted"` without restoring — the branch this diagram omits.
|
|
- **Clean completion restores the cursor to `round(start)`.** That is why the
|
|
follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as
|
|
a no-op, and the next idle check sees no net movement (so the synthetic
|
|
sweep is never mistaken for the user returning).
|
|
- **On-screen confinement is uniform.** `resolveTarget` reflects any
|
|
coordinate past a screen edge back inside the travel range — the sole,
|
|
per-pattern-independent policy. A strategy emits ideal geometry and never
|
|
bounds its own output.
|
|
|
|
## Loop mode (`--loop`)
|
|
|
|
This diagram is the single-sweep path (`config.loop === false`). Under
|
|
`--loop`, `simulateActivity` instead repeats the step loop until the user
|
|
interrupts: a pattern with an infinite `loopPath` (`line`, `diagonal`) runs
|
|
that one never-ending path, while the others chain their finite `path` cycle
|
|
after cycle, re-reading the cursor as the next `start` each time. The restore
|
|
in the final step is skipped (`restore: false`), so successive cycles flow
|
|
from where the last left off. Everything else — reflection, pacing, and the
|
|
per-step interrupt check — is identical to the sweep traced above.
|