Three linked failures that together took the nightmare run from ~8% to ~37% failures.
1. A5_WP matched the HUD. main's recaptured a5_wp.png (14f5876, pulled in by my merge)
scores 0.966 on the belt/mana-orb area at (980, 656) — above threshold and higher than
the real stone — so every search locked onto the HUD, clicked it, and open_wp burned
~83s escalating through anchors before giving up. buy_consumables hit 222-253s and
tripped the 240s maintenance timeout.
Fix: search the waypoint within ui_roi[cut_skill_bar] so the HUD can never compete
(select_by_template gained an optional roi for this), and search BOTH captures with
best_match — main's new one and the pre-merge a5_wp_2.png, which reaches 0.73-0.98 here
against the new one's 0.50-0.58. Neither setup has to lose. 222s -> 33s.
2. My own regression, introduced an hour earlier. The merc check pressed 'o' and closed
the panel only when MercPanelText matched, leaving the CHRONICLE panel (what 'o' opens
on this client) up all game. I "fixed" that with an unconditional esc — which is worse:
with nothing open, esc opens the GAME MENU, which LeftPanel/RightPanel do not match, so
nothing closed that either and every later search saw a menu instead of the town.
Fix: always toggle 'o' back. 'o' opened it, so 'o' closes it, and it cannot open
something new. Symmetry, not detection.
3. resurrect_npc=a4_tyrael (new config, mirrors repair_npc). Measured today:
A4 Tyrael ok 8.2s, ok 24.9s - 0 errors, ever
A5 Qual-Kehk fail 113.6s / 52.0s / 163.1s / 72.6s - 43 errors
Tyrael stands on a fixed spot by the A4 waypoint; Qual-Kehk is the least reliable NPC
in the route. Falls back to the current act if the trip to A4 fails.
Verified live: 4 games, 0 failures, town 17-23s (was 178-230s), approach 11-15s.
Co-Authored-By: Claude Opus 5 <[email protected]>
672 lines
38 KiB
Python
672 lines
38 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")),
|
|
"click_variance": int(self._select_optional("stealth", "click_variance", "8")),
|
|
"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"))),
|
|
}
|
|
|
|
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()
|