Initial project structure: reusable isometric bot engine with D2R implementation

This commit is contained in:
Hoid 2026-02-14 08:50:36 +00:00
commit e0282a7111
44 changed files with 3433 additions and 0 deletions

23
engine/input/__init__.py Normal file
View file

@ -0,0 +1,23 @@
"""Human-like input simulation for mouse and keyboard interactions.
This module provides tools for generating realistic input patterns that mimic
human behavior, including natural mouse movement curves and timing variations.
Components:
- mouse: Human-like mouse movement with Bézier curves
- keyboard: Keyboard input with realistic timing patterns
- humanize: Central controller for randomized, human-like interactions
"""
from .mouse import MouseController, MousePath
from .keyboard import KeyboardController, KeySequence
from .humanize import HumanInput, InputConfig
__all__ = [
"MouseController",
"MousePath",
"KeyboardController",
"KeySequence",
"HumanInput",
"InputConfig",
]

112
engine/input/humanize.py Normal file
View file

@ -0,0 +1,112 @@
"""Human-like behavior patterns for input simulation.
Provides randomization utilities to make bot inputs appear natural,
including variable delays, mouse jitter, and activity scheduling.
"""
import random
import time
import logging
from typing import Tuple, Optional
from dataclasses import dataclass, field
logger = logging.getLogger(__name__)
@dataclass
class HumanProfile:
"""Defines a human behavior profile for input randomization."""
# Reaction time range in seconds
reaction_min: float = 0.15
reaction_max: float = 0.45
# Mouse movement speed range (pixels per second)
mouse_speed_min: float = 400.0
mouse_speed_max: float = 1200.0
# Click position jitter in pixels
click_jitter: int = 3
# Chance of double-reading (hesitation before action)
hesitation_chance: float = 0.1
hesitation_duration: Tuple[float, float] = (0.3, 1.2)
# Break scheduling
micro_break_interval: Tuple[int, int] = (120, 300) # seconds
micro_break_duration: Tuple[int, int] = (2, 8) # seconds
long_break_interval: Tuple[int, int] = (1800, 3600) # seconds
long_break_duration: Tuple[int, int] = (60, 300) # seconds
class Humanizer:
"""Applies human-like randomization to bot actions."""
def __init__(self, profile: Optional[HumanProfile] = None):
self.profile = profile or HumanProfile()
self._last_micro_break = time.time()
self._last_long_break = time.time()
self._next_micro_break = self._schedule_break(self.profile.micro_break_interval)
self._next_long_break = self._schedule_break(self.profile.long_break_interval)
self._action_count = 0
def reaction_delay(self) -> float:
"""Generate a human-like reaction delay."""
base = random.uniform(self.profile.reaction_min, self.profile.reaction_max)
# Occasionally add hesitation
if random.random() < self.profile.hesitation_chance:
base += random.uniform(*self.profile.hesitation_duration)
# Slight fatigue factor based on actions performed
fatigue = min(self._action_count / 1000, 0.3)
base *= (1 + fatigue * random.random())
return base
def jitter_position(self, x: int, y: int) -> Tuple[int, int]:
"""Add small random offset to click position."""
jitter = self.profile.click_jitter
return (
x + random.randint(-jitter, jitter),
y + random.randint(-jitter, jitter),
)
def mouse_speed(self) -> float:
"""Get randomized mouse movement speed."""
return random.uniform(
self.profile.mouse_speed_min,
self.profile.mouse_speed_max,
)
def should_take_break(self) -> Optional[float]:
"""Check if it's time for a break. Returns break duration or None."""
now = time.time()
if now >= self._next_long_break:
duration = random.uniform(*self.profile.long_break_duration)
self._next_long_break = now + duration + self._schedule_break(
self.profile.long_break_interval
)
logger.info(f"Long break: {duration:.0f}s")
return duration
if now >= self._next_micro_break:
duration = random.uniform(*self.profile.micro_break_duration)
self._next_micro_break = now + duration + self._schedule_break(
self.profile.micro_break_interval
)
logger.debug(f"Micro break: {duration:.1f}s")
return duration
return None
def wait(self) -> None:
"""Wait for a human-like reaction delay."""
delay = self.reaction_delay()
time.sleep(delay)
self._action_count += 1
def _schedule_break(self, interval: Tuple[int, int]) -> float:
"""Schedule next break with randomized interval."""
return time.time() + random.uniform(*interval)

368
engine/input/keyboard.py Normal file
View file

@ -0,0 +1,368 @@
"""Human-like keyboard input simulation with realistic timing.
Provides keyboard input with natural typing patterns, including
varied keystroke timing and realistic human typing characteristics.
"""
from typing import List, Dict, Optional, Union
from dataclasses import dataclass
import time
import random
import logging
import pyautogui
from pynput import keyboard
logger = logging.getLogger(__name__)
@dataclass
class KeySequence:
"""Represents a sequence of keys with timing information."""
keys: List[str]
delays: List[float]
total_duration: float
class TypingProfile:
"""Defines typing characteristics for human-like input."""
def __init__(self, wpm: int = 60, accuracy: float = 0.95):
"""Initialize typing profile.
Args:
wpm: Words per minute typing speed
accuracy: Typing accuracy (0.0 to 1.0)
"""
self.wpm = wpm
self.accuracy = accuracy
# Calculate base timing from WPM (assuming 5 characters per word)
chars_per_minute = wpm * 5
self.base_char_delay = 60.0 / chars_per_minute
# Timing variations
self.min_delay = 0.02 # Minimum delay between keys
self.max_delay = 0.5 # Maximum delay between keys
self.word_pause = 0.1 # Additional pause after spaces
# Common typing patterns
self.difficult_sequences = {
'th', 'ch', 'sh', 'qu', 'tion', 'ing', 'er', 'ed'
}
# Keys that typically take longer to find
self.slow_keys = {
'q', 'z', 'x', 'j', 'k', 'shift', 'ctrl', 'alt'
}
class KeyboardController:
"""Controller for human-like keyboard input."""
def __init__(self, typing_profile: Optional[TypingProfile] = None):
"""Initialize keyboard controller.
Args:
typing_profile: Typing characteristics, or None for default
"""
self.profile = typing_profile or TypingProfile()
self.shift_held = False
self.ctrl_held = False
self.alt_held = False
# Key mapping for special keys
self.key_mapping = {
'enter': '\n',
'return': '\n',
'tab': '\t',
'space': ' ',
'backspace': 'backspace',
'delete': 'delete',
'escape': 'esc',
'esc': 'esc',
}
def calculate_key_delay(self, key: str, previous_key: Optional[str] = None) -> float:
"""Calculate realistic delay for typing a key.
Args:
key: Key to type
previous_key: Previously typed key for sequence analysis
Returns:
Delay in seconds before typing this key
"""
# Base delay from typing speed
delay = self.profile.base_char_delay
# Adjust for key difficulty
if key.lower() in self.profile.slow_keys:
delay *= random.uniform(1.2, 1.8)
# Adjust for difficult sequences
if previous_key:
sequence = previous_key.lower() + key.lower()
if any(seq in sequence for seq in self.profile.difficult_sequences):
delay *= random.uniform(1.1, 1.5)
# Add natural variation
delay *= random.uniform(0.7, 1.4)
# Extra pause after spaces (word boundaries)
if previous_key and previous_key == ' ':
delay += self.profile.word_pause * random.uniform(0.5, 1.5)
# Clamp to reasonable bounds
return max(self.profile.min_delay, min(self.profile.max_delay, delay))
def type_text(self, text: str, include_errors: bool = False) -> None:
"""Type text with human-like timing and optional errors.
Args:
text: Text to type
include_errors: Whether to include typing errors and corrections
"""
if not text:
return
logger.debug(f"Typing text: '{text[:50]}{'...' if len(text) > 50 else ''}'")
previous_key = None
for i, char in enumerate(text):
# Calculate delay before this character
delay = self.calculate_key_delay(char, previous_key)
# Sleep before typing
time.sleep(delay)
# Occasionally make typing errors if enabled
if include_errors and self.should_make_error():
self.make_typing_error(char)
else:
self.type_key(char)
previous_key = char
def should_make_error(self) -> bool:
"""Determine if a typing error should be made.
Returns:
True if an error should be made
"""
return random.random() > self.profile.accuracy
def make_typing_error(self, intended_key: str) -> None:
"""Make a typing error and correct it.
Args:
intended_key: The key that was supposed to be typed
"""
# Type wrong key (usually adjacent on keyboard)
wrong_key = self.get_adjacent_key(intended_key)
self.type_key(wrong_key)
# Pause as human realizes mistake
time.sleep(random.uniform(0.1, 0.4))
# Backspace to correct
self.type_key('backspace')
time.sleep(random.uniform(0.05, 0.15))
# Type correct key
self.type_key(intended_key)
def get_adjacent_key(self, key: str) -> str:
"""Get an adjacent key for typing errors.
Args:
key: Original key
Returns:
Adjacent key that could be mistyped
"""
# Simplified adjacent key mapping
adjacent_map = {
'a': 'sq', 'b': 'vgn', 'c': 'xvd', 'd': 'sfe', 'e': 'wrd',
'f': 'dgr', 'g': 'fht', 'h': 'gyu', 'i': 'uko', 'j': 'hnu',
'k': 'jmo', 'l': 'kpo', 'm': 'njk', 'n': 'bhm', 'o': 'ilp',
'p': 'olo', 'q': 'wa', 'r': 'etf', 's': 'adw', 't': 'rgy',
'u': 'yhi', 'v': 'cfg', 'w': 'qse', 'x': 'zdc', 'y': 'tgu',
'z': 'xas'
}
adjacent_keys = adjacent_map.get(key.lower(), 'abcd')
return random.choice(adjacent_keys)
def type_key(self, key: str) -> None:
"""Type a single key.
Args:
key: Key to type
"""
# Handle special keys
if key.lower() in self.key_mapping:
mapped_key = self.key_mapping[key.lower()]
if mapped_key in ['backspace', 'delete', 'esc']:
pyautogui.press(mapped_key)
else:
pyautogui.write(mapped_key)
else:
pyautogui.write(key)
def press_key_combination(self, *keys: str) -> None:
"""Press a combination of keys (e.g., Ctrl+C).
Args:
keys: Keys to press together
"""
logger.debug(f"Pressing key combination: {'+'.join(keys)}")
# Press all keys down
for key in keys:
pyautogui.keyDown(key)
time.sleep(random.uniform(0.01, 0.03))
# Hold briefly
time.sleep(random.uniform(0.05, 0.1))
# Release all keys (in reverse order)
for key in reversed(keys):
pyautogui.keyUp(key)
time.sleep(random.uniform(0.01, 0.03))
def press_key(self, key: str, duration: Optional[float] = None) -> None:
"""Press and release a key.
Args:
key: Key to press
duration: How long to hold key, or None for quick press
"""
if duration is None:
pyautogui.press(key)
else:
pyautogui.keyDown(key)
time.sleep(duration)
pyautogui.keyUp(key)
def hold_key(self, key: str) -> None:
"""Start holding a key down.
Args:
key: Key to hold
"""
pyautogui.keyDown(key)
# Track modifier keys
if key.lower() == 'shift':
self.shift_held = True
elif key.lower() in ['ctrl', 'control']:
self.ctrl_held = True
elif key.lower() == 'alt':
self.alt_held = True
def release_key(self, key: str) -> None:
"""Stop holding a key.
Args:
key: Key to release
"""
pyautogui.keyUp(key)
# Track modifier keys
if key.lower() == 'shift':
self.shift_held = False
elif key.lower() in ['ctrl', 'control']:
self.ctrl_held = False
elif key.lower() == 'alt':
self.alt_held = False
def release_all_keys(self) -> None:
"""Release all held modifier keys."""
if self.shift_held:
self.release_key('shift')
if self.ctrl_held:
self.release_key('ctrl')
if self.alt_held:
self.release_key('alt')
def type_number_sequence(self, numbers: Union[str, int],
use_numpad: bool = False) -> None:
"""Type a sequence of numbers.
Args:
numbers: Numbers to type
use_numpad: Whether to use numpad keys
"""
number_str = str(numbers)
for digit in number_str:
if digit.isdigit():
if use_numpad:
key = f'num{digit}'
else:
key = digit
self.type_key(key)
time.sleep(self.calculate_key_delay(digit))
def simulate_pause(self, pause_type: str = 'thinking') -> None:
"""Simulate natural pauses in typing.
Args:
pause_type: Type of pause ('thinking', 'reading', 'short')
"""
if pause_type == 'thinking':
duration = random.uniform(0.5, 2.0)
elif pause_type == 'reading':
duration = random.uniform(0.2, 0.8)
else: # short
duration = random.uniform(0.1, 0.3)
logger.debug(f"Simulating {pause_type} pause for {duration:.2f}s")
time.sleep(duration)
def generate_key_sequence(self, text: str) -> KeySequence:
"""Generate a key sequence with timing for given text.
Args:
text: Text to generate sequence for
Returns:
KeySequence with keys and delays
"""
keys = list(text)
delays = []
total_duration = 0.0
previous_key = None
for key in keys:
delay = self.calculate_key_delay(key, previous_key)
delays.append(delay)
total_duration += delay
previous_key = key
return KeySequence(keys, delays, total_duration)
def set_typing_speed(self, wpm: int) -> None:
"""Set typing speed.
Args:
wpm: Words per minute
"""
self.profile.wpm = max(10, min(200, wpm))
chars_per_minute = self.profile.wpm * 5
self.profile.base_char_delay = 60.0 / chars_per_minute
logger.info(f"Typing speed set to {self.profile.wpm} WPM")
def set_accuracy(self, accuracy: float) -> None:
"""Set typing accuracy.
Args:
accuracy: Accuracy from 0.0 to 1.0
"""
self.profile.accuracy = max(0.0, min(1.0, accuracy))
logger.info(f"Typing accuracy set to {self.profile.accuracy * 100:.1f}%")

345
engine/input/mouse.py Normal file
View file

@ -0,0 +1,345 @@
"""Human-like mouse movement and clicking with Bézier curves.
Provides realistic mouse movement patterns using Bézier curves with
randomized control points and natural acceleration/deceleration.
"""
from typing import Tuple, List, Optional, Callable
from dataclasses import dataclass
import time
import math
import random
import logging
import pyautogui
import numpy as np
logger = logging.getLogger(__name__)
# Disable pyautogui failsafe for production use
pyautogui.FAILSAFE = False
@dataclass
class MousePath:
"""Represents a mouse movement path with timing."""
points: List[Tuple[int, int]]
delays: List[float]
total_duration: float
class BezierCurve:
"""Bézier curve generation for natural mouse movement."""
@staticmethod
def cubic_bezier(t: float, p0: Tuple[float, float], p1: Tuple[float, float],
p2: Tuple[float, float], p3: Tuple[float, float]) -> Tuple[float, float]:
"""Calculate point on cubic Bézier curve at parameter t.
Args:
t: Parameter from 0 to 1
p0: Start point
p1: First control point
p2: Second control point
p3: End point
Returns:
(x, y) point on curve
"""
x = (1-t)**3 * p0[0] + 3*(1-t)**2*t * p1[0] + 3*(1-t)*t**2 * p2[0] + t**3 * p3[0]
y = (1-t)**3 * p0[1] + 3*(1-t)**2*t * p1[1] + 3*(1-t)*t**2 * p2[1] + t**3 * p3[1]
return (x, y)
@staticmethod
def generate_control_points(start: Tuple[int, int], end: Tuple[int, int],
randomness: float = 0.3) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""Generate random control points for natural curve.
Args:
start: Starting position
end: Ending position
randomness: Amount of randomness (0.0 to 1.0)
Returns:
Tuple of two control points
"""
dx = end[0] - start[0]
dy = end[1] - start[1]
distance = math.sqrt(dx*dx + dy*dy)
# Control point offset based on distance and randomness
offset_magnitude = distance * randomness * random.uniform(0.2, 0.8)
# Random angles for control points
angle1 = random.uniform(-math.pi, math.pi)
angle2 = random.uniform(-math.pi, math.pi)
# First control point (closer to start)
cp1_x = start[0] + dx * 0.25 + math.cos(angle1) * offset_magnitude
cp1_y = start[1] + dy * 0.25 + math.sin(angle1) * offset_magnitude
# Second control point (closer to end)
cp2_x = start[0] + dx * 0.75 + math.cos(angle2) * offset_magnitude
cp2_y = start[1] + dy * 0.75 + math.sin(angle2) * offset_magnitude
return ((cp1_x, cp1_y), (cp2_x, cp2_y))
class MouseController:
"""Controller for human-like mouse interactions."""
def __init__(self):
"""Initialize mouse controller."""
self.current_pos = pyautogui.position()
self.movement_speed = 1.0 # Multiplier for movement speed
self.click_variance = 3 # Pixel variance for click positions
# Movement timing parameters
self.min_duration = 0.1 # Minimum movement time
self.max_duration = 1.5 # Maximum movement time
self.base_speed = 1000 # Base pixels per second
def get_current_position(self) -> Tuple[int, int]:
"""Get current mouse position.
Returns:
(x, y) tuple of current position
"""
self.current_pos = pyautogui.position()
return self.current_pos
def calculate_movement_duration(self, start: Tuple[int, int],
end: Tuple[int, int]) -> float:
"""Calculate realistic movement duration based on distance.
Args:
start: Starting position
end: Ending position
Returns:
Movement duration in seconds
"""
dx = end[0] - start[0]
dy = end[1] - start[1]
distance = math.sqrt(dx*dx + dy*dy)
# Fitts' Law inspired calculation
# Time increases logarithmically with distance
base_time = distance / (self.base_speed * self.movement_speed)
fitts_factor = math.log2(1 + distance / 10) / 10
duration = base_time + fitts_factor
# Add some randomness
duration *= random.uniform(0.8, 1.2)
# Clamp to reasonable bounds
return max(self.min_duration, min(self.max_duration, duration))
def generate_movement_path(self, start: Tuple[int, int], end: Tuple[int, int],
duration: Optional[float] = None,
steps: Optional[int] = None) -> MousePath:
"""Generate Bézier curve path for mouse movement.
Args:
start: Starting position
end: Ending position
duration: Movement duration, or None to calculate
steps: Number of steps, or None to calculate
Returns:
MousePath with points and timing
"""
if duration is None:
duration = self.calculate_movement_duration(start, end)
if steps is None:
# Calculate steps based on distance and duration
distance = math.sqrt((end[0] - start[0])**2 + (end[1] - start[1])**2)
steps = max(10, int(distance / 10)) # Roughly 10 pixels per step
# Generate control points
cp1, cp2 = BezierCurve.generate_control_points(start, end)
# Generate path points
points = []
delays = []
for i in range(steps + 1):
t = i / steps
# Use ease-in-out curve for timing
timing_t = self._ease_in_out(t)
# Calculate position on Bézier curve
x, y = BezierCurve.cubic_bezier(timing_t, start, cp1, cp2, end)
points.append((int(x), int(y)))
# Calculate delay for this step
if i < steps:
delay = duration / steps
# Add small random variation
delay *= random.uniform(0.8, 1.2)
delays.append(delay)
return MousePath(points, delays, duration)
def move_to(self, target: Tuple[int, int], duration: Optional[float] = None) -> None:
"""Move mouse to target position using Bézier curve.
Args:
target: Target (x, y) position
duration: Movement duration, or None to calculate
"""
start = self.get_current_position()
path = self.generate_movement_path(start, target, duration)
logger.debug(f"Moving mouse from {start} to {target} in {path.total_duration:.2f}s")
for i, point in enumerate(path.points[1:], 1):
pyautogui.moveTo(point[0], point[1], duration=0)
if i <= len(path.delays):
time.sleep(path.delays[i-1])
self.current_pos = target
def click(self, position: Optional[Tuple[int, int]] = None,
button: str = 'left', move_first: bool = True) -> None:
"""Click at specified position with human-like variation.
Args:
position: Click position, or None for current position
button: Mouse button ('left', 'right', 'middle')
move_first: Whether to move to position first
"""
if position is None:
position = self.get_current_position()
else:
# Add small random offset for more human-like clicking
offset_x = random.randint(-self.click_variance, self.click_variance)
offset_y = random.randint(-self.click_variance, self.click_variance)
position = (position[0] + offset_x, position[1] + offset_y)
if move_first and position != self.get_current_position():
self.move_to(position)
# Random pre-click delay
time.sleep(random.uniform(0.01, 0.05))
logger.debug(f"Clicking {button} button at {position}")
pyautogui.click(position[0], position[1], button=button)
# Random post-click delay
time.sleep(random.uniform(0.01, 0.08))
def double_click(self, position: Optional[Tuple[int, int]] = None,
move_first: bool = True) -> None:
"""Double-click at specified position.
Args:
position: Click position, or None for current position
move_first: Whether to move to position first
"""
if position is None:
position = self.get_current_position()
if move_first and position != self.get_current_position():
self.move_to(position)
# Random delay before double-click
time.sleep(random.uniform(0.01, 0.05))
logger.debug(f"Double-clicking at {position}")
pyautogui.doubleClick(position[0], position[1])
# Random delay after double-click
time.sleep(random.uniform(0.05, 0.1))
def drag(self, start: Tuple[int, int], end: Tuple[int, int],
button: str = 'left', duration: Optional[float] = None) -> None:
"""Drag from start to end position.
Args:
start: Starting position
end: Ending position
button: Mouse button to drag with
duration: Drag duration, or None to calculate
"""
# Move to start position
self.move_to(start)
# Mouse down
time.sleep(random.uniform(0.01, 0.03))
pyautogui.mouseDown(start[0], start[1], button=button)
# Wait briefly before starting drag
time.sleep(random.uniform(0.05, 0.1))
# Generate drag path
path = self.generate_movement_path(start, end, duration)
logger.debug(f"Dragging from {start} to {end}")
# Execute drag movement
for i, point in enumerate(path.points[1:], 1):
pyautogui.moveTo(point[0], point[1], duration=0)
if i <= len(path.delays):
time.sleep(path.delays[i-1])
# Mouse up
time.sleep(random.uniform(0.01, 0.03))
pyautogui.mouseUp(end[0], end[1], button=button)
self.current_pos = end
def scroll(self, clicks: int, position: Optional[Tuple[int, int]] = None) -> None:
"""Scroll at specified position.
Args:
clicks: Number of scroll clicks (positive = up, negative = down)
position: Scroll position, or None for current position
"""
if position is not None and position != self.get_current_position():
self.move_to(position)
# Random delay before scrolling
time.sleep(random.uniform(0.05, 0.15))
# Scroll with small delays between clicks for more human-like behavior
for i in range(abs(clicks)):
scroll_direction = 1 if clicks > 0 else -1
pyautogui.scroll(scroll_direction)
if i < abs(clicks) - 1: # Don't delay after last scroll
time.sleep(random.uniform(0.02, 0.08))
def _ease_in_out(self, t: float) -> float:
"""Ease-in-out function for smooth acceleration/deceleration.
Args:
t: Input parameter (0 to 1)
Returns:
Eased parameter (0 to 1)
"""
return t * t * (3.0 - 2.0 * t)
def set_movement_speed(self, speed: float) -> None:
"""Set movement speed multiplier.
Args:
speed: Speed multiplier (1.0 = normal, 2.0 = double speed, etc.)
"""
self.movement_speed = max(0.1, min(5.0, speed))
logger.info(f"Mouse movement speed set to {self.movement_speed}x")
def set_click_variance(self, variance: int) -> None:
"""Set click position variance in pixels.
Args:
variance: Maximum pixel offset for clicks
"""
self.click_variance = max(0, min(10, variance))
logger.info(f"Click variance set to {self.click_variance} pixels")