Remove stepCount/stepSize; patterns own their geometry

The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
This commit is contained in:
2026-08-14 12:56:22 -05:00
parent db3310c247
commit ec33648e74
15 changed files with 233 additions and 224 deletions
+71 -68
View File
@@ -14,13 +14,16 @@
* pixels before commanding the cursor and applies the strategy's declared
* `BoundsPolicy` to keep everything on-screen.
*
* `Config` is imported type-only so that `config.ts` can import the value
* exports here (the registry, name list, and validator) without creating a
* runtime import cycle.
* 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";
import type { Config } from "./config.ts";
/**
* How the executor keeps a strategy's targets on-screen:
@@ -47,8 +50,6 @@ export interface MoveContext {
readonly width: number;
/** Primary-screen height in pixels. */
readonly height: number;
/** Resolved runtime config (supplies `stepCount`, `stepSize`, ...). */
readonly config: Config;
/** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */
readonly rng: () => number;
}
@@ -75,33 +76,25 @@ function clamp(v: number, max: number): number {
return v;
}
/**
* Total pixel reach of a sweep: number of steps times pixels per step.
* Strategies use this to size themselves relative to the configured sweep
* length regardless of `stepSize`.
*/
function reachOf(config: Config): number {
return config.stepCount * config.stepSize;
}
/**
* `line` — the original behavior, preserved exactly.
*
* Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left); walk `stepCount` steps of `stepSize` pixels
* with no vertical movement. With the default `stepSize` of 1 this emits
* the identical integer 1px-per-step path the keeper used before the
* strategy refactor, which is why its bounds policy is `abort` (the
* direction choice guarantees it never triggers).
* 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<Point> {
const { start, width, config } = ctx;
const dx: number = start.x + reachOf(config) < width ? 1 : -1;
for (let i = 1; i <= config.stepCount; i++) {
yield { x: start.x + i * dx * config.stepSize, y: start.y };
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 };
}
},
};
@@ -109,42 +102,42 @@ export const line: MovementStrategy = {
/**
* `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.
* 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<Point> {
const { start, width, height, config } = ctx;
const reach: number = reachOf(config);
const dx: number = start.x + reach < width ? 1 : -1;
const dy: number = start.y + reach < height ? 1 : -1;
for (let i = 1; i <= config.stepCount; i++) {
yield {
x: start.x + i * dx * config.stepSize,
y: start.y + i * dy * config.stepSize,
};
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 local radius of the start.
* Subtle "fidget" activity rather than a broad sweep. The radius scales off
* the sweep length (like the other patterns) so every hop is a real,
* distinct pixel move rather than rounding onto the pixel the cursor is
* already on. The executor restores the cursor to `start` after a clean
* run, so the net displacement is zero.
* `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<Point> {
const { start, config, rng } = ctx;
const radius: number = Math.max(4, reachOf(config) / 8);
for (let i = 1; i <= config.stepCount; i++) {
const { start, rng } = ctx;
for (let i = 1; i <= JITTER_STEPS; i++) {
const angle: number = rng() * 2 * Math.PI;
const r: number = rng() * radius;
const r: number = rng() * JITTER_RADIUS;
yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r };
}
},
@@ -152,20 +145,25 @@ export const jitter: MovementStrategy = {
/**
* `walk` — an unbounded cumulative random walk: each step adds a random
* per-axis delta in `[-stepSize, +stepSize]`. The generator itself lets the
* 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<Point> {
const { start, config, rng } = ctx;
const { start, rng } = ctx;
let x: number = start.x;
let y: number = start.y;
for (let i = 1; i <= config.stepCount; i++) {
x += (rng() * 2 - 1) * config.stepSize;
y += (rng() * 2 - 1) * config.stepSize;
for (let i = 1; i <= WALK_STEPS; i++) {
x += (rng() * 2 - 1) * WALK_STEP;
y += (rng() * 2 - 1) * WALK_STEP;
yield { x, y };
}
},
@@ -173,21 +171,23 @@ export const walk: MovementStrategy = {
/**
* `arc` — a smooth quadratic Bézier curve from the start to a random
* on-screen endpoint roughly `reach` pixels away, bowed out by a control
* point offset perpendicular to the straight path. Produces natural,
* hand-like curved motion.
* 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<Point> {
const { start, width, height, config, rng } = ctx;
const reach: number = reachOf(config);
const { start, width, height, rng } = ctx;
// Endpoint: a random direction, `reach` away, clamped on-screen.
// 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) * reach, width);
const endY: number = clamp(start.y + Math.sin(angle) * reach, height);
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.
@@ -196,12 +196,12 @@ export const arc: MovementStrategy = {
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) * reach * 0.5;
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 <= config.stepCount; i++) {
const t: number = i / config.stepCount;
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,
@@ -213,20 +213,23 @@ export const arc: MovementStrategy = {
/**
* `figureEight` — traces a Gerono lemniscate (a figure-eight) around the
* start point over one full period, so it returns to the origin. Amplitude
* scales with `reach`.
* 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<Point> {
const { start, config } = ctx;
const amp: number = reachOf(config) / 2;
for (let i = 1; i <= config.stepCount; i++) {
const t: number = (2 * Math.PI * i) / config.stepCount;
const { start } = ctx;
for (let i = 1; i <= FIG8_STEPS; i++) {
const t: number = (2 * Math.PI * i) / FIG8_STEPS;
yield {
x: start.x + amp * Math.sin(t),
y: start.y + amp * Math.sin(t) * Math.cos(t),
x: start.x + FIG8_AMP * Math.sin(t),
y: start.y + FIG8_AMP * Math.sin(t) * Math.cos(t),
};
}
},