void_crawl_core 0.3.7

Rust-native CDP browser automation core — stealth-patched headless Chrome, profile leasing, captcha detection
Documentation
//! Humanized pointer paths for CDP mouse input (CAS-147).
//!
//! Pure, dependency-free generation of a realistic cursor trajectory between
//! two points, emitted as a sequence of `Input.dispatchMouseEvent` `MouseMoved`
//! steps before the press. The path has **non-linear (arc) curvature**, a
//! **minimum-jerk** velocity profile (slow start/stop), small **tremor**, and
//! an optional **dwell** on the target — the traits a managed challenge looks
//! for in real pointer input. No page-world JS is injected; only CDP events.
//!
//! Generation is deterministic under a seeded [`Rng`] so it can be unit-tested
//! exactly; the live path seeds from wall-clock entropy at the call site.

#![allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]

/// One step along a humanized path: an absolute target plus the delay to wait
/// *before* dispatching it (ms).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PathStep {
    pub x:        f64,
    pub y:        f64,
    pub delay_ms: u64,
}

/// Shape/timing knobs for [`humanized_path`].
#[derive(Debug, Clone, Copy)]
pub struct HumanizeOptions {
    /// Max perpendicular bow of the arc, in px (actual is randomized within ±).
    pub curve:           f64,
    /// Per-step tremor amplitude, in px (damped toward the target).
    pub tremor:          f64,
    /// Pause on the target before the press, in ms (a final dwell step).
    pub dwell_ms:        u64,
    /// Total movement duration floor, in ms.
    pub min_duration_ms: f64,
    /// Total movement duration ceiling, in ms — keeps agent workflows bounded.
    pub max_duration_ms: f64,
}

impl Default for HumanizeOptions {
    fn default() -> Self {
        Self {
            curve:           22.0,
            tremor:          1.1,
            dwell_ms:        55,
            min_duration_ms: 120.0,
            max_duration_ms: 900.0,
        }
    }
}

/// SplitMix64 — a tiny deterministic PRNG. Avoids a `rand` dependency and makes
/// path generation reproducible from a seed (for tests).
#[derive(Debug, Clone)]
pub struct Rng(u64);

impl Rng {
    #[must_use]
    pub fn seed(seed: u64) -> Self {
        Self(seed)
    }

    fn next_u64(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = self.0;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^ (z >> 31)
    }

    /// Uniform in `[0, 1)`.
    fn unit(&mut self) -> f64 {
        // 53-bit mantissa for an even distribution.
        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
    }

    /// Uniform in `[lo, hi)`.
    fn range(&mut self, lo: f64, hi: f64) -> f64 {
        lo + (hi - lo) * self.unit()
    }
}

/// Minimum-jerk easing `6t^5 - 15t^4 + 10t^3` (smootherstep): zero velocity and
/// acceleration at both ends, like a real hand starting and stopping.
fn min_jerk(t: f64) -> f64 {
    t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
}

/// Generate a humanized cursor path from `start` to `end`.
///
/// Returns at least a couple of `MouseMoved` steps (more for longer travel),
/// the **last of which lands exactly on `end`**, optionally followed by a dwell
/// step (same coords, `dwell_ms`). Total of all `delay_ms` is bounded by
/// `opts.max_duration_ms` (+ the dwell).
#[must_use]
pub fn humanized_path(
    start: (f64, f64),
    end: (f64, f64),
    opts: &HumanizeOptions,
    rng: &mut Rng,
) -> Vec<PathStep> {
    let (x0, y0) = start;
    let (x1, y1) = end;
    let (dx, dy) = (x1 - x0, y1 - y0);
    let dist = dx.hypot(dy);

    // Step count and duration both scale with distance, then clamp.
    let steps = ((dist / 9.0).round() as usize).clamp(6, 40);
    let duration =
        (opts.min_duration_ms + dist * 1.6).clamp(opts.min_duration_ms, opts.max_duration_ms);

    // Perpendicular unit vector for the arc bow (zero for a zero-length move).
    let (nx, ny) = if dist > 1e-6 { (-dy / dist, dx / dist) } else { (0.0, 0.0) };
    let bow = rng.range(-opts.curve, opts.curve);

    let mut out = Vec::with_capacity(steps + 1);
    let mut prev_e = 0.0_f64;
    for i in 1..=steps {
        let t = i as f64 / steps as f64;
        let e = min_jerk(t);
        // Arc weight peaks at the midpoint, vanishes at both ends.
        let arc = bow * (1.0 - (2.0 * e - 1.0).powi(2));
        // Tremor: damped toward the target for a clean final landing.
        let damp = 1.0 - 0.65 * e;
        let jx = rng.range(-opts.tremor, opts.tremor) * damp;
        let jy = rng.range(-opts.tremor, opts.tremor) * damp;
        // Per-step delay follows the same min-jerk profile (fast in the middle).
        let delay = ((e - prev_e) * duration).max(1.0).round() as u64;
        prev_e = e;
        out.push(PathStep {
            x:        x0 + dx * e + nx * arc + jx,
            y:        y0 + dy * e + ny * arc + jy,
            delay_ms: delay,
        });
    }
    // Land exactly on target (no residual tremor on the final position).
    if let Some(last) = out.last_mut() {
        last.x = x1;
        last.y = y1;
    }
    // Optional dwell on the target before the press.
    if opts.dwell_ms > 0 {
        out.push(PathStep { x: x1, y: y1, delay_ms: opts.dwell_ms });
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    const A: (f64, f64) = (0.0, 0.0);
    const B: (f64, f64) = (300.0, 200.0);

    #[test]
    fn seeded_generation_is_deterministic() {
        let opts = HumanizeOptions::default();
        let p1 = humanized_path(A, B, &opts, &mut Rng::seed(42));
        let p2 = humanized_path(A, B, &opts, &mut Rng::seed(42));
        assert_eq!(p1, p2);
    }

    #[test]
    fn different_seeds_differ() {
        let opts = HumanizeOptions::default();
        let p1 = humanized_path(A, B, &opts, &mut Rng::seed(1));
        let p2 = humanized_path(A, B, &opts, &mut Rng::seed(2));
        assert_ne!(p1, p2);
    }

    #[test]
    fn emits_multiple_moves_and_lands_exactly_on_target() {
        let opts = HumanizeOptions::default();
        let path = humanized_path(A, B, &opts, &mut Rng::seed(7));
        // Several moves before the press (dwell is the last step here).
        assert!(path.len() >= 6, "got {} steps", path.len());
        // The move that places the cursor on target is exact.
        let move_steps = &path[..path.len() - 1];
        assert!(!move_steps.is_empty(), "humanized path should include movement steps");
        let landing = move_steps[move_steps.len() - 1];
        assert!((landing.x - B.0).abs() < 1e-9 && (landing.y - B.1).abs() < 1e-9);
    }

    #[test]
    fn duration_is_bounded() {
        let opts = HumanizeOptions::default();
        // A very long travel still stays within the ceiling (+ dwell).
        let path = humanized_path((0.0, 0.0), (5000.0, 5000.0), &opts, &mut Rng::seed(3));
        let total: u64 = path.iter().map(|s| s.delay_ms).sum();
        let ceiling = opts.max_duration_ms as u64 + opts.dwell_ms + path.len() as u64;
        assert!(total <= ceiling, "total {total} > ceiling {ceiling}");
    }

    #[test]
    fn path_is_curved_not_straight() {
        // A no-tremor, fixed-bow path should bow off the straight line at the mid.
        let opts = HumanizeOptions { tremor: 0.0, dwell_ms: 0, ..Default::default() };
        let path = humanized_path(A, B, &opts, &mut Rng::seed(11));
        let mid = path[path.len() / 2];
        // Distance of the midpoint from the straight A→B line.
        let (dx, dy) = (B.0 - A.0, B.1 - A.1);
        let len = dx.hypot(dy);
        let dev = ((mid.x - A.0) * dy - (mid.y - A.1) * dx).abs() / len;
        assert!(dev > 1.0, "midpoint deviation {dev} too small — path is ~straight");
    }
}