Rewrite to Go: engine, plugin system, D2R plugin, API, loot filter

This commit is contained in:
Hoid 2026-02-14 09:43:39 +00:00
parent e0282a7111
commit 3b363192f2
60 changed files with 1576 additions and 3407 deletions

View file

@ -0,0 +1,99 @@
// Package capture provides screen capture from various sources.
//
// Supports capturing from:
// - Local window (by title or handle)
// - VM display (VNC, Spice, or VM window on host)
// - Full screen / monitor region
//
// The capture interface is source-agnostic — the engine doesn't care
// where the frames come from.
package capture
import (
"image"
"time"
)
// Region defines a rectangular area to capture.
type Region struct {
X, Y, Width, Height int
}
// Source represents a capture source (window, VM, screen, etc.)
type Source interface {
// Name returns a human-readable description of the source.
Name() string
// Capture grabs a single frame.
Capture() (image.Image, error)
// CaptureRegion grabs a sub-region of the source.
CaptureRegion(r Region) (image.Image, error)
// Size returns the source dimensions.
Size() (width, height int)
// Close releases resources.
Close() error
}
// Stats tracks capture performance metrics.
type Stats struct {
FrameCount uint64
AvgCaptureMs float64
FPS float64
LastCapture time.Time
}
// Manager handles screen capture with performance tracking.
type Manager struct {
source Source
stats Stats
}
// NewManager creates a capture manager with the given source.
func NewManager(source Source) *Manager {
return &Manager{source: source}
}
// Capture grabs a frame and updates performance stats.
func (m *Manager) Capture() (image.Image, error) {
start := time.Now()
frame, err := m.source.Capture()
if err != nil {
return nil, err
}
elapsed := time.Since(start)
m.stats.FrameCount++
m.stats.LastCapture = start
// Rolling average
alpha := 0.1
ms := float64(elapsed.Microseconds()) / 1000.0
if m.stats.FrameCount == 1 {
m.stats.AvgCaptureMs = ms
} else {
m.stats.AvgCaptureMs = m.stats.AvgCaptureMs*(1-alpha) + ms*alpha
}
if m.stats.AvgCaptureMs > 0 {
m.stats.FPS = 1000.0 / m.stats.AvgCaptureMs
}
return frame, nil
}
// CaptureRegion grabs a sub-region.
func (m *Manager) CaptureRegion(r Region) (image.Image, error) {
return m.source.CaptureRegion(r)
}
// Stats returns current capture performance stats.
func (m *Manager) Stats() Stats {
return m.stats
}
// Close releases the capture source.
func (m *Manager) Close() error {
return m.source.Close()
}

View file

@ -0,0 +1,103 @@
// Package input — humanize.go provides human-like behavior randomization.
package input
import (
"image"
"math/rand"
"sync/atomic"
"time"
)
// HumanProfile defines behavior parameters for human-like input.
type HumanProfile struct {
ReactionMin time.Duration // min reaction delay
ReactionMax time.Duration // max reaction delay
MouseSpeedMin float64 // min pixels per second
MouseSpeedMax float64 // max pixels per second
ClickJitter int // pixel jitter on clicks
HesitationChance float64 // chance of extra pause (0-1)
HesitationMin time.Duration
HesitationMax time.Duration
}
// DefaultProfile returns a realistic human behavior profile.
func DefaultProfile() HumanProfile {
return HumanProfile{
ReactionMin: 150 * time.Millisecond,
ReactionMax: 450 * time.Millisecond,
MouseSpeedMin: 400,
MouseSpeedMax: 1200,
ClickJitter: 3,
HesitationChance: 0.1,
HesitationMin: 300 * time.Millisecond,
HesitationMax: 1200 * time.Millisecond,
}
}
// Humanizer applies human-like randomization to bot actions.
type Humanizer struct {
Profile HumanProfile
actionCount atomic.Int64
}
// NewHumanizer creates a humanizer with the given profile.
func NewHumanizer(profile HumanProfile) *Humanizer {
return &Humanizer{Profile: profile}
}
// ReactionDelay returns a randomized human-like reaction delay.
func (h *Humanizer) ReactionDelay() time.Duration {
minMs := h.Profile.ReactionMin.Milliseconds()
maxMs := h.Profile.ReactionMax.Milliseconds()
base := time.Duration(minMs+rand.Int63n(maxMs-minMs)) * time.Millisecond
// Occasional hesitation
if rand.Float64() < h.Profile.HesitationChance {
hesMinMs := h.Profile.HesitationMin.Milliseconds()
hesMaxMs := h.Profile.HesitationMax.Milliseconds()
base += time.Duration(hesMinMs+rand.Int63n(hesMaxMs-hesMinMs)) * time.Millisecond
}
// Slight fatigue factor
actions := float64(h.actionCount.Load())
fatigue := min(actions/1000.0, 0.3)
base = time.Duration(float64(base) * (1 + fatigue*rand.Float64()))
return base
}
// Wait pauses for a human-like reaction delay.
func (h *Humanizer) Wait() {
time.Sleep(h.ReactionDelay())
h.actionCount.Add(1)
}
// WaitMs pauses for baseMs ± varianceMs with human randomization.
func (h *Humanizer) WaitMs(baseMs, varianceMs int) {
actual := baseMs + rand.Intn(2*varianceMs+1) - varianceMs
if actual < 0 {
actual = 0
}
time.Sleep(time.Duration(actual) * time.Millisecond)
}
// JitterPosition adds random offset to a click position.
func (h *Humanizer) JitterPosition(pos image.Point) image.Point {
j := h.Profile.ClickJitter
return image.Point{
X: pos.X + rand.Intn(2*j+1) - j,
Y: pos.Y + rand.Intn(2*j+1) - j,
}
}
// MouseSpeed returns a randomized mouse movement speed.
func (h *Humanizer) MouseSpeed() float64 {
return h.Profile.MouseSpeedMin + rand.Float64()*(h.Profile.MouseSpeedMax-h.Profile.MouseSpeedMin)
}
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}

162
pkg/engine/input/input.go Normal file
View file

@ -0,0 +1,162 @@
// Package input provides human-like mouse and keyboard simulation.
//
// Mouse movement uses Bézier curves with natural acceleration.
// All inputs include randomized timing to mimic human behavior.
// Platform-specific backends: SendInput (Windows), X11/uinput (Linux).
package input
import (
"image"
"math"
"math/rand"
"time"
)
// MouseController handles human-like mouse movement and clicks.
type MouseController struct {
humanizer *Humanizer
}
// NewMouseController creates a mouse controller with the given humanizer.
func NewMouseController(h *Humanizer) *MouseController {
return &MouseController{humanizer: h}
}
// MoveTo moves the mouse to target using a Bézier curve.
func (m *MouseController) MoveTo(target image.Point) {
// Get current position
current := m.GetPosition()
// Generate Bézier control points
points := m.bezierPath(current, target)
// Animate along the path
speed := m.humanizer.MouseSpeed()
totalDist := m.pathLength(points)
steps := int(totalDist / speed * 1000) // ms-based steps
if steps < 5 {
steps = 5
}
for i := 1; i <= steps; i++ {
t := float64(i) / float64(steps)
// Ease in-out for natural acceleration
t = m.easeInOut(t)
p := m.evalBezier(points, t)
m.setPosition(p)
time.Sleep(time.Millisecond)
}
}
// Click performs a mouse click at the current position.
func (m *MouseController) Click() {
m.humanizer.Wait()
// TODO: Platform-specific click (SendInput / X11)
// Randomize hold duration
holdMs := 50 + rand.Intn(80)
time.Sleep(time.Duration(holdMs) * time.Millisecond)
}
// ClickAt moves to position and clicks.
func (m *MouseController) ClickAt(pos image.Point) {
jittered := m.humanizer.JitterPosition(pos)
m.MoveTo(jittered)
m.Click()
}
// RightClick performs a right mouse click.
func (m *MouseController) RightClick() {
m.humanizer.Wait()
holdMs := 50 + rand.Intn(80)
time.Sleep(time.Duration(holdMs) * time.Millisecond)
}
// GetPosition returns current mouse position.
func (m *MouseController) GetPosition() image.Point {
// TODO: Platform-specific implementation
return image.Point{}
}
func (m *MouseController) setPosition(p image.Point) {
// TODO: Platform-specific implementation
}
// bezierPath generates a cubic Bézier curve with randomized control points.
func (m *MouseController) bezierPath(start, end image.Point) [4]image.Point {
dx := float64(end.X - start.X)
dy := float64(end.Y - start.Y)
// Randomize control points for natural curvature
cp1 := image.Point{
X: start.X + int(dx*0.25+rand.Float64()*50-25),
Y: start.Y + int(dy*0.25+rand.Float64()*50-25),
}
cp2 := image.Point{
X: start.X + int(dx*0.75+rand.Float64()*50-25),
Y: start.Y + int(dy*0.75+rand.Float64()*50-25),
}
return [4]image.Point{start, cp1, cp2, end}
}
// evalBezier evaluates a cubic Bézier curve at parameter t.
func (m *MouseController) evalBezier(pts [4]image.Point, t float64) image.Point {
u := 1 - t
return image.Point{
X: int(u*u*u*float64(pts[0].X) + 3*u*u*t*float64(pts[1].X) + 3*u*t*t*float64(pts[2].X) + t*t*t*float64(pts[3].X)),
Y: int(u*u*u*float64(pts[0].Y) + 3*u*u*t*float64(pts[1].Y) + 3*u*t*t*float64(pts[2].Y) + t*t*t*float64(pts[3].Y)),
}
}
// easeInOut applies ease-in-out for natural mouse acceleration.
func (m *MouseController) easeInOut(t float64) float64 {
return t * t * (3 - 2*t)
}
func (m *MouseController) pathLength(pts [4]image.Point) float64 {
length := 0.0
prev := pts[0]
for i := 1; i <= 20; i++ {
t := float64(i) / 20.0
p := m.evalBezier(pts, t)
dx := float64(p.X - prev.X)
dy := float64(p.Y - prev.Y)
length += math.Sqrt(dx*dx + dy*dy)
prev = p
}
return length
}
// KeyboardController handles human-like keyboard input.
type KeyboardController struct {
humanizer *Humanizer
}
// NewKeyboardController creates a keyboard controller.
func NewKeyboardController(h *Humanizer) *KeyboardController {
return &KeyboardController{humanizer: h}
}
// PressKey presses and releases a key with human-like timing.
func (k *KeyboardController) PressKey(key string) {
k.humanizer.Wait()
// TODO: Platform-specific key press
holdMs := 30 + rand.Intn(70)
time.Sleep(time.Duration(holdMs) * time.Millisecond)
}
// TypeText types text with randomized inter-key delays.
func (k *KeyboardController) TypeText(text string) {
for _, ch := range text {
k.PressKey(string(ch))
delay := 30 + rand.Intn(120) // 30-150ms between keys
time.Sleep(time.Duration(delay) * time.Millisecond)
}
}
// HoldKey holds a key down for a duration.
func (k *KeyboardController) HoldKey(key string, durationMs int) {
// TODO: Platform-specific key down/up
variance := rand.Intn(durationMs / 5)
time.Sleep(time.Duration(durationMs+variance) * time.Millisecond)
}

120
pkg/engine/loot/filter.go Normal file
View file

@ -0,0 +1,120 @@
// Package loot provides a declarative, rule-based loot filter engine.
//
// Loot rules are defined in YAML and evaluated against detected items.
// Supports complex matching on type, name, rarity, properties, and more.
package loot
import (
"strings"
"git.cloonar.com/openclawd/iso-bot/pkg/plugin"
)
// Priority levels for pickup ordering.
const (
PriorityLow = 1
PriorityNormal = 5
PriorityHigh = 8
PriorityCritical = 10
)
// Condition defines a match condition for a loot rule.
type Condition struct {
Type *string `yaml:"type,omitempty"` // "unique", "set", "rare", etc.
NameContains *string `yaml:"name_contains,omitempty"` // substring match
NameExact *string `yaml:"name_exact,omitempty"` // exact match
MinRarity *int `yaml:"min_rarity,omitempty"` // minimum rarity tier
MaxRarity *int `yaml:"max_rarity,omitempty"`
BaseType *string `yaml:"base_type,omitempty"` // e.g., "Diadem", "Monarch"
HasProperty *string `yaml:"has_property,omitempty"` // item has this property key
PropertyGTE map[string]int `yaml:"property_gte,omitempty"` // property >= value
}
// Action defines what to do when a rule matches.
type Action string
const (
ActionPickup Action = "pickup"
ActionIgnore Action = "ignore"
ActionAlert Action = "alert" // pickup + send notification
)
// Rule is a single loot filter rule.
type Rule struct {
Name string `yaml:"name,omitempty"`
Match Condition `yaml:"match"`
Action Action `yaml:"action"`
Priority int `yaml:"priority,omitempty"`
}
// RuleEngine evaluates items against a list of rules.
type RuleEngine struct {
Rules []Rule
}
// NewRuleEngine creates a rule engine from a list of rules.
func NewRuleEngine(rules []Rule) *RuleEngine {
return &RuleEngine{Rules: rules}
}
// Evaluate checks an item against all rules.
// Returns the first matching rule's action and priority.
func (e *RuleEngine) Evaluate(item plugin.DetectedItem) (action Action, priority int, matched bool) {
for _, rule := range e.Rules {
if e.matches(rule.Match, item) {
p := rule.Priority
if p == 0 {
p = PriorityNormal
}
return rule.Action, p, true
}
}
return ActionIgnore, 0, false
}
// ShouldPickup implements plugin.LootFilter.
func (e *RuleEngine) ShouldPickup(item plugin.DetectedItem) (bool, int) {
action, priority, matched := e.Evaluate(item)
if !matched {
return false, 0
}
return action == ActionPickup || action == ActionAlert, priority
}
// ShouldAlert implements plugin.LootFilter.
func (e *RuleEngine) ShouldAlert(item plugin.DetectedItem) bool {
action, _, matched := e.Evaluate(item)
return matched && action == ActionAlert
}
func (e *RuleEngine) matches(cond Condition, item plugin.DetectedItem) bool {
if cond.Type != nil && !strings.EqualFold(item.Type, *cond.Type) {
return false
}
if cond.NameContains != nil && !strings.Contains(strings.ToLower(item.Name), strings.ToLower(*cond.NameContains)) {
return false
}
if cond.NameExact != nil && !strings.EqualFold(item.Name, *cond.NameExact) {
return false
}
if cond.MinRarity != nil && item.Rarity < *cond.MinRarity {
return false
}
if cond.MaxRarity != nil && item.Rarity > *cond.MaxRarity {
return false
}
if cond.BaseType != nil && !strings.EqualFold(item.Properties["base_type"], *cond.BaseType) {
return false
}
if cond.HasProperty != nil {
if _, ok := item.Properties[*cond.HasProperty]; !ok {
return false
}
}
for key, minVal := range cond.PropertyGTE {
// TODO: Parse property value as int and compare
_ = key
_ = minVal
}
return true
}

108
pkg/engine/safety/safety.go Normal file
View file

@ -0,0 +1,108 @@
// Package safety provides anti-detection measures: session timing,
// break scheduling, and behavioral pattern randomization.
package safety
import (
"math/rand"
"time"
)
// SessionConfig defines play session parameters.
type SessionConfig struct {
MinSessionHours float64
MaxSessionHours float64
MinBreakMinutes float64
MaxBreakMinutes float64
MaxDailyHours float64
MicroBreakMinSec int
MicroBreakMaxSec int
MicroBreakIntervalMinSec int
MicroBreakIntervalMaxSec int
}
// DefaultSessionConfig returns realistic session timing.
func DefaultSessionConfig() SessionConfig {
return SessionConfig{
MinSessionHours: 1.0,
MaxSessionHours: 4.0,
MinBreakMinutes: 10.0,
MaxBreakMinutes: 45.0,
MaxDailyHours: 12.0,
MicroBreakMinSec: 2,
MicroBreakMaxSec: 8,
MicroBreakIntervalMinSec: 120,
MicroBreakIntervalMaxSec: 300,
}
}
// SessionTimer manages play session duration and breaks.
type SessionTimer struct {
config SessionConfig
sessionStart time.Time
dailyPlaytime time.Duration
targetDuration time.Duration
nextMicroBreak time.Time
}
// NewSessionTimer creates a session timer.
func NewSessionTimer(config SessionConfig) *SessionTimer {
st := &SessionTimer{
config: config,
sessionStart: time.Now(),
}
st.targetDuration = st.rollSessionDuration()
st.nextMicroBreak = st.scheduleMicroBreak()
return st
}
// ShouldStopSession returns true if the current session should end.
func (s *SessionTimer) ShouldStopSession() bool {
elapsed := time.Since(s.sessionStart)
if elapsed >= s.targetDuration {
return true
}
if s.dailyPlaytime+elapsed >= time.Duration(s.config.MaxDailyHours*float64(time.Hour)) {
return true
}
return false
}
// ShouldMicroBreak returns the break duration if it's time, or 0.
func (s *SessionTimer) ShouldMicroBreak() time.Duration {
if time.Now().After(s.nextMicroBreak) {
duration := time.Duration(s.config.MicroBreakMinSec+rand.Intn(s.config.MicroBreakMaxSec-s.config.MicroBreakMinSec+1)) * time.Second
s.nextMicroBreak = s.scheduleMicroBreak()
return duration
}
return 0
}
// BreakDuration returns a randomized long break duration.
func (s *SessionTimer) BreakDuration() time.Duration {
minMs := int(s.config.MinBreakMinutes * 60 * 1000)
maxMs := int(s.config.MaxBreakMinutes * 60 * 1000)
return time.Duration(minMs+rand.Intn(maxMs-minMs)) * time.Millisecond
}
// StartNewSession resets for a new play session.
func (s *SessionTimer) StartNewSession() {
s.dailyPlaytime += time.Since(s.sessionStart)
s.sessionStart = time.Now()
s.targetDuration = s.rollSessionDuration()
}
// Elapsed returns time in current session.
func (s *SessionTimer) Elapsed() time.Duration {
return time.Since(s.sessionStart)
}
func (s *SessionTimer) rollSessionDuration() time.Duration {
minMs := int(s.config.MinSessionHours * 3600 * 1000)
maxMs := int(s.config.MaxSessionHours * 3600 * 1000)
return time.Duration(minMs+rand.Intn(maxMs-minMs)) * time.Millisecond
}
func (s *SessionTimer) scheduleMicroBreak() time.Time {
interval := s.config.MicroBreakIntervalMinSec + rand.Intn(s.config.MicroBreakIntervalMaxSec-s.config.MicroBreakIntervalMinSec+1)
return time.Now().Add(time.Duration(interval) * time.Second)
}

86
pkg/engine/state/state.go Normal file
View file

@ -0,0 +1,86 @@
// Package state provides game state machine management with event callbacks.
package state
import (
"image"
"sync"
"time"
"git.cloonar.com/openclawd/iso-bot/pkg/plugin"
)
// Transition records a state change.
type Transition struct {
From plugin.GameState
To plugin.GameState
Timestamp time.Time
}
// Manager tracks game state and fires callbacks on transitions.
type Manager struct {
mu sync.RWMutex
current plugin.GameState
previous plugin.GameState
enterTime time.Time
history []Transition
callbacks map[plugin.GameState][]func(Transition)
detector plugin.GameDetector
}
// NewManager creates a state manager with the given detector.
func NewManager(detector plugin.GameDetector) *Manager {
return &Manager{
current: plugin.StateUnknown,
enterTime: time.Now(),
callbacks: make(map[plugin.GameState][]func(Transition)),
detector: detector,
}
}
// Update analyzes the current frame and updates state.
func (m *Manager) Update(frame image.Image) plugin.GameState {
newState := m.detector.DetectState(frame)
m.mu.Lock()
defer m.mu.Unlock()
if newState != m.current {
t := Transition{
From: m.current,
To: newState,
Timestamp: time.Now(),
}
m.history = append(m.history, t)
m.previous = m.current
m.current = newState
m.enterTime = time.Now()
// Fire callbacks outside lock? For simplicity, keep in lock for now.
for _, cb := range m.callbacks[newState] {
cb(t)
}
}
return m.current
}
// Current returns the current game state.
func (m *Manager) Current() plugin.GameState {
m.mu.RLock()
defer m.mu.RUnlock()
return m.current
}
// TimeInState returns how long we've been in the current state.
func (m *Manager) TimeInState() time.Duration {
m.mu.RLock()
defer m.mu.RUnlock()
return time.Since(m.enterTime)
}
// OnState registers a callback for entering a specific state.
func (m *Manager) OnState(state plugin.GameState, cb func(Transition)) {
m.mu.Lock()
defer m.mu.Unlock()
m.callbacks[state] = append(m.callbacks[state], cb)
}

View file

@ -0,0 +1,88 @@
// Package vision provides computer vision utilities for game screen analysis.
//
// Uses GoCV (OpenCV bindings for Go) for template matching, color detection,
// and contour analysis. Designed for high-throughput real-time analysis.
package vision
import (
"image"
"image/color"
)
// Match represents a detected element on screen.
type Match struct {
Position image.Point
BBox image.Rectangle
Confidence float64
Label string
}
// Template is a pre-loaded image template for matching.
type Template struct {
Name string
Image image.Image
Width int
Height int
}
// ColorRange defines an HSV color range for detection.
type ColorRange struct {
LowerH, LowerS, LowerV int
UpperH, UpperS, UpperV int
}
// Pipeline processes frames through a series of vision operations.
type Pipeline struct {
templates map[string]*Template
threshold float64
}
// NewPipeline creates a vision pipeline with the given confidence threshold.
func NewPipeline(threshold float64) *Pipeline {
return &Pipeline{
templates: make(map[string]*Template),
threshold: threshold,
}
}
// LoadTemplate loads a template image for matching.
func (p *Pipeline) LoadTemplate(name string, img image.Image) {
bounds := img.Bounds()
p.templates[name] = &Template{
Name: name,
Image: img,
Width: bounds.Dx(),
Height: bounds.Dy(),
}
}
// FindTemplate searches for a template in the frame.
// Returns the best match above threshold, or nil.
func (p *Pipeline) FindTemplate(frame image.Image, templateName string) *Match {
// TODO: Implement with GoCV matchTemplate
// This is a stub — actual implementation needs gocv.MatchTemplate
return nil
}
// FindAllTemplates finds all matches of a template above threshold.
func (p *Pipeline) FindAllTemplates(frame image.Image, templateName string) []Match {
// TODO: Implement with GoCV + NMS
return nil
}
// FindByColor detects regions matching an HSV color range.
func (p *Pipeline) FindByColor(frame image.Image, colorRange ColorRange, minArea int) []Match {
// TODO: Implement with GoCV inRange + findContours
return nil
}
// ReadBarPercentage reads a horizontal bar's fill level (health, mana, xp).
func (p *Pipeline) ReadBarPercentage(frame image.Image, barRegion image.Rectangle, filledColor ColorRange) float64 {
// TODO: Implement — scan columns for filled color ratio
return 0.0
}
// GetPixelColor returns the color at a specific pixel.
func (p *Pipeline) GetPixelColor(frame image.Image, x, y int) color.Color {
return frame.At(x, y)
}