CI / Install & Test (Windows) (push) Canceled after 0s
* fix: raise maintenance budget for cross-act routes; document Trav findings Travincal leaves the character in Act 3, but maintenance relocates it to Act 4 (stash at a4_tyrael_stash, repair at a4_halbu) and the next run then needs the A3 waypoint again -- two cross-act waypoint trips per game. That travel is not attributed to any timed step, so a FAIL> trail can show ~38s of work inside a 250s maintenance window. The 240s budget (tuned when Pindle kept all town business in one act) was tripped 5 times in one session, each costing a whole game. Raised to 420s. Also documents, in HANDOVER.md: - The open_wp failures are NOT a stale template. Scored a5_wp.png against all three real failure frames: 0.96-0.98 full-frame, but that match is the belt/mana-orb HUD false positive the a5.py comment already documents. Inside the real cut_skill_bar ROI it scores 0.436-0.480 against a 0.55 threshold at scattered positions -- noise. The waypoint is genuinely not on screen; the character never reaches it. Recapturing would fix nothing. Scoring a template without the ROI the code actually uses produces a confident wrong answer. - PR #39's pather abort is merged, live, and finally firing (1 abort in 21 games, after being 0-for-152 while it looked correct). - Keeping Trav town business in Act 3 is the real fix for both the timeouts and the waypoint failures. a3.py already reports can_buy_pots/can_heal/ can_stash as True, so the Act 4 trip is not a missing capability. - FoH vs hammerdin on the council, and the [fohdin]/[paladin] section trap: concentration and redemption lived in [fohdin], which hammerdin does not read, so both would have gone silently unbound after a respec. Co-Authored-By: Claude Opus 5 <[email protected]> * docs: post-mortem for the hell Travincal / hammerdin session Narrative write-up of 29 Aug 2026: run_trav on hell, FoHdin -> hammerdin. Outcome: health chickens 3-in-8 -> 0-in-11, battle 64-68s -> 41-60s, failure rate 12% -> 9%, zero deaths throughout. The document covers the four times the obvious answer was wrong: 1. Battle time did not move after the respec, which looked like failure. hammerdin.kill_council casts for a FIXED duration with no kill detection, so the clock cannot report damage -- loot proved the respec worked. 2. a5_wp.png scored 0.96-0.98 full-frame on every failure frame, which reads as "template fine, search broken". That match is the belt/mana-orb HUD false positive the a5.py comment already documents. Inside the real cut_skill_bar ROI it scores 0.436-0.480 against a 0.55 threshold at wandering positions -- noise. The waypoint was never on screen. 3. Ormus: not masked (4-channel but fully opaque, so no mask is passed), and not a threshold problem (in-ROI best 0.342, background level -- lowering the bar would recreate the Bug 4 false-positive clicks). A3 town has frames with ZERO pather landmarks over threshold, so the character never arrives. One cause, three symptoms. 4. Reverses this session's own earlier recommendation to move Trav town business into Act 3. That was reasoned from a3.py reporting can_buy_pots/ can_heal/can_stash as True -- a capability check, not evidence about pathing. A3 is the worst-supported town in the project. Also documents three settings that were live but inert: binds under [fohdin] which hammerdin never reads, casting_frames=8 (unreachable -- a paladin's fastest cast is 10 frames, so every cast was cut short), and repair_npc= a5_larzuk, which Bug 12 set to keep repair in-act for Pindle but which forces a cross-act trip on Trav (35.8s vs 9.0s). And a caution on the key auto-detector: it reported the wrong stand_still bind both before and after a live remap, consistent with a stale .keyo read. Co-Authored-By: Claude Opus 5 <[email protected]> * docs: correct stale hammerdin migration notes in HANDOVER (Codex review) Addresses Codex feedback on PR #40: the handover still described the character as needing conversion to hammerdin with both skills blank, while the same commit verified type=hammerdin and concentration='f6' loading live. The conflict was worse than reported. The stale "Two things only you can do" section instructed the operator to rebind F7 -> Concentration, but F7 is Battle Orders (CTA). Following it would have overwritten the CTA bind and cost a large share of the life pool -- the exact problem this session was fixing. - Replaces the migration paragraph with the verified end state, and records that Blessed Hammer lives on LEFT-CLICK, not an F-key (_cast_hammers puts the aura on the right slot, so a hammer hotkey would replace it). - Notes that concentration/redemption had to move into [paladin]; under [fohdin] a hammerdin never reads them and both would be silently unbound. - Adds the full verified bind map so F7 cannot be reassigned by mistake. - Rewrites the operator TODOs: fire resist is superseded (Zaka + Mara took chickens 3-in-8 -> 0-in-11); the live items are lightning/poison resist and the 75% FCR breakpoint, with the casting_frames mapping (75%=11, 60%=12, <48%=13) so cast timing tracks any gear change. Co-Authored-By: Claude Opus 5 <[email protected]> * feat: baal_xp farm — game browser join, hide-and-wait state, config - src/ui/game_browser.py: retry-wrapped join flow (Play -> Join Game tab -> OCR game list -> click row -> loading/InGame confirm), wait_for_in_game - src/bot.py: 'baal_xp' state + run_baal_xp/end_run transitions; on_run_baal_xp: fast_save_and_exit -> hero select -> join public game -> corpse/nopickup/pre_buff -> walk to hide spot -> wait loop (XP every 30s, HP check, death recovery, InGame-miss re-anchor) -> leave -> recover to own game; on_end_run short-circuit straight to maintenance - src/config.py: [baal_xp] section (enabled, game_name_filter, max_wait_s, xp_threshold, min_hp_pct, hide_x/y, join_timeout_s) with profile overrides - config/params.ini: [baal_xp] section, order=run_baal_xp, run_trav - profile paladalla: order=run_baal_xp, run_trav (profile routes override base config — without this the route never activates) - stealth: daily budget cap (persisted, restart-proof), buy at A4 Jamella from A3 too (Ormus measured unreliable), session budget 20h * fix: import mouse in bot.py (baal_xp hide-spot click) * feat(stealth): hard per-day runtime cap that survives restarts, and quit D2R session_budget_h is per PROCESS. Every restart re-rolls it, so a bot that gets restarted -- by the user, or by the restart-on-crash path -- can run all day and never trip it. That is exactly the signal a runtime cap is meant to remove. It also silently failed in practice: params.ini still carried the temporary levelling value session_budget_h = 20, which rolls 13-27h, so the live config could not stop the bot within a day at all. Restored to 8. Adds daily_budget_h, keyed on the CALENDAR DATE and persisted to log/.daily_runtime.json, so restarts cannot hand out a fresh allowance. Design points that matter: - The rolled target is stored WITH the date. Re-rolling per process would make restarting a way to draw a bigger budget; once a day's target is chosen it is fixed until the date changes. - Jitter subtracts ONLY. daily_budget_h is a ceiling, not an average: a two-sided roll on 8h could hand out 9h, which is not what "cap it at 8 hours" means. 8 with 0.12 jitter now runs 7.0-8.0h and never more. Verified over 300 rolls: min 7.04h, max exactly 8.00h. - Time before the first tick in a process is not counted, so a crash under-counts rather than over-counts -- the safe direction for a cap. Also closes D2R when the cap trips (daily_budget_close_game, default on). The check runs in on_end_game AFTER save-and-exit, so the character is at the menu with nothing in progress and nothing is lost -- this is not the mid-game force kill that leaves D2R unenterable. A bot parked at character select for 16h is itself the pattern the cap exists to remove; a real player quits the game. The stealth manifest reports daily_budget by checking the CALL SITE in bot.py, not just the config value -- the lesson from chicken_variance and the AFK break that was 0-for-225 while the manifest said "wired". Co-Authored-By: Claude Opus 5 <[email protected]> --------- Co-authored-by: alexpolo1 <[email protected]> Co-authored-by: Claude Opus 5 <[email protected]>
702 lines
41 KiB
Python
702 lines
41 KiB
Python
import configparser
|
|
import string
|
|
import threading
|
|
import numpy as np
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
from logger import Logger
|
|
config_lock = threading.Lock()
|
|
from utils.misc import wait, only_lowercase_letters
|
|
|
|
def _default_iff(value, iff, default = None):
|
|
return default if value == iff else value
|
|
|
|
class Config:
|
|
data_loaded = False
|
|
|
|
configs = {}
|
|
|
|
# config data
|
|
general = {}
|
|
advanced_options = {}
|
|
discord_events = {}
|
|
gamble = {}
|
|
ui_roi = {}
|
|
ui_pos = {}
|
|
routes = {}
|
|
routes_order = []
|
|
cold_plains = {}
|
|
baal_xp = {}
|
|
char = {}
|
|
colors = {}
|
|
shop = {}
|
|
path = {}
|
|
blizz_sorc = {}
|
|
blizzorb_sorc = {}
|
|
light_sorc = {}
|
|
nova_sorc = {}
|
|
hydra_sorc = {}
|
|
hammerdin = {}
|
|
fohdin = {}
|
|
fire_lock = {}
|
|
echo_lock = {}
|
|
abyss_lock = {}
|
|
javazon = {}
|
|
trapsin = {}
|
|
barbarian = {}
|
|
poison_necro = {}
|
|
bone_necro = {}
|
|
necro = {}
|
|
basic = {}
|
|
basic_ranged = {}
|
|
|
|
_instance = None
|
|
_env_overrides = {}
|
|
|
|
def __new__(cls):
|
|
if cls._instance is None:
|
|
cls._instance = super(Config, cls).__new__(cls)
|
|
with config_lock:
|
|
if not cls._instance.data_loaded:
|
|
cls._instance.data_loaded = True
|
|
cls._instance.load_data()
|
|
return cls._instance
|
|
|
|
def _select_optional(self, section: string, key: string, default = None):
|
|
try:
|
|
return self._select_val(section=section, key=key, required=False)
|
|
except:
|
|
return default
|
|
|
|
def _select_val(self, section: str, key: str = None, required: bool = True):
|
|
found_in = ""
|
|
try:
|
|
if section in self.configs["custom"]["parser"] and key in self.configs["custom"]["parser"][section]:
|
|
found_val = self.configs["custom"]["parser"][section][key]
|
|
found_in = "custom"
|
|
elif section in self.configs["profile"]["parser"] and key in self.configs["profile"]["parser"][section]:
|
|
found_val = self.configs["profile"]["parser"][section][key]
|
|
found_in = "profile"
|
|
elif section in self.configs["config"]["parser"]:
|
|
found_val = self.configs["config"]["parser"][section][key]
|
|
found_in = "config"
|
|
elif section in self.configs["shop"]["parser"]:
|
|
found_val = self.configs["shop"]["parser"][section][key]
|
|
found_in = "shop"
|
|
else:
|
|
found_val = self.configs["game"]["parser"][section][key]
|
|
found_in = "game"
|
|
|
|
for var_name in self.configs[found_in]["vars"]: # set variable.
|
|
if var_name in found_val:
|
|
var_val = self.configs[found_in]["vars"][var_name]
|
|
found_val = found_val.replace(var_name, var_val)
|
|
return self._resolve_env_references(found_val)
|
|
except KeyError:
|
|
if not required:
|
|
raise
|
|
Logger.error(f"Key '{key}' not found in section '{section}'")
|
|
Logger.error("Closing in 10 seconds..")
|
|
wait(10)
|
|
os._exit(1)
|
|
|
|
def _resolve_env_references(self, value: str):
|
|
if not isinstance(value, str):
|
|
return value
|
|
|
|
# Supported inline env reference formats in ini files:
|
|
# ${BOTTY_CUSTOM_MESSAGE_HOOK}
|
|
# ${ENV:BOTTY_CUSTOM_MESSAGE_HOOK}
|
|
def repl(match):
|
|
token = (match.group(1) or "").strip()
|
|
if token.upper().startswith("ENV:"):
|
|
token = token[4:].strip()
|
|
return self._env_overrides.get(token, "")
|
|
|
|
resolved = re.sub(r"\$\{([^}]+)\}", repl, value)
|
|
return resolved
|
|
|
|
@staticmethod
|
|
def _load_env_overrides():
|
|
env_map = {}
|
|
env_file = ".env"
|
|
if os.path.exists(env_file):
|
|
try:
|
|
with open(env_file, "r", encoding="utf-8") as f:
|
|
for raw_line in f:
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.lower().startswith("export "):
|
|
line = line[7:].strip()
|
|
if "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
value = value.strip().strip('"').strip("'")
|
|
if key:
|
|
env_map[key] = value
|
|
except Exception as e:
|
|
Logger.warning(f"Failed to read .env file: {e}")
|
|
# OS environment variables should win over .env file values.
|
|
env_map.update(dict(os.environ))
|
|
return env_map
|
|
|
|
def _env_override(self, key: str, current_value):
|
|
env_key = f"BOTTY_{key.upper()}"
|
|
if env_key in self._env_overrides:
|
|
return self._env_overrides[env_key]
|
|
return current_value
|
|
|
|
def _normalize_difficulty(self, value: str) -> str:
|
|
normalized = (value or "").strip().lower()
|
|
# Accept common variants/typos instead of silently falling through to Hell.
|
|
if normalized in {"normal", "norm", "n"}:
|
|
return "normal"
|
|
if normalized in {"nightmare", "night", "nm", "nigthmare", "nigtmare"}:
|
|
return "nightmare"
|
|
if normalized in {"hell", "h"}:
|
|
return "hell"
|
|
Logger.warning(f"Unknown difficulty '{value}', defaulting to 'hell'")
|
|
return "hell"
|
|
|
|
def turn_off_goldpickup(self):
|
|
Logger.info("All stash tabs and character are full of gold, turn off gold pickup")
|
|
with config_lock:
|
|
self.char["stash_gold"] = False
|
|
|
|
def turn_on_goldpickup(self):
|
|
Logger.info("All stash tabs and character are no longer full of gold. Turn gold stashing back on.")
|
|
self.char["stash_gold"] = True
|
|
|
|
@staticmethod
|
|
def get_active_profile() -> str:
|
|
try:
|
|
with open(os.path.join("config", "active_profile.txt"), encoding="utf-8") as f:
|
|
return f.read().strip()
|
|
except OSError:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def set_active_profile(name: str):
|
|
with open(os.path.join("config", "active_profile.txt"), "w", encoding="utf-8") as f:
|
|
f.write(name.strip())
|
|
|
|
@staticmethod
|
|
def list_profiles() -> list[str]:
|
|
base = os.path.join("config", "profiles")
|
|
if not os.path.isdir(base):
|
|
return []
|
|
return sorted(d for d in os.listdir(base)
|
|
if os.path.isfile(os.path.join(base, d, "profile.ini")))
|
|
|
|
@staticmethod
|
|
def list_pickit_profiles() -> list[str]:
|
|
base = os.path.join("config", "pickit_profiles")
|
|
if not os.path.isdir(base):
|
|
return []
|
|
return sorted(d for d in os.listdir(base) if os.path.isdir(os.path.join(base, d)))
|
|
|
|
def load_data(self):
|
|
Logger.debug("Loading config")
|
|
self._env_overrides = self._load_env_overrides()
|
|
self.configs = {
|
|
"config": {"parser": configparser.ConfigParser(), "vars": {}},
|
|
"game": {"parser": configparser.ConfigParser(), "vars": {}},
|
|
"shop": {"parser": configparser.ConfigParser(), "vars": {}},
|
|
"transmute": {"parser": configparser.ConfigParser(), "vars": {}},
|
|
"profile": {"parser": configparser.ConfigParser(), "vars": {}},
|
|
"custom": {"parser": configparser.ConfigParser(), "vars": {}},
|
|
}
|
|
self.configs["config"]["parser"].read('config/params.ini')
|
|
self.configs["game"]["parser"].read('config/game.ini')
|
|
self.configs["shop"]["parser"].read('config/shop.ini')
|
|
self.configs["transmute"]["parser"].read('config/transmute.ini')
|
|
|
|
# Profile layer: per-user/per-character overrides that survive git
|
|
# (config/profiles/ is gitignored). Selected via config/active_profile.txt.
|
|
# Priority: custom.ini > profile.ini > params.ini.
|
|
self.active_profile = Config.get_active_profile()
|
|
if self.active_profile:
|
|
profile_ini = os.path.join("config", "profiles", self.active_profile, "profile.ini")
|
|
if os.path.exists(profile_ini):
|
|
try:
|
|
self.configs["profile"]["parser"].read(profile_ini)
|
|
Logger.info(f"Profile active: {self.active_profile} ({profile_ini})")
|
|
except configparser.MissingSectionHeaderError:
|
|
Logger.error(f"{profile_ini} missing section header — profile ignored")
|
|
else:
|
|
Logger.warning(f"Active profile '{self.active_profile}' has no profile.ini — ignored")
|
|
|
|
if os.environ.get('RUN_ENV') != "test" and os.path.exists('config/custom.ini'):
|
|
try:
|
|
self.configs["custom"]["parser"].read('config/custom.ini')
|
|
except configparser.MissingSectionHeaderError:
|
|
Logger.error("custom.ini missing section header, defaulting to params.ini")
|
|
|
|
for config_name in self.configs:
|
|
config = self.configs[config_name]
|
|
try:
|
|
for var in config["parser"]["variables"]:
|
|
config["vars"][var] = config["parser"]["variables"][var] # set var name to var value
|
|
except KeyError: # "" header section was not found for this config file.
|
|
pass
|
|
|
|
|
|
|
|
self.general = {
|
|
"saved_games_folder": self._select_val("general", "saved_games_folder"),
|
|
"name": _default_iff(self._select_val("general", "name"), "", "botty"),
|
|
"pickit_profile": self._select_optional("general", "pickit_profile", ""),
|
|
"max_game_length_s": float(self._select_val("general", "max_game_length_s")),
|
|
"max_consecutive_fails": int(self._select_val("general", "max_consecutive_fails")),
|
|
"max_maintenance_time_s": float(self._select_optional("general", "max_maintenance_time_s", "120")),
|
|
"auto_downgrade_threshold": int(self._select_optional("general", "auto_downgrade_threshold", "0")),
|
|
"max_runtime_before_break_m": float(_default_iff(self._select_val("general", "max_runtime_before_break_m"), '', 0)),
|
|
"break_length_m": float(_default_iff(self._select_val("general", "break_length_m"), '', 0)),
|
|
"randomize_runs": bool(int(self._select_val("general", "randomize_runs"))),
|
|
"difficulty": self._normalize_difficulty(self._select_val("general", "difficulty")),
|
|
"message_api_type": self._select_val("general", "message_api_type"),
|
|
"custom_message_hook": self._select_val("general", "custom_message_hook"),
|
|
"custom_loot_message_hook": self._select_val("general", "custom_loot_message_hook"),
|
|
"discord_status_count": False if not self._select_val("general", "discord_status_count") else int(self._select_val("general", "discord_status_count")),
|
|
"discord_timing_report_h": 2.0 if not self._select_val("general", "discord_timing_report_h") else float(self._select_val("general", "discord_timing_report_h")),
|
|
"discord_status_runs": False if not self._select_optional("general", "discord_status_runs", "") else int(self._select_optional("general", "discord_status_runs", "")),
|
|
"discord_log_chicken": bool(int(self._select_val("general", "discord_log_chicken"))),
|
|
"discord_log_errors": bool(int(self._select_optional("general", "discord_log_errors", "1"))),
|
|
"info_screenshots": bool(int(self._select_val("general", "info_screenshots"))),
|
|
"error_screenshots": bool(int(self._select_optional("general", "error_screenshots", "1"))),
|
|
"disable_run_after_failures": int(self._select_optional("general", "disable_run_after_failures", "5")),
|
|
"pickup_drought_window": int(self._select_optional("general", "pickup_drought_window", "10")),
|
|
"pickit_screenshots": bool(int(self._select_val("general", "pickit_screenshots"))),
|
|
"d2r_path": _default_iff(self._select_val("general", "d2r_path"), "", r"C:\Program Files (x86)\Diablo II Resurrected"),
|
|
"restart_d2r_when_stuck": bool(int(self._select_val("general", "restart_d2r_when_stuck"))),
|
|
"hardcore": bool(int(self._select_val("general", "hardcore"))),
|
|
"auto_login": bool(int(self._select_val("general", "auto_login"))),
|
|
"bnet_name": self._select_val("general", "bnet_name"),
|
|
"bnet_pass": self._select_val("general", "bnet_pass"),
|
|
"char_name": self._select_val("general", "char_name"),
|
|
"stash_scan_interval": False if not self._select_optional("general", "stash_scan_interval", "0") else int(self._select_optional("general", "stash_scan_interval", "0")),
|
|
}
|
|
self.general["name"] = self._env_override("name", self.general["name"])
|
|
self.general["custom_message_hook"] = self._env_override("custom_message_hook", self.general["custom_message_hook"])
|
|
self.general["custom_loot_message_hook"] = self._env_override("custom_loot_message_hook", self.general["custom_loot_message_hook"])
|
|
self.general["bnet_name"] = self._env_override("bnet_name", self.general["bnet_name"])
|
|
self.general["bnet_pass"] = self._env_override("bnet_pass", self.general["bnet_pass"])
|
|
self.general["char_name"] = self._env_override("char_name", self.general["char_name"])
|
|
self.discord_events = {
|
|
"status": bool(int(self._select_optional("discord_events", "status", "1"))),
|
|
"item_keep": bool(int(self._select_optional("discord_events", "item_keep", "1"))),
|
|
"death": bool(int(self._select_optional("discord_events", "death", "1"))),
|
|
"chicken": bool(int(self._select_optional("discord_events", "chicken", "1"))),
|
|
"stash_full": bool(int(self._select_optional("discord_events", "stash_full", "1"))),
|
|
"gold_full": bool(int(self._select_optional("discord_events", "gold_full", "1"))),
|
|
"error": bool(int(self._select_optional("discord_events", "error", "1"))),
|
|
}
|
|
|
|
self.stealth = {
|
|
"wait_jitter_min": float(self._select_optional("stealth", "wait_jitter_min", "0.85")),
|
|
"wait_jitter_max": float(self._select_optional("stealth", "wait_jitter_max", "1.20")),
|
|
"reshuffle_each_rotation": bool(int(self._select_optional("stealth", "reshuffle_each_rotation", "1"))),
|
|
"skip_run_chance": int(self._select_optional("stealth", "skip_run_chance", "10")),
|
|
"afk_break_chance": int(self._select_optional("stealth", "afk_break_chance", "5")),
|
|
"afk_break_min_m": float(self._select_optional("stealth", "afk_break_min_m", "2")),
|
|
"afk_break_max_m": float(self._select_optional("stealth", "afk_break_max_m", "12")),
|
|
"run_duration_variance": float(self._select_optional("stealth", "run_duration_variance", "0.15")),
|
|
"micro_pause_min_ms": int(self._select_optional("stealth", "micro_pause_min_ms", "20")),
|
|
"micro_pause_max_ms": int(self._select_optional("stealth", "micro_pause_max_ms", "120")),
|
|
"human_curve_complexity": float(self._select_optional("stealth", "human_curve_complexity", "1.0")),
|
|
"vary_kill_time": bool(int(self._select_optional("stealth", "vary_kill_time", "1"))),
|
|
# These eight were present in params.ini but never loaded here, so
|
|
# utils.stealth's cfg.get(key, <hardcoded>) always won and editing
|
|
# params.ini had NO effect. They only looked correct because the
|
|
# hardcoded fallbacks happened to match the shipped ini values.
|
|
"click_delay_min_ms": int(self._select_optional("stealth", "click_delay_min_ms", "50")),
|
|
"click_delay_max_ms": int(self._select_optional("stealth", "click_delay_max_ms", "250")),
|
|
"key_press_min_ms": int(self._select_optional("stealth", "key_press_min_ms", "20")),
|
|
"key_press_max_ms": int(self._select_optional("stealth", "key_press_max_ms", "200")),
|
|
"skill_hesitation_min_ms": int(self._select_optional("stealth", "skill_hesitation_min_ms", "80")),
|
|
"skill_hesitation_max_ms": int(self._select_optional("stealth", "skill_hesitation_max_ms", "300")),
|
|
"wrong_waypoint_chance": float(self._select_optional("stealth", "wrong_waypoint_chance", "0.025")),
|
|
"skill_mistake_chance": float(self._select_optional("stealth", "skill_mistake_chance", "0.015")),
|
|
# Opt-in switches for behaviours that alter WHERE or WHEN a click
|
|
# lands. Both default OFF: the per-call-site randomize= values in
|
|
# npc_manager/waypoint are hand-tuned against real button geometry,
|
|
# and stacking a global offset on top is what starts missing NPCs.
|
|
"click_delay_enabled": bool(int(self._select_optional("stealth", "click_delay_enabled", "0"))),
|
|
# Session rhythm — the signals per-action jitter cannot reach.
|
|
"session_budget_h": float(self._select_optional("stealth", "session_budget_h", "0")),
|
|
# Hard per-CALENDAR-DAY cap. session_budget_h is per process, so a restart
|
|
# resets it and a bot restarted all day still runs all day. This one is
|
|
# persisted to disk and survives restarts. 0 = unlimited.
|
|
"daily_budget_h": float(self._select_optional("stealth", "daily_budget_h", "0")),
|
|
"daily_budget_jitter": float(self._select_optional("stealth", "daily_budget_jitter", "0.12")),
|
|
# Close D2R when the daily cap trips. A bot process idling at the character
|
|
# select screen for 16h is itself a signal; a real player quits the game.
|
|
"daily_budget_close_game": bool(int(self._select_optional("stealth", "daily_budget_close_game", "1"))),
|
|
"idle_drift_enabled": bool(int(self._select_optional("stealth", "idle_drift_enabled", "1"))),
|
|
"chicken_variance": float(self._select_optional("stealth", "chicken_variance", "0.08")),
|
|
"town_browse_chance": float(self._select_optional("stealth", "town_browse_chance", "0.06")),
|
|
"pickup_skip_chance": float(self._select_optional("stealth", "pickup_skip_chance", "0.02")),
|
|
}
|
|
|
|
self.routes = {}
|
|
order_str = self._select_val("routes", "order")
|
|
self.routes_order = [x.strip() for x in order_str.split(",")]
|
|
del self.configs["config"]["parser"]["routes"]["order"]
|
|
for key in self.routes_order:
|
|
self.routes[key] = True
|
|
# Botty only knows "run_shenk" but in orders we split run_eldritch and run_eldritch_shenk
|
|
self.routes_order = ["run_shenk" if x in ["run_eldritch", "run_eldritch_shenk"] else x for x in self.routes_order]
|
|
|
|
self.char = {
|
|
"type": self._select_val("char", "type"),
|
|
"show_items": self._select_val("char", "show_items"),
|
|
"inventory_screen": self._select_val("char", "inventory_screen"),
|
|
"teleport": self._select_val("char", "teleport"),
|
|
"stand_still": self._select_val("char", "stand_still"),
|
|
"force_move": self._select_val("char", "force_move"),
|
|
"num_loot_columns": int(self._select_val("char", "num_loot_columns")),
|
|
"take_health_potion": float(self._select_val("char", "take_health_potion")),
|
|
"take_mana_potion": float(self._select_val("char", "take_mana_potion")),
|
|
"take_rejuv_potion_health": float(self._select_val("char", "take_rejuv_potion_health")),
|
|
"take_rejuv_potion_mana": float(self._select_val("char", "take_rejuv_potion_mana")),
|
|
"heal_merc": float(self._select_val("char", "heal_merc")),
|
|
"heal_rejuv_merc": float(self._select_val("char", "heal_rejuv_merc")),
|
|
"chicken": float(self._select_val("char", "chicken")),
|
|
"merc_chicken": float(self._select_val("char", "merc_chicken")),
|
|
"town_portal": self._select_val("char", "town_portal"),
|
|
"belt_rows": int(self._select_val("char", "belt_rows")),
|
|
"show_belt": self._select_val("char", "show_belt"),
|
|
"potion1": self._select_val("char", "potion1"),
|
|
"potion2": self._select_val("char", "potion2"),
|
|
"potion3": self._select_val("char", "potion3"),
|
|
"potion4": self._select_val("char", "potion4"),
|
|
"belt_rejuv_columns": int(self._select_val("char", "belt_rejuv_columns")),
|
|
"belt_hp_columns": int(self._select_val("char", "belt_hp_columns")),
|
|
"belt_mp_columns": int(self._select_val("char", "belt_mp_columns")),
|
|
"stash_gold": bool(int(self._select_val("char", "stash_gold"))),
|
|
"use_merc": bool(int(self._select_val("char", "use_merc"))),
|
|
"open_chests": bool(int(self._select_val("char", "open_chests"))),
|
|
"fill_shared_stash_first": bool(int(self._select_val("char", "fill_shared_stash_first"))),
|
|
"pre_buff_every_run": bool(int(self._select_val("char", "pre_buff_every_run"))),
|
|
"cta_available": bool(int(self._select_val("char", "cta_available"))),
|
|
"buff_with_cta": bool(int(self._select_val("char", "buff_with_cta"))),
|
|
"weapon_switch": self._select_val("char", "weapon_switch"),
|
|
"battle_orders": self._select_val("char", "battle_orders"),
|
|
"battle_command": self._select_val("char", "battle_command"),
|
|
"casting_frames": int(self._select_val("char", "casting_frames")),
|
|
"cta_casting_frames": int(self._select_val("char", "cta_casting_frames")),
|
|
"attack_frames": int(self._select_val("char", "attack_frames")),
|
|
"atk_len_arc": float(self._select_val("char", "atk_len_arc")),
|
|
"atk_len_trav": float(self._select_val("char", "atk_len_trav")),
|
|
"atk_len_pindle": float(self._select_val("char", "atk_len_pindle")),
|
|
"atk_len_eldritch": float(self._select_val("char", "atk_len_eldritch")),
|
|
"atk_len_shenk": float(self._select_val("char", "atk_len_shenk")),
|
|
"atk_len_nihlathak": float(self._select_val("char", "atk_len_nihlathak")),
|
|
"atk_len_diablo_vizier": float(self._select_val("char", "atk_len_diablo_vizier")),
|
|
"atk_len_diablo_deseis": float(self._select_val("char", "atk_len_diablo_deseis")),
|
|
"atk_len_diablo_infector": float(self._select_val("char", "atk_len_diablo_infector")),
|
|
"atk_len_diablo": float(self._select_val("char", "atk_len_diablo")),
|
|
"atk_len_cs_trashmobs": float(self._select_val("char", "atk_len_cs_trashmobs")),
|
|
"kill_cs_trash": bool(int(self._select_val("char", "kill_cs_trash"))),
|
|
"cs_town_visits": bool(int(self._select_val("char", "cs_town_visits"))),
|
|
"cs_mob_detect": bool(int(self._select_val("char", "cs_mob_detect"))),
|
|
"runs_per_stash": False if not self._select_val("char", "runs_per_stash") else int(self._select_val("char", "runs_per_stash")),
|
|
"runs_per_repair": False if not self._select_val("char", "runs_per_repair") else int(self._select_val("char", "runs_per_repair")),
|
|
"repair_npc": self._select_optional("char", "repair_npc", "a4_halbu").strip().lower(),
|
|
"resurrect_npc": self._select_optional("char", "resurrect_npc", "").strip().lower(),
|
|
"gamble_items": False if not self._select_val("char", "gamble_items") else self._select_val("char", "gamble_items").replace(" ","").split(","),
|
|
"sell_junk": bool(int(self._select_val("char", "sell_junk"))),
|
|
"protect_shields_from_sell": bool(int(self._select_optional("char", "protect_shields_from_sell", "1"))),
|
|
"protect_charms_from_sell": bool(int(self._select_optional("char", "protect_charms_from_sell", "1"))),
|
|
"pick_rares_for_gold": bool(int(self._select_optional("char", "pick_rares_for_gold", "0"))),
|
|
"pick_gold": bool(int(self._select_optional("char", "pick_gold", "1"))),
|
|
"enable_no_pickup": bool(int(self._select_val("char", "enable_no_pickup"))),
|
|
"safer_routines": bool(int(self._select_val("char", "safer_routines"))),
|
|
}
|
|
# Cold Plains clear run
|
|
self.cold_plains = dict(self.configs["config"]["parser"]["cold_plains"])
|
|
if "cold_plains" in self.configs["profile"]["parser"]:
|
|
self.cold_plains.update(dict(self.configs["profile"]["parser"]["cold_plains"]))
|
|
if "cold_plains" in self.configs["custom"]["parser"]:
|
|
self.cold_plains.update(dict(self.configs["custom"]["parser"]["cold_plains"]))
|
|
# Baal XP farm (join public games, hide, collect XP, leave)
|
|
self.baal_xp = {
|
|
"enabled": self._select_optional("baal_xp", "enabled", "0") not in (None, "", "0", "false", "False"),
|
|
"game_name_filter": self._select_optional("baal_xp", "game_name_filter", "") or "",
|
|
"max_wait_s": float(self._select_optional("baal_xp", "max_wait_s", "900")),
|
|
"xp_threshold": int(self._select_optional("baal_xp", "xp_threshold", "50000000")),
|
|
"min_hp_pct": float(self._select_optional("baal_xp", "min_hp_pct", "40")),
|
|
"hide_x": int(self._select_optional("baal_xp", "hide_x", "640")),
|
|
"hide_y": int(self._select_optional("baal_xp", "hide_y", "360")),
|
|
"join_timeout_s": float(self._select_optional("baal_xp", "join_timeout_s", "60")),
|
|
}
|
|
for _baal_xp_src in ("profile", "custom"):
|
|
if "baal_xp" in self.configs[_baal_xp_src]["parser"]:
|
|
_baal_xp_over = self.configs[_baal_xp_src]["parser"]["baal_xp"]
|
|
for k in list(self.baal_xp.keys()):
|
|
if k not in _baal_xp_over:
|
|
continue
|
|
raw = _baal_xp_over[k]
|
|
if k == "enabled":
|
|
self.baal_xp[k] = raw not in (None, "", "0", "false", "False")
|
|
elif isinstance(self.baal_xp[k], str):
|
|
self.baal_xp[k] = raw if raw is not None else ""
|
|
else:
|
|
self.baal_xp[k] = type(self.baal_xp[k])(raw)
|
|
# Sorc base config
|
|
sorc_base_cfg = dict(self.configs["config"]["parser"]["sorceress"])
|
|
if "sorceress" in self.configs["profile"]["parser"]:
|
|
sorc_base_cfg.update(dict(self.configs["profile"]["parser"]["sorceress"]))
|
|
if "sorceress" in self.configs["custom"]["parser"]:
|
|
sorc_base_cfg.update(dict(self.configs["custom"]["parser"]["sorceress"]))
|
|
# blizz sorc
|
|
self.blizz_sorc = dict(self.configs["config"]["parser"]["blizz_sorc"])
|
|
if "blizz_sorc" in self.configs["profile"]["parser"]:
|
|
self.blizz_sorc.update(dict(self.configs["profile"]["parser"]["blizz_sorc"]))
|
|
if "blizz_sorc" in self.configs["custom"]["parser"]:
|
|
self.blizz_sorc.update(dict(self.configs["custom"]["parser"]["blizz_sorc"]))
|
|
self.blizz_sorc.update(sorc_base_cfg)
|
|
# blizzorb sorc
|
|
self.blizzorb_sorc = dict(self.configs["config"]["parser"]["blizzorb_sorc"])
|
|
if "blizzorb_sorc" in self.configs["profile"]["parser"]:
|
|
self.blizzorb_sorc.update(dict(self.configs["profile"]["parser"]["blizzorb_sorc"]))
|
|
if "blizzorb_sorc" in self.configs["custom"]["parser"]:
|
|
self.blizzorb_sorc.update(dict(self.configs["custom"]["parser"]["blizzorb_sorc"]))
|
|
self.blizzorb_sorc.update(sorc_base_cfg)
|
|
# light sorc
|
|
self.light_sorc = dict(self.configs["config"]["parser"]["light_sorc"])
|
|
if "light_sorc" in self.configs["profile"]["parser"]:
|
|
self.light_sorc.update(dict(self.configs["profile"]["parser"]["light_sorc"]))
|
|
if "light_sorc" in self.configs["custom"]["parser"]:
|
|
self.light_sorc.update(dict(self.configs["custom"]["parser"]["light_sorc"]))
|
|
self.light_sorc.update(sorc_base_cfg)
|
|
# nova sorc
|
|
self.nova_sorc = dict(self.configs["config"]["parser"]["nova_sorc"])
|
|
if "nova_sorc" in self.configs["profile"]["parser"]:
|
|
self.nova_sorc.update(dict(self.configs["profile"]["parser"]["nova_sorc"]))
|
|
if "nova_sorc" in self.configs["custom"]["parser"]:
|
|
self.nova_sorc.update(dict(self.configs["custom"]["parser"]["nova_sorc"]))
|
|
self.nova_sorc.update(sorc_base_cfg)
|
|
# hydra sorc
|
|
self.hydra_sorc = dict(self.configs["config"]["parser"]["hydra_sorc"])
|
|
if "hydra_sorc" in self.configs["profile"]["parser"]:
|
|
self.hydra_sorc.update(dict(self.configs["profile"]["parser"]["hydra_sorc"]))
|
|
if "hydra_sorc" in self.configs["custom"]["parser"]:
|
|
self.hydra_sorc.update(dict(self.configs["custom"]["parser"]["hydra_sorc"]))
|
|
self.hydra_sorc.update(sorc_base_cfg)
|
|
|
|
# Warlock base config
|
|
warlock_base_cfg = dict(self.configs["config"]["parser"]["warlock"])
|
|
if "warlock" in self.configs["profile"]["parser"]:
|
|
warlock_base_cfg.update(dict(self.configs["profile"]["parser"]["warlock"]))
|
|
if "warlock" in self.configs["custom"]["parser"]:
|
|
warlock_base_cfg.update(dict(self.configs["custom"]["parser"]["warlock"]))
|
|
# fire warlock
|
|
self.fire_lock = dict(self.configs["config"]["parser"]["fire_lock"])
|
|
if "fire_lock" in self.configs["profile"]["parser"]:
|
|
self.fire_lock.update(dict(self.configs["profile"]["parser"]["fire_lock"]))
|
|
if "fire_lock" in self.configs["custom"]["parser"]:
|
|
self.fire_lock.update(dict(self.configs["custom"]["parser"]["fire_lock"]))
|
|
self.fire_lock.update(warlock_base_cfg)
|
|
# echo warlock
|
|
self.echo_lock = dict(self.configs["config"]["parser"]["echo_lock"])
|
|
if "echo_lock" in self.configs["profile"]["parser"]:
|
|
self.echo_lock.update(dict(self.configs["profile"]["parser"]["echo_lock"]))
|
|
if "echo_lock" in self.configs["custom"]["parser"]:
|
|
self.echo_lock.update(dict(self.configs["custom"]["parser"]["echo_lock"]))
|
|
self.echo_lock.update(warlock_base_cfg)
|
|
# abyss warlock
|
|
self.abyss_lock = dict(self.configs["config"]["parser"]["abyss_lock"])
|
|
if "abyss_lock" in self.configs["profile"]["parser"]:
|
|
self.abyss_lock.update(dict(self.configs["profile"]["parser"]["abyss_lock"]))
|
|
if "abyss_lock" in self.configs["custom"]["parser"]:
|
|
self.abyss_lock.update(dict(self.configs["custom"]["parser"]["abyss_lock"]))
|
|
self.abyss_lock.update(warlock_base_cfg)
|
|
# Paladin base config
|
|
paladin_base_cfg = dict(self.configs["config"]["parser"]["paladin"])
|
|
if "paladin" in self.configs["profile"]["parser"]:
|
|
paladin_base_cfg.update(dict(self.configs["profile"]["parser"]["paladin"]))
|
|
if "paladin" in self.configs["custom"]["parser"]:
|
|
paladin_base_cfg.update(dict(self.configs["custom"]["parser"]["paladin"]))
|
|
# Hammerdin config
|
|
self.hammerdin = self.configs["config"]["parser"]["hammerdin"]
|
|
if "hammerdin" in self.configs["profile"]["parser"]:
|
|
self.hammerdin.update(dict(self.configs["profile"]["parser"]["hammerdin"]))
|
|
if "hammerdin" in self.configs["custom"]["parser"]:
|
|
self.hammerdin.update(self.configs["custom"]["parser"]["hammerdin"])
|
|
self.hammerdin.update(paladin_base_cfg)
|
|
# FoHdin config
|
|
self.fohdin = dict(self.configs["config"]["parser"]["fohdin"])
|
|
if "fohdin" in self.configs["profile"]["parser"]:
|
|
self.fohdin.update(dict(self.configs["profile"]["parser"]["fohdin"]))
|
|
if "fohdin" in self.configs["custom"]["parser"]:
|
|
self.fohdin.update(self.configs["custom"]["parser"]["fohdin"])
|
|
self.fohdin.update(paladin_base_cfg)
|
|
|
|
# Amazon base config
|
|
amazon_base_cfg = dict(self.configs["config"]["parser"]["amazon"])
|
|
if "amazon" in self.configs["profile"]["parser"]:
|
|
amazon_base_cfg.update(dict(self.configs["profile"]["parser"]["amazon"]))
|
|
if "amazon" in self.configs["custom"]["parser"]:
|
|
amazon_base_cfg.update(dict(self.configs["custom"]["parser"]["amazon"]))
|
|
# javazon
|
|
self.javazon = dict(self.configs["config"]["parser"]["javazon"])
|
|
if "javazon" in self.configs["profile"]["parser"]:
|
|
self.javazon.update(dict(self.configs["profile"]["parser"]["javazon"]))
|
|
if "javazon" in self.configs["custom"]["parser"]:
|
|
self.javazon.update(dict(self.configs["custom"]["parser"]["javazon"]))
|
|
self.javazon.update(amazon_base_cfg)
|
|
|
|
# Assassin config
|
|
self.trapsin = self.configs["config"]["parser"]["trapsin"]
|
|
if "trapsin" in self.configs["profile"]["parser"]:
|
|
self.trapsin.update(dict(self.configs["profile"]["parser"]["trapsin"]))
|
|
if "trapsin" in self.configs["custom"]["parser"]:
|
|
self.trapsin.update(self.configs["custom"]["parser"]["trapsin"])
|
|
|
|
# Barbarian config
|
|
self.barbarian = self.configs["config"]["parser"]["barbarian"]
|
|
if "barbarian" in self.configs["profile"]["parser"]:
|
|
self.barbarian.update(dict(self.configs["profile"]["parser"]["barbarian"]))
|
|
if "barbarian" in self.configs["custom"]["parser"]:
|
|
self.barbarian.update(self.configs["custom"]["parser"]["barbarian"])
|
|
self.barbarian = dict(self.barbarian)
|
|
self.barbarian["cry_frequency"] = float(self.barbarian["cry_frequency"])
|
|
|
|
# Basic config
|
|
self.basic = self.configs["config"]["parser"]["basic"]
|
|
if "basic" in self.configs["profile"]["parser"]:
|
|
self.basic.update(dict(self.configs["profile"]["parser"]["basic"]))
|
|
if "basic" in self.configs["custom"]["parser"]:
|
|
self.basic.update(self.configs["custom"]["parser"]["basic"])
|
|
|
|
# Basic Ranged config
|
|
self.basic_ranged = self.configs["config"]["parser"]["basic_ranged"]
|
|
if "basic_ranged" in self.configs["profile"]["parser"]:
|
|
self.basic_ranged.update(dict(self.configs["profile"]["parser"]["basic_ranged"]))
|
|
if "basic_ranged" in self.configs["custom"]["parser"]:
|
|
self.basic_ranged.update(self.configs["custom"]["parser"]["basic_ranged"])
|
|
|
|
# Necro config
|
|
self.necro = self.configs["config"]["parser"]["necro"]
|
|
if "necro" in self.configs["profile"]["parser"]:
|
|
self.necro.update(dict(self.configs["profile"]["parser"]["necro"]))
|
|
if "necro" in self.configs["custom"]["parser"]:
|
|
self.necro.update(self.configs["custom"]["parser"]["necro"])
|
|
|
|
self.poison_necro = self.configs["config"]["parser"]["poison_necro"]
|
|
if "poison_necro" in self.configs["profile"]["parser"]:
|
|
self.poison_necro.update(dict(self.configs["profile"]["parser"]["poison_necro"]))
|
|
if "poison_necro" in self.configs["custom"]["parser"]:
|
|
self.poison_necro.update(self.configs["custom"]["parser"]["poison_necro"])
|
|
|
|
self.bone_necro = self.configs["config"]["parser"]["bone_necro"]
|
|
if "bone_necro" in self.configs["profile"]["parser"]:
|
|
self.bone_necro.update(dict(self.configs["profile"]["parser"]["bone_necro"]))
|
|
if "bone_necro" in self.configs["custom"]["parser"]:
|
|
self.bone_necro.update(self.configs["custom"]["parser"]["bone_necro"])
|
|
|
|
self.advanced_options = {
|
|
"pathing_delay_factor": min(max(int(self._select_val("advanced_options", "pathing_delay_factor")), 1), 10),
|
|
"message_headers": self._select_val("advanced_options", "message_headers"),
|
|
"message_body_template": self._select_val("advanced_options", "message_body_template"),
|
|
"graphic_debugger_layer_creator": bool(int(self._select_val("advanced_options", "graphic_debugger_layer_creator"))),
|
|
"logg_lvl": self._select_val("advanced_options", "logg_lvl"),
|
|
"exit_key": self._select_val("advanced_options", "exit_key"),
|
|
"resume_key": self._select_val("advanced_options", "resume_key"),
|
|
"auto_settings_key": self._select_val("advanced_options", "auto_settings_key"),
|
|
"restore_settings_from_backup_key": self._select_val("advanced_options", "restore_settings_from_backup_key"),
|
|
"settings_backup_key": self._select_val("advanced_options", "settings_backup_key"),
|
|
"select_runs_key": self._select_val("advanced_options", "select_runs_key"),
|
|
"graphic_debugger_key": self._select_val("advanced_options", "graphic_debugger_key"),
|
|
"cycle_pickit_profile_key": self._select_val("advanced_options", "cycle_pickit_profile_key"),
|
|
"hwnd_window_title": _default_iff(Config()._select_val("advanced_options", "hwnd_window_title"), ''),
|
|
"hwnd_window_process": _default_iff(Config()._select_val("advanced_options", "hwnd_window_process"), ''),
|
|
"window_client_area_offset": tuple(map(int, Config()._select_val("advanced_options", "window_client_area_offset").split(","))),
|
|
"ocr_during_pickit": bool(int(self._select_val("advanced_options", "ocr_during_pickit"))),
|
|
"launch_options": self._build_launch_options(),
|
|
"override_capabilities": _default_iff(Config()._select_optional("advanced_options", "override_capabilities"), ""),
|
|
}
|
|
|
|
self.log_rotation = {
|
|
"pickit_max_files": int(self._select_optional("log_rotation", "pickit_max_files", "300")),
|
|
"pickit_max_mb": int(self._select_optional("log_rotation", "pickit_max_mb", "200")),
|
|
"info_max_files": int(self._select_optional("log_rotation", "info_max_files", "100")),
|
|
"info_max_mb": int(self._select_optional("log_rotation", "info_max_mb", "200")),
|
|
"items_max_files": int(self._select_optional("log_rotation", "items_max_files", "50")),
|
|
"items_max_mb": int(self._select_optional("log_rotation", "items_max_mb", "50")),
|
|
"discord_notify_rotation": bool(int(self._select_optional("log_rotation", "discord_notify_rotation", "1"))),
|
|
}
|
|
|
|
self.colors = {}
|
|
for key in self.configs["game"]["parser"]["colors"]:
|
|
self.colors[key] = np.split(np.array([int(x) for x in self._select_val("colors", key).split(",")]), 2)
|
|
|
|
self.ui_pos = {}
|
|
for key in self.configs["game"]["parser"]["ui_pos"]:
|
|
self.ui_pos[key] = int(self._select_val("ui_pos", key))
|
|
|
|
self.ui_roi = {}
|
|
for key in self.configs["game"]["parser"]["ui_roi"]:
|
|
self.ui_roi[key] = np.array([int(x) for x in self._select_val("ui_roi", key).split(",")])
|
|
open_width = int(self.ui_pos["slot_width"] * self.char["num_loot_columns"])
|
|
# calc roi for restricted inventory area
|
|
self.ui_roi["restricted_inventory_area"] = self.ui_roi["right_inventory"].copy()
|
|
self.ui_roi["restricted_inventory_area"][0] += open_width # left
|
|
self.ui_roi["restricted_inventory_area"][2] -= open_width # width
|
|
# calc roi for open inventory area
|
|
self.ui_roi["open_inventory_area"] = self.ui_roi["right_inventory"].copy()
|
|
self.ui_roi["open_inventory_area"][2] = open_width # width
|
|
|
|
self.path = {}
|
|
for key in self.configs["game"]["parser"]["path"]:
|
|
self.path[key] = np.reshape(np.array([int(x) for x in self._select_val("path", key).split(",")]), (-1, 2))
|
|
|
|
self.shop = {
|
|
"shop_trap_claws": bool(int(self._select_val("claws", "shop_trap_claws"))),
|
|
"shop_melee_claws": bool(int(self._select_val("claws", "shop_melee_claws"))),
|
|
"shop_3_skills_ias_gloves": bool(int(self._select_val("gloves", "shop_3_skills_ias_gloves"))),
|
|
"shop_2_skills_ias_gloves": bool(int(self._select_val("gloves", "shop_2_skills_ias_gloves"))),
|
|
"trap_min_score": int(self._select_val("claws", "trap_min_score")),
|
|
"melee_min_score": int(self._select_val("claws", "melee_min_score")),
|
|
"shop_hammerdin_scepters": bool(int(self._select_val("scepters", "shop_hammerdin_scepters"))),
|
|
"speed_factor": float(self._select_val("scepters", "speed_factor")),
|
|
"apply_pather_adjustment": bool(int(self._select_val("scepters", "apply_pather_adjustment"))),
|
|
}
|
|
|
|
# Auto-detect key bindings from D2R .key file
|
|
try:
|
|
from utils.key_detector import apply_key_bindings
|
|
apply_key_bindings(self)
|
|
except Exception as e:
|
|
Logger.debug(f"Key auto-detection skipped: {e}")
|
|
stash_destination_str = self._select_val("transmute","stash_destination")
|
|
transmute_str = self._select_val("transmute","transmute")
|
|
self.configs["transmute"]["parser"] = {
|
|
"stash_destination": [int(x.strip()) for x in stash_destination_str.split(",")],
|
|
"transmute_every_x_game": self._select_val("transmute","transmute_every_x_game"),
|
|
"transmute": [x.strip() for x in transmute_str.split(",")],
|
|
"stash_tabs": int(self._select_val("transmute","stash_tabs")),
|
|
"convert_rejuv": self._select_val("transmute","convert_rejuv") == "1",
|
|
"min_rejuv_to_convert": int(self._select_val("transmute","min_rejuv_to_convert")),
|
|
}
|
|
|
|
def _build_launch_options(self) -> str:
|
|
"""Build D2R launch options, appending auto-login flags when bnet credentials are set."""
|
|
opts = self._select_val("advanced_options", "launch_options").replace(
|
|
"<name>", only_lowercase_letters(self.general["name"].lower())
|
|
)
|
|
if self.general.get("auto_login", False):
|
|
bnet_name = self.general.get("bnet_name", "")
|
|
bnet_pass = self.general.get("bnet_pass", "")
|
|
if bnet_name and bnet_pass:
|
|
opts += f" -bnetname {bnet_name} -bnetpass {bnet_pass}"
|
|
return opts
|
|
|
|
if __name__ == "__main__":
|
|
from copy import deepcopy
|
|
config = Config()
|