bot

package
v0.2.0-beta Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Overview

Package bot — boot_report.go

BootReport is the structured, persisted, Wails-emitted record of one boot attempt. It is built incrementally by the BootOrchestrator and surfaces three places: the in-memory status (returned to the React side via the bot_init_report Wails event), the JSON file under paths.ResolveConfig("logs/"), and the bundled boot profile (p95 / recommended timeout — see bootprofile.go).

The intent is "never again should the user see a bare 'failed to initialize bot' line and have to grep app.log to find out why." Every step records its name, duration, result, and a short human detail. The SuggestedAction field is what the orchestrator believes the user should try next ("wait longer", "check adb", etc.) — the React panel surfaces this as a one-liner with optional buttons.

The BootReport type holds a sync.Mutex so the orchestrator can append steps and recovery names from multiple goroutines safely. BootReport is therefore NOT safe to copy by value — copying a sync.Mutex is undefined behavior. The companion BootReportView type below is the value-copyable, JSON-marshalable, Wails-emittable projection: Snapshot() and SaveJSON() produce a BootReportView, and all read paths operate on it. This split keeps the mutex where it belongs (on the writer) and prevents the "go vet warning: return copies lock value" footgun.

Package bot — bootorchestrator.go

BootOrchestrator is the single entry point that brings the bot from "user clicked Start" to "ready to capture frames". It replaces the inline boot sequence that used to live in NewBot() in bot.go, and turns the multi-step hand-rolled procedure into a state machine with named steps, layered recovery, structured reporting, and learned timing.

The orchestrator does NOT own the capture loop, the classifier, the attack executor, or any of the runtime subsystems. It only owns the BOOT phase. NewBot() now reads:

bctx, err := NewBootOrchestrator(cfg, client).Boot(ctx)
if err != nil { ... }
// bctx.ScreenW/H, bctx.Client, bctx.BlueStacksRestarted are ready

BootConfig carries the tunables. The defaults are production-safe; in dev mode NewBot shrinks them via WithDevFastFail so a failed boot surfaces in 25s instead of 180s+.

Threading: the orchestrator is single-use. Construct one per NewBot call, call Boot() once, and discard. The Client it receives is mutated (its transport is connected, the pipe is not enabled) — that's the intended hand-off.

Package bot — bootprofile.go

BootProfile is the persistent, learned-timing store for boot durations. Each successful boot appends a sample; on timeout the recommended timeout is bumped (p95 * 1.5 capped at MaxRecommended). The orchestrator reads the recommended timeout before starting, so after one or two runs on a slow MacBook the bot stops timing out on a value that was hand-picked for fast hardware.

The file lives at paths.ResolveConfig("boot_profile.json") and is written atomically (tmp + rename) so a crash mid-write doesn't corrupt the existing profile.

Package bot — recovery.go

RecoveryPolicy is the layered escalation engine that decides what to try when a boot step fails. The whole point of replacing the hard-coded "kill BlueStacks" with named strategies is that the bot tries the cheapest fixes first and only escalates to destructive actions as a last resort.

Strategy ladder (cheapest to most-destructive):

  1. RetryTransport — close + reopen the ADB transport socket. Fast (~50ms). Handles the common case of a stale connection from BlueStacks restart.

  2. SoftReset — `adb shell stop && adb shell start`. Restarts the Android runtime without killing BlueStacks. ~5-10s. Handles the "boot_completed never appeared" case where Android is up but wedged.

  3. RelaunchGame — force-stop + restart CoC via monkey. Doesn't touch BlueStacks or the Android runtime. ~3-5s. Handles the "PackageManager up but CoC crashed" case.

  4. RestartBlueStacks — osascript quit + open -a BlueStacks. ~10-15s for the launch. The last reasonable option before…

  5. NuclearOption — kill -9 BlueStacks + rewrite config from scratch. ~20s. Only used when EnsureBlueStacksMac itself failed (config write rejected, etc).

Each strategy has metadata: the action function, a "what went wrong" predicate that decides when to try it, and a budget. The orchestrator walks the ladder, applying backoff between attempts, and surfaces a "SuggestedAction" string for the UI.

Index

Constants

View Source
const DefaultRecommendedTimeoutMs = 90_000

DefaultRecommendedTimeoutMs is the initial recommended boot timeout when no profile exists yet. 90s matches the prior hard-coded value in bot.go so behavior is unchanged on first launch.

View Source
const MaxBootProfileSamples = 30

MaxBootProfileSamples is the rolling window of recent boots the profile keeps. 30 is enough to absorb a few bad days without forgetting that "this user usually boots in 12s."

View Source
const MaxRecommendedTimeoutMs = 300_000

MaxRecommendedTimeoutMs is the upper cap on the recommended timeout. Even on a very slow machine, 5 minutes is enough — past that, something is actually wrong and the user should investigate rather than have the bot silently retry for 10 minutes.

View Source
const MinRecommendedTimeoutMs = 30_000

MinRecommendedTimeoutMs is the lower bound. 30s is a reasonable floor: any device that boots faster than 30s on a clean run is already happy at the floor, and bumping below 30s just causes spurious timeouts on noisy hardware.

Variables

This section is empty.

Functions

func AsyncWriteFile

func AsyncWriteFile(path string, data []byte, perms os.FileMode) error

func CPUTime

func CPUTime() time.Duration

CPUTime returns the total CPU time consumed by this process since it started, as an absolute duration. It is the sum of user + system time reported by the kernel (getrusage RUSAGE_SELF).

Unlike a percentage, this number is device-independent: it means the same thing on an M1, an M3 Max, or any other machine. A busy-loop burning one core for 10 seconds reports ~10s here regardless of how many cores the host has. To compare efficiency across devices, measure CPUTime() delta over a fixed wall-clock window (see CPUUsage).

func RunWallUpgradeLoop

func RunWallUpgradeLoop(h *WallUpgradeHooks)

RunWallUpgradeLoop drives the wall-upgrade sequence with explicit deps (parametrised by h). Production wires a Bot-built hooks struct; the cmd/test_wall_upgrade diagnostic tool wires its own hooks struct against a live adb.Client without instantiating a full *Bot.

Flow selection (in priority order):

  1. ASSET-DRIVEN (BLIND-TAP) — when wall_upgrade_buttons.json + wall_upgrade_confirm.json + wall_upgrade_x_roi.json ALL load cleanly: a. Tap gold rect Center. b. Tap Confirm rect Center (BLIND — no template match). c. Wait + capture. Then check BOTH hasModalInRect(x_popup_roi) AND hasModalInRect(x_popup_roi_alt) (alt only if configured). Chained-popup support per the user's spec: dismissing the gem-buy modal can reveal a SECOND popup whose X lives at x_popup_roi_alt; both must be down before declaring success. d. If ANY popup is still up, single-tap each X in sequence: primary center, then alt center (if configured). ONE tap per X, no retry, no offsets. Per the user's "click X, then click X again in the confirm menu" spec. e. Verify both rects dismissed after the sequence. If either is still up → fall through to the NEXT button on this same wall (gold → elixir per spec "if gold was unsuccessful, click elixir"), emitting primary_still_up / alt_still_up diagnostics so the user knows which rect mis-picked. If BOTH buttons fire this path, the post-button-loop check surfaces the `all_unaffordable` exit and the sequence ends. Wall-level aborts only fire on defensive capture failures (modalErr / verErr — transport sick), not on rect mis-picks. f. Both rects down → silent spawn = success. Move to next button.

  2. PROBE-AND-DISCARD — when only wall_upgrade_buttons.json loads (legacy mode from the original pre-rect refactor): - Tap gold → if btn_confirm_upgrade template matches ∧ checkConfirmRed says white → tap template-derived confirm. - Tap gold → confirm missing → silent spawn = success. - Tap gold → confirm shown + checkConfirmRed says red → tap X. - Then loop to elixir with the same logic.

  3. LEGACY TEMPLATE — when no rect assets load: - For each btn_upgrade_wall candidate (multi-scale match): pre-tap cost-color check via costROI, then probe-and-discard with btn_confirm_upgrade template match.

Each phase boundary emits an OnStep event so the diagnostic tool can capture + annotate a screenshot at every decision point. Payloads follow the Mat-ownership contract documented on WallUpgradeHooks.step.

func SuggestedAction

func SuggestedAction(lastStep, lastStrategy, lastErr string) string

SuggestedAction is the human-readable "what to try next" string surfaced in the BootReport and the React UI. It takes the most recent failed step + the last strategy attempted and produces a one-liner. Kept short (1 sentence) so it fits a UI card.

Types

type AsyncWriter

type AsyncWriter struct {
	// contains filtered or unexported fields
}

func NewAsyncWriter

func NewAsyncWriter() *AsyncWriter

func (*AsyncWriter) Close

func (aw *AsyncWriter) Close()

func (*AsyncWriter) Write

func (aw *AsyncWriter) Write(path string, data []byte, perms os.FileMode) error

type AttackReport

type AttackReport struct {
	Timestamp        string `json:"timestamp"`
	Strategy         string `json:"strategy"`
	TargetEdge       string `json:"target_edge"`
	DeploySuccess    bool   `json:"deploy_success"`
	UndeployedSlots  int    `json:"undeployed_slots"`
	DeployError      string `json:"deploy_error,omitempty"`
	ParsedResults    bool   `json:"parsed_results"`
	Stars            int    `json:"stars"`
	GoldStolen       int    `json:"gold_stolen"`
	ElixirStolen     int    `json:"elixir_stolen"`
	DarkElixirStolen int    `json:"dark_elixir_stolen"`
	BonusGold        int    `json:"bonus_gold"`
	BonusElixir      int    `json:"bonus_elixir"`
	BonusDE          int    `json:"bonus_de"`
	TotalAttacks     int32  `json:"total_attacks_session"`
}

type BootConfig

type BootConfig struct {
	// ADB timeouts.
	AdbConnectTimeout time.Duration // overall budget for the transport connect loop
	AdbConnectPoll    time.Duration // gap between connect attempts
	AdbPerCallTimeout time.Duration // passed to transport.Exec (one Shell call)

	// Boot-probe timeouts (multi-signal).
	BootProbeTimeout    time.Duration // overall budget for the probe loop
	BootProbePoll       time.Duration // gap between probe passes
	BootProbeMinSignals int           // required # of ready signals (2 = 2-of-4)
	BootProbePerSignal  time.Duration // per-signal timeout inside one probe pass

	// Recovery knobs.
	MaxRecoveryAttempts    int
	AllowNuclear           bool
	InitialRecoveryBackoff time.Duration
	MaxRecoveryBackoff     time.Duration

	// Misc.
	WaitForGameSettle time.Duration // post-StartApp sleep
	PackageName       string
	DeviceID          string
	ExpectedWidth     int
	ExpectedHeight    int
	ExpectedDPI       int

	// DevFastFail is true when wails dev is running. Shrinks all
	// the timeouts above; the orchestrator does NOT touch this
	// field directly (NewBootConfigFromBotConfig reads the env).
	DevFastFail bool
}

BootConfig is the per-call configuration for a single boot attempt. Constructed by NewBootConfigFromBotConfig; tune via the WithDevFastFail and WithAllowNuclear helpers.

func DefaultBootConfig

func DefaultBootConfig() BootConfig

DefaultBootConfig returns the production defaults. 90s on the boot probe, 90s on the ADB connect loop, 2-of-4 signals, 5 recovery attempts with a 0.5s..4s exponential backoff.

func NewBootConfigFromBotConfig

func NewBootConfigFromBotConfig(cfg *config.BotConfig) BootConfig

NewBootConfigFromBotConfig merges the BotConfig defaults with the orchestrator's own defaults. The DeviceID, PackageName, and resolution come from cfg.Device. The recovery policy reads cfg flags for future tunability (currently none — the orchestrator has its own opinion).

func (BootConfig) WithDevFastFail

func (c BootConfig) WithDevFastFail() BootConfig

WithDevFastFail returns a copy of cfg with shrunken timeouts suitable for `wails dev`. 10s for the ADB loop, 15s for the boot probe, 500ms poll cadence, no nuclear option. A coding error in dev cycles through the failure modes in ~25s instead of 180s+.

type BootContext

type BootContext struct {
	Client              *adb.Client
	DeviceID            string
	PackageName         string
	ScreenW             int
	ScreenH             int
	ExpectedScreenW     int
	ExpectedScreenH     int
	BlueStacksRestarted bool
	Report              BootReportView
	BootDuration        time.Duration
	RecoveryUsed        []string
}

BootContext is what the orchestrator returns on success. NewBot reads ScreenW/H to build the Calibration; everything else is passed through to the Bot struct for runtime use.

Report is a value-copyable BootReportView (no mutex), safe to pass across goroutines, serialize, or emit via Wails.

type BootOrchestrator

type BootOrchestrator struct {
	// contains filtered or unexported fields
}

BootOrchestrator is the live state of a single boot attempt. One per NewBot call. Use NewBootOrchestrator to construct.

func NewBootOrchestrator

func NewBootOrchestrator(cfg BootConfig, client *adb.Client, logger zerolog.Logger) *BootOrchestrator

NewBootOrchestrator wires the orchestrator. The client is the already-constructed adb.Client from NewBot (no transport connect has happened yet — the orchestrator does that itself).

func (*BootOrchestrator) Boot

Boot runs the full sequence. The returned BootContext is non-nil iff err is nil. On failure the BootContext is nil; use orchestrator.Report().Snapshot() to get the structured report.

func (*BootOrchestrator) Report

func (o *BootOrchestrator) Report() *BootReport

Report returns the live (not snapshot) report. Callers that need to serialize should use Report().Snapshot() instead.

type BootProfile

type BootProfile struct {
	DeviceID      string              `json:"device_id,omitempty"`
	Samples       []BootProfileSample `json:"samples"`
	LastUpdated   time.Time           `json:"last_updated"`
	RecommendedMs int                 `json:"recommended_timeout_ms"`
	// contains filtered or unexported fields
}

BootProfile is the persistent learned-timing record for a single device. Currently the file is per-host (one profile, not per device), but the DeviceID field is kept on every sample so a future per-device split is a one-line change.

func LoadBootProfile

func LoadBootProfile(path string) (*BootProfile, error)

LoadBootProfile reads path from disk. A missing file is not an error — the caller gets a default profile. Any other read or parse failure is returned wrapped.

func NewBootProfile

func NewBootProfile() *BootProfile

NewBootProfile returns a fresh, empty profile. Use LoadBootProfile to read an existing one from disk.

func (*BootProfile) AddSample

func (p *BootProfile) AddSample(s BootProfileSample)

AddSample appends a sample and recomputes the recommended timeout in one atomic-with-respect-to-readers step. Trims the rolling window to MaxBootProfileSamples so the file stays small.

Recommendation rule:

  • If any sample is a "timeout", the recommended timeout is bumped to max(p95(all) * 1.5, currentRecommended).
  • Otherwise the recommended timeout is max(p95(successful) * 1.5, MinRecommended).
  • Always clamped to [Min, Max].

1.5x headroom covers a single noisy run without overcorrecting.

func (*BootProfile) RecommendedTimeout

func (p *BootProfile) RecommendedTimeout() time.Duration

RecommendedTimeout returns the recommended boot timeout in milliseconds, clamped to [Min, Max]. The mutex is held only for the read; recomputation is cheap.

func (*BootProfile) Save

func (p *BootProfile) Save(path string) error

Save writes the profile to path atomically (write to .tmp, fsync, rename). The mutex is held only over the in-memory mutation; the disk write is best-effort and any error is returned to the caller.

type BootProfileSample

type BootProfileSample struct {
	StartedAt time.Time `json:"started_at"`
	Duration  int64     `json:"duration_ms"`
	Outcome   string    `json:"outcome"`
}

BootProfileSample is one observation. Outcome is "ok" or "timeout" — both are recorded so the recommended timeout adapts to BOTH typical-case speed AND the worst-case stretch the user has hit.

type BootReport

type BootReport struct {
	// contains filtered or unexported fields
}

BootReport is the full per-attempt record. It is safe for concurrent writes via the embedded mutex; the orchestrator holds the lock while appending steps. DO NOT copy a BootReport by value — use Snapshot() to get a value-copyable BootReportView for reads, JSON marshaling, or Wails emission.

The mutex is embedded (not a pointer) so the zero value is usable without explicit initialization. NewBootReport() handles the slice and map allocations.

func NewBootReport

func NewBootReport() *BootReport

NewBootReport constructs an empty report with the current wall clock as the start time. The caller is expected to fill DeviceID, PackageName, ExpectedWidth/Height, and Metadata before appending the first step (or, at minimum, before Snapshot() / Complete()).

func (*BootReport) AppendStep

func (r *BootReport) AppendStep(name string, startedAt time.Time, result BootResult, detail string)

AppendStep records a completed step. Safe for concurrent use. The passed-in startedAt is when the step began; Duration is recomputed here to keep callers from getting the wall-clock math wrong.

func (*BootReport) Complete

func (r *BootReport) Complete(outcome string, err error, suggestedAction string)

Complete finalizes the report. outcome should be "ok" or "failed". err may be nil for "ok". suggestedAction is a short imperative like "wait longer" or "relaunch BlueStacks manually" — surfaced in the UI and the JSON file.

func (*BootReport) JoinedStepSummary

func (r *BootReport) JoinedStepSummary(maxLen int) string

JoinedStepSummary returns "adb.connect=ok/2.1s | boot.probe=ok/87s | ..." suitable for one-line log emission and the Wails bot_init_report event payload. Truncates to maxLen characters with a trailing "…" so a 90-second probe doesn't blow up a UI card.

func (*BootReport) MarkRecovery

func (r *BootReport) MarkRecovery(strategy string)

MarkRecovery appends a recovery-strategy name to the report. Called once per executed strategy (not per attempt) so the user can see "we had to SoftReset this time" in the UI.

func (*BootReport) SaveJSON

func (r *BootReport) SaveJSON(path string) error

SaveJSON writes the report to path as pretty-printed JSON. Errors are returned to the caller but should typically be logged at debug level — a failed persistence must not itself become a boot failure.

func (*BootReport) SetAttempts

func (r *BootReport) SetAttempts(n int)

SetAttempts records the number of probe attempts the orchestrator made. Called once at the end of the boot.

func (*BootReport) SetBlueStacksEnsured

func (r *BootReport) SetBlueStacksEnsured(v bool)

SetBlueStacksEnsured marks whether EnsureBlueStacksMac was called during this boot. Used by the UI to distinguish "cold-started BlueStacks" from "reused an already-running instance."

func (*BootReport) SetDeviceContext

func (r *BootReport) SetDeviceContext(deviceID, packageName string, width, height int)

SetDeviceContext sets the device/package/resolution fields. Called once at construction so Snapshot() can include them in the view. Safe to call before any concurrent writer exists.

func (*BootReport) SetMetadata

func (r *BootReport) SetMetadata(kv map[string]string)

SetMetadata merges key/value pairs into the report's free-form metadata. Safe for concurrent use. Existing keys are overwritten; new keys are added.

func (*BootReport) Snapshot

func (r *BootReport) Snapshot() BootReportView

Snapshot returns a value-copyable, JSON-marshalable view of the report. The mutex is held only long enough to clone the slices and map; the returned BootReportView is independent of the source BootReport and can be passed across goroutines, marshaled, or emitted via Wails without any lock-copy issues.

func (*BootReport) Summary

func (r *BootReport) Summary() string

Summary returns a one-line human description for log lines. Format:

"boot ok in 12.3s (4 steps, no recovery)"                   — success
"boot failed in 90.0s: android boot timeout (5 steps, 2 recovery)" — failure

type BootReportView

type BootReportView struct {
	StartedAt         time.Time         `json:"started_at"`
	CompletedAt       time.Time         `json:"completed_at,omitempty"`
	Duration          time.Duration     `json:"duration_ns"`
	Outcome           string            `json:"outcome"` // "ok" | "failed"
	FinalError        string            `json:"final_error,omitempty"`
	SuggestedAction   string            `json:"suggested_action,omitempty"`
	DeviceID          string            `json:"device_id,omitempty"`
	PackageName       string            `json:"package_name,omitempty"`
	ExpectedWidth     int               `json:"expected_width,omitempty"`
	ExpectedHeight    int               `json:"expected_height,omitempty"`
	BlueStacksEnsured bool              `json:"bluestacks_ensured"`
	RecoveryUsed      []string          `json:"recovery_used,omitempty"`
	Attempts          int               `json:"attempts"`
	Steps             []BootStep        `json:"steps"`
	Metadata          map[string]string `json:"metadata,omitempty"`
}

BootReportView is the JSON-serializable, Wails-emittable, copyable view of a BootReport. It has the same fields as BootReport minus the mutex, and is what callers actually read/serialize. The orchestrator hands out views via Snapshot() and the React side consumes them as plain JSON.

func LoadBootReportJSON

func LoadBootReportJSON(path string) (BootReportView, error)

LoadBootReportJSON parses a previously-saved BootReport from disk into a BootReportView. The view is what callers actually want — they don't need a mutex-protected writer for reading. Returns os.ErrNotExist on a missing file (not an error condition).

type BootResult

type BootResult string

BootResult is the terminal outcome of a single step in the boot pipeline. We keep it as a small enum-style string so it round-trips through JSON cleanly and is grep-friendly in app.log.

const (
	BootResultOK      BootResult = "ok"
	BootResultTimeout BootResult = "timeout"
	BootResultError   BootResult = "error"
	BootResultSkipped BootResult = "skipped"
)

type BootStep

type BootStep struct {
	Name      string        `json:"name"`             // e.g. "adb.connect", "bluestacks.ensure", "boot.probe"
	StartedAt time.Time     `json:"started_at"`       // RFC3339Nano
	Duration  time.Duration `json:"duration_ns"`      // wall-clock duration of this step
	Result    BootResult    `json:"result"`           // ok | timeout | error | skipped
	Detail    string        `json:"detail,omitempty"` // human-readable context (error string, signal name, etc.)
}

BootStep is one discrete phase of the boot sequence. The orchestrator emits one per phase. Latency at the field level is the per-step duration; cumulative timing is recomputed by Summary().

func (BootStep) StepSummary

func (s BootStep) StepSummary() string

StepSummary is a small printable summary for the React side / log line. One per step, joined with " | " by JoinedStepSummary.

type Bot

type Bot struct {
	OnFrame       func(string)
	OnStatsUpdate func()
	// contains filtered or unexported fields
}

func NewBot

func NewBot(cfg *config.BotConfig) (*Bot, error)

func (*Bot) Cancel

func (b *Bot) Cancel()

Cancel signals the bot's context to stop. The captureLoop and any in-flight attack sequence see the cancellation on their next `b.ctx.Done()` check (sub-millisecond), which is what makes the Stop button feel "instant" to the user — no more taps, captures, state transitions, or attack progression. The full `Stop()` path (ADB close, async-writer drain, file flush) is intentionally kept out of this method so callers can split the work: `Cancel()` for the synchronous "stop what you're doing" signal, and `Stop()` for the heavier teardown that App.StopBot detaches into a goroutine.

func (*Bot) DumpDiagnostics

func (b *Bot) DumpDiagnostics(reason string, screen gocv.Mat, context map[string]interface{}) error

DumpDiagnostics saves a screenshot and a JSON file containing the bot's state.

func (*Bot) GetClient

func (b *Bot) GetClient() *adb.Client

func (*Bot) GetLastFrame

func (b *Bot) GetLastFrame() string

func (*Bot) Health

func (b *Bot) Health() game.SystemHealth

func (*Bot) QuickDeploy

func (b *Bot) QuickDeploy() error

QuickDeploy is the manual single-shot deploy path for run_designed_attack.sh. The user is assumed to already be on the attack screen with a base loaded — there's no search loop, no attack-button discovery, no home → finds-match pipeline. We capture the current screen once, run deployTroops (which honors formula.json overrides if design_attack wrote one next to the strategy), and return.

The captureLoop is intentionally NOT started; cli.go's --deploy-only branch calls this directly and bypasses b.Start() to avoid the Find-Attack-Button race that would otherwise kick the bot into the next-base search cycle.

On non-Battle screens (e.g. user is still on home, or in clouds), we log a WARN and proceed anyway — the deployTroops path will see the absence of a troop bar and either error out cleanly or succeed-by-luck if the screen does actually contain a deployable base. Caller should surface the error.

func (*Bot) Start

func (b *Bot) Start() error

func (*Bot) Stats

func (b *Bot) Stats() BotStats

func (*Bot) Stop

func (b *Bot) Stop()

func (*Bot) UpdateConfig

func (b *Bot) UpdateConfig(cfg *config.BotConfig)

func (*Bot) UpgradeWalls

func (b *Bot) UpgradeWalls(gc *game.GameContext)

UpgradeWalls executes the wall-upgrade sequence repeatedly until no more affordable options exist. After this refactor it is a thin wrapper that delegates to runWallUpgradeLoop with the production Bot's dependencies. The diagnostic tool at cmd/test_wall_upgrade calls runWallUpgradeLoop directly with a hand-built hooks struct.

type BotStats

type BotStats struct {
	AttacksCompleted int32         `json:"attacks_completed"`
	SearchSkips      int32         `json:"search_skips"`
	TotalGold        int64         `json:"total_gold"`
	TotalElixir      int64         `json:"total_elixir"`
	TotalDE          int64         `json:"total_de"`
	Stars0           int32         `json:"stars_0"`
	Stars1           int32         `json:"stars_1"`
	Stars2           int32         `json:"stars_2"`
	Stars3           int32         `json:"stars_3"`
	Uptime           time.Duration `json:"uptime"`
	AdbHealth        adb.Health    `json:"adb_health"`
	// CPUTimeSec is the absolute CPU time consumed since process start
	// (device-independent, unlike "% CPU").
	CPUTimeSec float64 `json:"cpu_time_sec"`
	// CPUCores is CPU usage as a fraction of one core over the last sample
	// window (1.0 == one full core busy).
	CPUCores float64 `json:"cpu_cores"`
}

type DiagnosticData

type DiagnosticData struct {
	Timestamp time.Time              `json:"timestamp"`
	Reason    string                 `json:"reason"`
	State     string                 `json:"state"`
	Context   map[string]interface{} `json:"context,omitempty"`
}

DiagnosticData holds the state of the bot at the time of failure.

type Pinpoint

type Pinpoint struct {
	X, Y int
	Name string
}

Pinpoint defines a precise location on the reference screen (860x732) and a color check to verify it before clicking.

type RecoveryConfig

type RecoveryConfig struct {
	// MaxAttempts is the cap on the whole boot sequence (NOT per
	// strategy). Default 5: one initial try + four escalations.
	MaxAttempts int

	// AllowNuclear gates the RestartBlueStacks and NuclearOption
	// strategies. In dev mode the orchestrator sets this to false so
	// a coding error can't trigger a BlueStacks restart loop in
	// the dev's editor.
	AllowNuclear bool

	// InitialBackoff is the wait before the SECOND attempt. Each
	// subsequent attempt doubles, capped at MaxBackoff. 500ms is a
	// reasonable default — long enough to let a shell call return
	// its real result, short enough that a human doesn't notice.
	InitialBackoff time.Duration
	MaxBackoff     time.Duration
}

RecoveryConfig is the orchestration knobs the policy consults.

func DefaultRecoveryConfig

func DefaultRecoveryConfig() RecoveryConfig

DefaultRecoveryConfig returns the production defaults.

type RecoveryPolicy

type RecoveryPolicy struct {
	// contains filtered or unexported fields
}

RecoveryPolicy wires the strategy ladder to the live Client. It is constructed once by the orchestrator and consulted at most once per attempt. Stateless after construction.

func NewRecoveryPolicy

func NewRecoveryPolicy(cfg RecoveryConfig, client adbClient) *RecoveryPolicy

NewRecoveryPolicy constructs a policy. The client is the live ADB client (or a fake in tests); the cfg carries the budgets.

func (*RecoveryPolicy) Backoff

func (p *RecoveryPolicy) Backoff(attempt int) time.Duration

Backoff returns the wait time before the next attempt. Exponential with a cap, starting from cfg.InitialBackoff. attempt is 1-based.

func (*RecoveryPolicy) Escalate

func (p *RecoveryPolicy) Escalate(strats []RecoveryStrategy, attempt int) int

Escalate decides which strategy (by index in Strategies()) to try next. failureContext tells the policy what step failed; the return value is the index into Strategies() to try, or -1 if the ladder is exhausted.

The current implementation is a simple linear walk: it always tries the next strategy on each escalation. A future enhancement could skip strategies whose "predicate" doesn't match the failure (e.g. don't try RelaunchGame if the failure is in adb.connect itself).

func (*RecoveryPolicy) Strategies

func (p *RecoveryPolicy) Strategies(packageName string, w, h, dpi int) []RecoveryStrategy

Strategies returns the ordered ladder for the current config. The returned slice is a fresh copy — callers can mutate without affecting future ladders.

Order rationale (cheapest to most-destructive, with the diagnostics- first principle applied):

  1. RetryTransport — close + reopen the ADB transport socket. Fast (~50ms). Handles a stale connection from a fresh BlueStacks start.
  2. ResetAdbServer — `adb kill-server` + `adb start-server`. ~3s. Handles the "localhost:5555 listed as offline in `adb devices` even though the device is up" failure mode that hits after a hard kill or a wails-dev session. Non-destructive of the emulator state but does drop ALL adb connections globally — noted in the BootReport.
  3. SoftReset — `adb shell stop && adb shell start`. Restarts the Android runtime without killing BlueStacks. ~8s. Handles Android wedged at the runtime layer.
  4. RelaunchGame — force-stop + restart CoC via monkey. Doesn't touch BlueStacks or Android. ~5s. Useless if ADB isn't yet talking to a device, so in practice only invoked from the boot-probe ladder.
  5. RestartBlueStacks — osascript quit + open -a BlueStacks. ~15s. The nuclear option, only used when AllowNuclear is true and everything above has failed.

type RecoveryStrategy

type RecoveryStrategy struct {
	Name string
	// Apply is the side-effecting action. It returns nil on success
	// or an error if the strategy itself failed (e.g. SoftReset
	// timeout). The error is reported on the BootReport.
	Apply func(ctx context.Context) error
	// Cost is a rough estimate of how long Apply takes. Used only
	// for the "total recovery time" reporting — not for budgeting.
	Cost time.Duration
	// Destructive is true for strategies that kill the emulator or
	// the game. The orchestrator's AllowNuclear flag controls whether
	// strategies with Destructive=true are ever invoked.
	Destructive bool
}

RecoveryStrategy is one named escalation step.

type Rect

type Rect struct {
	X1 int `json:"x1"`
	Y1 int `json:"y1"`
	X2 int `json:"x2"`
	Y2 int `json:"y2"`
}

Rect is the JSON-friendly shape for picker'd tap regions. Each of the three wall-upgrade asset files

  • assets/wall_upgrade_buttons.json (top-level: gold, elixir)
  • assets/wall_upgrade_confirm.json (top-level: confirm_button)
  • assets/wall_upgrade_x_roi.json (top-level: x_popup_roi)

writes a top-level dict with one or more {x1, y1, x2, y2} rects, which this struct decodes uniformly.

Coords are PHYSICAL pixels captured at the picker session's actual screen size. Per the user's calibration baseline (860x732 on BlueStacks Air + adb screencap at the device's native frame), the picker and the bot's Cal.ScaleX/ScaleY agree at 1:1, so JSON values land directly in bot tap coords without re-scaling. Mismatch on a different device frame is a separate concern — fix at picker or bot, not within the loader.

The legacy tap pattern in earlier versions of this file used the image.Rectangle stdlib type via image.Rect(x1,y1,x2,y2). image.Rectangle serializes to JSON as `{"Min":{"X":x,"Y":y},"Max":{"X":x2,"Y":y2}}`, which doesn't match the picker's flat x1/y1/x2/y2 output. The Rect struct here decodes the flat shape 1:1 with no per-file ad-hoc map[string]int shape, so adding a new asset file is one struct-tag each.

func (Rect) Center

func (r Rect) Center() (cx, cy int)

Center returns ((X1+X2)/2, (Y1+Y2)/2). Integer truncation matches adb input.tap's nearest-pixel rounding, so a 153-wide rect lands at ±0.5 px off geometric center — visually indistinguishable from the user's drag.

func (Rect) Empty

func (r Rect) Empty() bool

Empty returns true if the rect has zero area — i.e., zero width (X1==X2) OR zero height (Y1==Y2). The picker writes degenerate (0,0,0,0) only when handed a broken drag; the loaders use Empty() to reject that so the bot's tap path starts with `ok=false` rather than emitting a 0-area tap region that would silently tap the top-left corner of the screen. The all-zero (0,0,0,0) case is covered by the X1==X2 path because (X1==X2 AND Y1==Y2) is a subset of (X1==X2 OR Y1==Y2).

func (Rect) ImageRect

func (r Rect) ImageRect() image.Rectangle

ImageRect converts to Go's stdlib image.Rectangle for use with gocv.Region / image.Rect expected by vision math elsewhere. The returned rectangle uses the same Min/Max convention as image.Rect.

type WallUpgradeHooks

type WallUpgradeHooks struct {
	Logger    zerolog.Logger
	Client    wallClient
	Cal       *game.Calibration
	Templates *game.TemplateStore

	// Classify is invoked with each capture during the MainVillage
	// verify loop and to detect interruption dialogs. May be nil.
	Classify func(gocv.Mat) (game.GameState, int)

	// Dismiss taps a neutral background area to clear any active
	// wall-selection after a failed button-template match. May be nil.
	Dismiss func()

	// OnStep is the optional phase-boundary instrumentation hook.
	OnStep func(step string, data map[string]any)
}

WallUpgradeHooks groups the dependencies a wall-upgrade loop iteration needs. Used both by Bot.UpgradeWalls (production path) and the cmd/test_wall_upgrade diagnostic tool.

Optional fields:

  • Classify: nil disables state verification + interruption dismissal (the diagnostic tool's "manual" mode assumes the user navigated the game into MainVillage themselves and skips dialog handling).
  • OnStep: nil silences all instrumentation events. When wired, each phase boundary calls OnStep("phase_name", data). The diagnostic tool uses this to save annotated screenshots per phase.
  • Dismiss: nil makes the loop's fallback skip the neutral-tap dismiss (e.g. when btn_upgrade_wall or btn_confirm_upgrade fail matching). Production wires this to Bot.dismissSelection.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL