/** * strategies.ts * ------------- * The movement-pattern seam: pure generators of cursor targets. * * A `MovementStrategy` describes *where* the cursor should go, as an * iterable of ideal `Point`s starting from the sweep's origin. It performs * no I/O, no timing, and no interrupt handling — that all belongs to the * executor (`executor.ts`). This split is what makes patterns trivial to * add (write one pure generator) and trivial to test (feed a deterministic * `rng`, assert the emitted points). * * Coordinates emitted here may be fractional; the executor rounds to whole * pixels before commanding the cursor and applies the strategy's declared * `BoundsPolicy` to keep everything on-screen. * * Each pattern owns its own geometry — how many steps it takes, how far it * reaches, how tight its radius is — as module-private constants below. Those * are properties of the pattern, not user preferences: a jitter is inherently * small and twitchy, an arc inherently a broad curve. There is deliberately * no user knob for sweep size or step count; the cadence (`stepDelay`) is the * only tunable, and it lives in the executor, not here. As a result this * module needs nothing from `Config` and imports only `Point`. */ import type { Point } from "./device.ts"; /** * How the executor keeps a strategy's targets on-screen: * * - `abort` — stop the sweep the moment a target falls out of bounds. * Used by `line`, whose direction is chosen so this never * actually fires; preserves the original straight-line * semantics exactly. * - `clamp` — pin each out-of-bounds coordinate to the nearest edge. * - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming * pattern bounces off the screen edges instead of sticking. */ export type BoundsPolicy = "abort" | "clamp" | "reflect"; /** * Everything a strategy needs to generate a path. Screen dimensions and the * start point are snapshotted per sweep by the caller; `rng` is injected so * stochastic strategies are deterministic under test. */ export interface MoveContext { /** Cursor position at the start of the sweep. */ readonly start: Point; /** Primary-screen width in pixels. */ readonly width: number; /** Primary-screen height in pixels. */ readonly height: number; /** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */ readonly rng: () => number; } /** * A named movement pattern. * * - `name` — registry key, also the value accepted by `--pattern` / the * `pattern` config key. * - `bounds` — how the executor confines this pattern to the screen. * - `path` — pure generator of ideal (possibly fractional) targets, * emitted in visiting order. Should not re-emit `start`. */ export interface MovementStrategy { readonly name: string; readonly bounds: BoundsPolicy; path(ctx: MoveContext): Iterable; } /** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */ function clamp(v: number, max: number): number { if (v < 0) return 0; if (v > max - 1) return max - 1; return v; } /** * `line` — the original behavior, preserved exactly. * * Pick a horizontal direction that keeps the sweep on-screen (right if * there's room, else left) and walk `LINE_STEPS` single-pixel steps with no * vertical movement. 250 one-pixel steps is byte-for-byte the sweep the * keeper produced before movement patterns existed, which is why its bounds * policy is `abort` (the direction choice guarantees it never triggers). */ const LINE_STEPS = 250; export const line: MovementStrategy = { name: "line", bounds: "abort", *path(ctx: MoveContext): Generator { const { start, width } = ctx; const dx: number = start.x + LINE_STEPS < width ? 1 : -1; for (let i = 1; i <= LINE_STEPS; i++) { yield { x: start.x + i * dx, y: start.y }; } }, }; /** * `diagonal` — straight line on both axes at once. Each axis's direction is * chosen independently by available room, so the sweep heads toward the * roomiest corner and stays on-screen. 250 single-pixel steps per axis * (≈250px reach), matching `line`'s magnitude. */ const DIAGONAL_STEPS = 250; export const diagonal: MovementStrategy = { name: "diagonal", bounds: "clamp", *path(ctx: MoveContext): Generator { const { start, width, height } = ctx; const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1; const dy: number = start.y + DIAGONAL_STEPS < height ? 1 : -1; for (let i = 1; i <= DIAGONAL_STEPS; i++) { yield { x: start.x + i * dx, y: start.y + i * dy }; } }, }; /** * `jitter` — many small random hops within a tight radius of the start. * Subtle "fidget" activity rather than a broad sweep. The radius is large * enough that every hop is a real, distinct pixel move rather than rounding * onto the pixel the cursor already occupies. The executor restores the * cursor to `start` after a clean run, so the net displacement is zero. */ const JITTER_STEPS = 80; const JITTER_RADIUS = 30; export const jitter: MovementStrategy = { name: "jitter", bounds: "clamp", *path(ctx: MoveContext): Generator { const { start, rng } = ctx; for (let i = 1; i <= JITTER_STEPS; i++) { const angle: number = rng() * 2 * Math.PI; const r: number = rng() * JITTER_RADIUS; yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r }; } }, }; /** * `walk` — an unbounded cumulative random walk: each step adds a random * per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is * deliberately several pixels so the walk actually roams — a ±1px walk over * this many steps would drift only ~√N pixels net. The generator lets the * position drift freely; the executor's `reflect` policy mirrors it back * on-screen, so the cursor bounces off the edges instead of escaping. */ const WALK_STEPS = 200; const WALK_STEP = 4; export const walk: MovementStrategy = { name: "walk", bounds: "reflect", *path(ctx: MoveContext): Generator { const { start, rng } = ctx; let x: number = start.x; let y: number = start.y; for (let i = 1; i <= WALK_STEPS; i++) { x += (rng() * 2 - 1) * WALK_STEP; y += (rng() * 2 - 1) * WALK_STEP; yield { x, y }; } }, }; /** * `arc` — a smooth quadratic Bézier curve from the start to a random * on-screen endpoint `ARC_REACH` pixels away, bowed out by a control point * offset perpendicular to the straight path. `ARC_STEPS` samples keep the * curve smooth. Produces natural, hand-like curved motion. */ const ARC_STEPS = 120; const ARC_REACH = 300; export const arc: MovementStrategy = { name: "arc", bounds: "clamp", *path(ctx: MoveContext): Generator { const { start, width, height, rng } = ctx; // Endpoint: a random direction, `ARC_REACH` away, clamped on-screen. const angle: number = rng() * 2 * Math.PI; const endX: number = clamp(start.x + Math.cos(angle) * ARC_REACH, width); const endY: number = clamp(start.y + Math.sin(angle) * ARC_REACH, height); // Control point: midpoint pushed along the perpendicular so the path // bows rather than running straight. Direction/magnitude randomized. const midX: number = (start.x + endX) / 2; const midY: number = (start.y + endY) / 2; const perpX: number = -(endY - start.y); const perpY: number = endX - start.x; const perpLen: number = Math.hypot(perpX, perpY) || 1; const bow: number = (rng() * 2 - 1) * ARC_REACH * 0.5; const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width); const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height); for (let i = 1; i <= ARC_STEPS; i++) { const t: number = i / ARC_STEPS; const u: number = 1 - t; yield { x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX, y: u * u * start.y + 2 * u * t * ctrlY + t * t * endY, }; } }, }; /** * `figureEight` — traces a Gerono lemniscate (a figure-eight) around the * start point over one full period, so it returns to the origin. * `FIG8_AMP` sets its half-width (≈250px across); `FIG8_STEPS` samples keep * the curve smooth. */ const FIG8_STEPS = 90; const FIG8_AMP = 125; export const figureEight: MovementStrategy = { name: "figureEight", bounds: "clamp", *path(ctx: MoveContext): Generator { const { start } = ctx; for (let i = 1; i <= FIG8_STEPS; i++) { const t: number = (2 * Math.PI * i) / FIG8_STEPS; yield { x: start.x + FIG8_AMP * Math.sin(t), y: start.y + FIG8_AMP * Math.sin(t) * Math.cos(t), }; } }, }; /** * The registry of every selectable movement pattern, keyed by name. Adding * a strategy is a one-line addition here plus its definition above. */ export const STRATEGIES: Readonly> = { line, diagonal, jitter, walk, arc, figureEight, }; /** Pattern used when neither the CLI nor the config file selects one. */ export const DEFAULT_PATTERN = "line"; /** All valid pattern names, for validation messages and help text. */ export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES); /** * The set of valid `--pattern` / `pattern` values as a string-literal-ish * type. Kept as `string` at the type level (the registry is the runtime * source of truth); `isPatternName` is the guard callers use. */ export type PatternName = string; /** True when `name` is an exact, registered strategy key. */ export function isPatternName(name: string): boolean { return Object.prototype.hasOwnProperty.call(STRATEGIES, name); } /** * Normalize a pattern name for lenient user-facing matching: lowercase and * strip separators (`-`, `_`, whitespace) so `figure-eight`, `figure_eight`, * and `FIGUREEIGHT` all collapse onto the same key as `figureEight`. */ const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, ""); /** * Map of normalized name -> canonical registry key. Built once at module * load. The assertion below guards against two registered names collapsing * to the same normalized form (e.g. a future `"figure_eight"` alongside * `"figureEight"`), which would otherwise let one silently shadow the other. */ const CANONICAL_PATTERNS: ReadonlyMap = new Map( PATTERN_NAMES.map((n) => [normalizePattern(n), n]), ); if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) { throw new Error( "strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators", ); } /** * Resolve loose user input to the canonical registry key, or `null` when no * registered strategy matches. Used at the CLI and config-file validation * boundaries so `Config.pattern` is always a canonical key and the keeper's * direct `STRATEGIES[pattern]` lookup needs no normalization of its own. */ export function resolvePatternName(name: string): string | null { return CANONICAL_PATTERNS.get(normalizePattern(name)) ?? null; }