Files
Move/src/keeper.ts
T
nokeo08 d38949edb4 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.
2026-08-18 14:36:29 -05:00

193 lines
8.2 KiB
TypeScript

/**
* keeper.ts
* ---------
* The "Teams Status Keeper" behavior: the idle-watch loop plus the
* per-sweep glue that ties a movement strategy to the execution driver.
*
* The mechanics are split across three seams so this file stays small and
* the interesting parts stay testable:
* - `device.ts` — the nut.js I/O boundary (injected here).
* - `strategies.ts` — pure "where to move" pattern generators.
* - `executor.ts` — the "how to move" driver (on-screen reflection,
* timing, interrupt detection, restore).
*
* `runKeeper` takes an optional `Device` so tests can drive the loop with a
* fake; production supplies the nut.js device. Importing this module is
* side-effect-free: nut.js isn't touched until `createNutDevice()` runs.
*
* Logging policy:
* - The startup banner in `runKeeper` is unconditional so the user always
* sees the process is alive.
* - Per-sweep / interrupt lines are gated by `config.verbose` (see
* `makeLogger`). Errors stay on `console.error`, raised by the entry
* point on unhandled rejection.
*/
import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
import {
createRandomPicker,
DEFAULT_PATTERN,
RANDOM_PATTERN,
STRATEGIES,
type MoveContext,
type MovementStrategy,
} from "./strategies.ts";
import type { Config } from "./config.ts";
/**
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only
* fires when the caller asked for verbose output. Returning a small object
* keeps call sites free of `if (verbose)` noise at every log line.
*/
function makeLogger(verbose: boolean): Logger {
return {
info: (msg: string): void => {
console.log(msg);
},
event: (msg: string): void => {
if (verbose) console.log(msg);
},
};
}
/**
* Perform synthetic mouse activity once the keeper decides the cursor is
* idle.
*
* 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.
*
* `pattern: "random"` isn't a registry key — it asks for a fresh pattern per
* sweep, so `pickRandom` supplies one here. The pick happens once, before the
* loop-mode branch below, which is what makes a random selection hold for an
* entire loop run rather than changing under the user mid-run; the picker's
* own no-repeat memory then spans sweeps, since the keeper holds one picker
* for the life of the process. Because the pick is a real strategy, the log
* lines below and in `executePath` name the concrete pattern, not "random".
*
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
* `executePath`, which owns on-screen reflection, 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). The cursor is never restored between
* iterations (`restore: false`). 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,
pickRandom: () => MovementStrategy,
): Promise<void> {
const width: number = await device.width();
const height: number = await device.height();
const strategy: MovementStrategy =
config.pattern === RANDOM_PATTERN
? pickRandom()
: (STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!);
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, 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}.`);
}
/**
* Main idle-watch loop. Runs forever; exits only on `Ctrl+C` (SIGINT) or
* an unhandled rejection caught by the entry point.
*
* Algorithm:
* - Track the last known cursor position (`lastPos`) and the timestamp of
* the last observed real user movement (`lastActivity`).
* - Every `config.checkInterval`:
* * If the cursor moved since the last check, that's real user
* activity: reset `lastActivity` and `lastPos`, skip the rest.
* * Otherwise, if it's been at least `config.moveInterval` since the
* last real activity, fire a synthetic sweep, then reset the
* idleness clock so we wait another full `moveInterval` before
* firing again.
*
* `simulateActivity` (via `executePath`) is designed so its own synthetic
* movement never counts as real activity: on a clean sweep it restores the
* cursor, and on a user-interrupted sweep the next iteration sees the
* user's new position and correctly resets the clock.
*
* @param config - Resolved runtime config.
* @param device - I/O device; defaults to the production nut.js device.
* @param pickRandom - Supplies a strategy when `config.pattern` is `random`.
* Created once here (not per sweep) so its no-repeat
* memory spans the whole run; injectable so tests can
* drive a deterministic sequence.
*/
export async function runKeeper(
config: Config,
device?: Device,
pickRandom: () => MovementStrategy = createRandomPicker(),
): Promise<void> {
const dev: Device = device ?? (await createNutDevice());
const log = makeLogger(config.verbose);
log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
let lastPos: Point = await dev.getPosition();
let lastActivity: number = Date.now();
while (true) {
await dev.sleep(config.checkInterval);
const pos: Point = await dev.getPosition();
const now: number = Date.now();
if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
// Real user activity since the last check; reset the idleness clock.
lastActivity = now;
lastPos = pos;
continue;
}
if (now - lastActivity >= config.moveInterval) {
await simulateActivity(config, log, dev, pickRandom);
// The sweep either restored the cursor to its start (clean) or
// left it where the user moved it (interrupt). Either way, reset
// the clock and require another full moveInterval of inactivity
// before firing again.
lastActivity = Date.now();
// Re-sync lastPos to where the cursor actually ended. After a
// clean sweep this is a no-op (it was restored to start). After
// an interrupt it snaps lastPos to the user's position, so the
// next poll doesn't re-read that same displacement and count it a
// second time as fresh activity.
lastPos = await dev.getPosition();
}
}
}