From 6c8da72bf4fe8f016d318ba61ec0f69fd9cd51c5 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 5 Jun 2026 16:36:07 +0200 Subject: [PATCH] fix: weapon swap verification, health pot selling, town healing, pickup drought alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i_char.py: rewrite _pre_buff_cta() to verify each weapon switch via BC skill-bar template; detects wrong slot on game start (leftover from interrupted buff cycle), retries failed switches once, logs clearly when stuck on CTA slot to prevent dying with wrong weapon in combat - personal.py: protect needed consumables from sell/drop in inspect_items; check get_needs() before marking a pot for discard — if the belt needs that pot type, skip it so fill_up_belt_from_inventory can restock later - bot.py: add fill_up_belt_from_inventory + update_pot_needs after buy_consumables so inventory pots reach the belt even when out of gold; add town-heal loop at start of on_maintenance to drink health/rejuv pots until HP >= 95% before the next run (health manager is paused in town) - game_stats.py: add rolling 10-game pickup health check; warns in log and sends Discord alert when zero item pickups occur across 10 games while chickens or merc deaths are present - params.ini: fix show_belt=n -> show_belt=k (belt key was wrong, causing 1.5s wasted recovery attempt every first game in a session) Co-Authored-By: Claude Sonnet 4.6 --- config/params.ini | 2 +- docs/hermes_qwen_takeover.md | 13 ++++++-- src/bot.py | 30 +++++++++++++++++ src/char/i_char.py | 65 +++++++++++++++++++++++++++++++++++- src/game_stats.py | 52 +++++++++++++++++++++++++++++ src/inventory/personal.py | 11 ++++-- 6 files changed, 166 insertions(+), 7 deletions(-) diff --git a/config/params.ini b/config/params.ini index 82b1f3e..781c3fd 100644 --- a/config/params.ini +++ b/config/params.ini @@ -216,7 +216,7 @@ potion2=2 potion3=3 potion4=4 ; show_belt is different from the default hotkey as "~" is for many keyboards not reachable without also pressing altgr -show_belt=n +show_belt=k show_items=alt ; stand_still cannot be the default "shift" as it would interfere with merc healing stand_still=capslock diff --git a/docs/hermes_qwen_takeover.md b/docs/hermes_qwen_takeover.md index 7578dfc..23e881a 100644 --- a/docs/hermes_qwen_takeover.md +++ b/docs/hermes_qwen_takeover.md @@ -300,23 +300,26 @@ All fixes were applied and verified by Python compile/config tests: | Bug | File | Symptom in logs | Fix | |---|---|---|---| -| `show_belt='k'` wrong key | `config/params.ini` | "Keeping show_belt: 'k'" every boot; belt tries wrong key | `show_belt=k` → `show_belt=n` | +| `show_belt` wrong key (`n` instead of `k`) | `config/params.ini` | "Recovered belt hotkey using 'k'" on first game, then silent in-memory mutation | `show_belt=n` → `show_belt=k` | | `AttributeError: 'bool' object has no attribute 'upper'` | `src/town/town_manager.py:36` | Crash in `get_act_from_location` when `True`/`False` passed as loc | Added `isinstance(loc, str)` guard | | No-TP → infinite loop | `src/bot.py` | "No TP charges left, trying to walk back" repeated forever | Trigger `end_game` instead of `end_run` on zero TP | | Distance y-axis wrong | `src/d2r_image/processing_helpers.py` | Items sorted by wrong distance; far items picked first | `screen_width/2` → `screen_height/2` for y | | `on_maintenance` crash with no location | `src/bot.py` | Crash after chicken recovery when `_curr_loc=None` | Guard: default to `A1_TOWN_START` if None | | False-positive chicken on mana rejuv | `src/health_manager.py:133` | "Two juvs drank within 0.63s. Chicken, HP 99.9%!" | Added `and health_percentage <= take_rejuv_potion_health` | | Gold pickup infinite loop | `src/item/pickit.py:241` | 338g/157g alternating in logs for 20s | Added `PickedUpFailed` case to blacklist `item.ID` | +| Health pots sold when needed | `src/inventory/personal.py:351` | "Discarding SUPER HEALING POTION." + "Confirmed sell SUPER HEALING POTION" despite health needs | Check `get_needs()` before dropping consumable; `continue` to skip sell/drop when pot is needed | +| No fill_from_inventory after failed buy | `src/bot.py` (after line 438) | Belt empty all game despite pots sitting in inventory; "Out of gold" then nothing fills belt | After buy_consumables block, call `fill_up_belt_from_inventory` + `update_pot_needs` when needs > 0 | +| Wrong weapon in combat after chicken mid-buff | `src/char/i_char.py` `_pre_buff_cta` | Character dies immediately; dies with CTA flail/shield instead of main weapon | Added BC skill-bar template verification after each `weapon_switch`; corrects slot if wrong at game start; retries once on failure | --- ## 12) Known pending issues (as of 2026-06-05) - **C10** (IMPROVEMENTS.md): `kill_thread()` uses `PyThreadState_SetAsyncExc` — can leave locks inconsistent. Replace with `threading.Event` cooperative shutdown. High risk. -- **"Failed to switch weapon" / CTA pre-buff**: Seen in logs around weapon-switch timing. May self-resolve with correct `show_belt=n`. Investigate if it recurs. - **optipng pass on assets/**: Pending. Run `asset_manager.py batch` or `optipng -o7` on all PNGs. - **Thread safety** (H14 in IMPROVEMENTS.md): `health_manager` and `death_manager` shared state — Lock is now present in HealthManager but verify all paths use it. - **PickedUpResult enum gap** (M14): Values are 0,1,3,4,5. Value 2 is missing. Non-critical but confusing. +- **Gold vicious cycle**: Low gold → can't buy pots → health empty → more chickens/deaths → less gold. Monitor runs after the personal.py + bot.py fix — if the cycle still triggers, also check that `inspect_items` isn't being called with vendor_open=True before `fill_up_belt_from_inventory`. --- @@ -324,7 +327,7 @@ All fixes were applied and verified by Python compile/config tests: | Symptom in logs | Where to look | Likely cause | |---|---|---| -| "Keeping show_belt: 'k'" every boot | `config/params.ini [char]` | `show_belt` not matching .keyo file | +| "Recovered belt hotkey using 'k'" on game 1, then silent | `config/params.ini` | `show_belt=n` should be `show_belt=k` | | "Two juvs drank... Chicken" at HP > 80% | `src/health_manager.py:133` | Missing HP check on two-rejuv condition | | Gold pile (XYZg) repeating 5+ times | `src/item/pickit.py:241` | `PickedUpFailed` case missing; item not blacklisted | | "Failed to pick up X" then same X again immediately | `_yoink_item` / `_cached_pickit_items` | Blacklist not being set on failure | @@ -335,6 +338,10 @@ All fixes were applied and verified by Python compile/config tests: | Repair fail loops | `src/town/a5.py` + `town_manager.py` | Larzuk template noise; check A4 fallback path | | "Failed to log exp" | `src/ui/player_bar.py` | OCR misread; check for `I/l` → `1` ambiguity | | Sell includes wrong items | `src/inventory/personal.py` | `protect_shields_from_sell` or item filter issue | +| "Discarding SUPER HEALING POTION" + "Confirmed sell..." | `src/inventory/personal.py:351` | Consumable sold despite belt need — fixed by get_needs() guard | +| Belt needs stay health=3/mana=3 game after game; pots never drunk | `src/bot.py` after buy_consumables + `personal.py:351` | Health pots sold during inspect; no fill_from_inventory fallback | +| "started on CTA slot" in logs; dies in first seconds of run | `src/char/i_char.py _pre_buff_cta` | Game saved with CTA slot active (interrupted buff). Use `BC` template check at startup to detect and correct | +| Character enters run at partial HP (e.g. 40% after chicken) | `src/bot.py on_maintenance` | Health manager paused in town; no town-heal loop. Check `meters.get_health` and drink belt pots in maintenance before `update_pot_needs` | --- diff --git a/src/bot.py b/src/bot.py index f19ddc9..247a8f2 100644 --- a/src/bot.py +++ b/src/bot.py @@ -356,6 +356,29 @@ class Bot: if not view.dismiss_skills_icon(): view.return_to_play() + # Top up health with belt potions before doing any town work. + # The health manager is paused in town so it won't auto-pot for us. + # We aim for ~95% HP so the character enters the next run at full health. + # Drinks health first (cheaper), then rejuv as fallback if health is unavailable. + # Happens before update_pot_needs so the reduced belt is accurately counted + # and the buy step will restock whatever was drunk. + _town_heal_threshold = 0.95 + _town_heal_max_drinks = 5 + for _drink_attempt in range(_town_heal_max_drinks): + _hp_img = grab() + _hp = meters.get_health(_hp_img) + if _hp >= _town_heal_threshold: + break + Logger.info(f"Town heal: HP at {_hp * 100:.0f}% — drinking potion to recover (attempt {_drink_attempt + 1}/{_town_heal_max_drinks})") + _mp = meters.get_mana(_hp_img) + _drank = belt.drink_potion("health", stats=[_hp, _mp]) + if not _drank: + _drank = belt.drink_potion("rejuv", stats=[_hp, _mp]) + if not _drank: + Logger.debug("Town heal: no health or rejuv potions in belt — cannot recover HP in town") + break + wait(2.0, 2.5) # wait for potion to tick (town regen is slow) + # Look at belt to figure out how many pots need to be picked up belt.update_pot_needs() @@ -447,6 +470,13 @@ class Bot: Logger.warning("Heal failed after retry, continuing without heal") self._curr_loc = Location.A1_TOWN_START + # After buying (or failing to buy), pull any pots already sitting in inventory into the belt. + # This covers the "out of gold" case where we couldn't buy new pots but have leftover ones. + if consumables.get_needs("health") > 0 or consumables.get_needs("mana") > 0 or consumables.get_needs("rejuv") > 0: + Logger.debug("Filling belt from inventory pots before stash") + belt.fill_up_belt_from_inventory(Config().char["num_loot_columns"]) + belt.update_pot_needs() + # Stash stuff if keep_items or stash_gold: Logger.info("Stashing items") diff --git a/src/char/i_char.py b/src/char/i_char.py index 5dc8a88..2bbd530 100644 --- a/src/char/i_char.py +++ b/src/char/i_char.py @@ -304,9 +304,63 @@ class IChar: return False def _pre_buff_cta(self): + """Switch to CTA weapon, cast BC + BO, switch back to main. + + Uses the BC skill-bar template to verify each weapon switch rather than + blindly toggling — prevents the character from fighting with the wrong + weapon after a chicken/death interrupted a previous buff cycle and saved + the game with the CTA slot active. + """ + bc_hotkey = Config().char.get("battle_command") + + if not bc_hotkey: + # No hotkey configured → can't verify; fall back to unverified toggle + Logger.warning("_pre_buff_cta: battle_command hotkey not set — using unverified weapon switch") + keyboard.send(Config().char["weapon_switch"]) + wait(0.3, 0.35) + self._select_skill(skill="battle_command", mouse_click_type="right", delay=(0.1, 0.2)) + mouse.click(button="right") + wait(self._cta_cast_duration, self._cta_cast_duration) + self._select_skill(skill="battle_orders", mouse_click_type="right", delay=(0.1, 0.2)) + mouse.click(button="right") + wait(self._cta_cast_duration, self._cta_cast_duration) + if Config().char["buff_with_cta"]: + self.cast_buffs(self._cta_cast_duration) + wait(0.08, 0.08) + keyboard.send(Config().char["weapon_switch"]) + wait(0.3, 0.35) + return True + + # --- Step 1: ensure we start on the main weapon slot --- + # If a previous run was interrupted mid-buff (chicken / death), D2R may have + # saved with the CTA slot active. Detect that by pressing the BC hotkey and + # checking if BC appears on the right skill bar. + keyboard.send(bc_hotkey) + wait(0.12, 0.18) + if skills.is_right_skill_selected(["BC"]): + Logger.warning("_pre_buff_cta: game started on CTA slot (leftover from interrupted buff) — correcting to main weapon") + keyboard.send(Config().char["weapon_switch"]) + wait(0.45, 0.55) + + # --- Step 2: switch TO CTA slot and verify --- keyboard.send(Config().char["weapon_switch"]) wait(0.3, 0.35) - self._select_skill(skill="battle_command", mouse_click_type="right", delay=(0.1, 0.2)) + keyboard.send(bc_hotkey) + wait(0.12, 0.18) + on_cta = skills.is_right_skill_selected(["BC"]) + if not on_cta: + Logger.warning("_pre_buff_cta: switch to CTA slot failed — retrying") + keyboard.send(Config().char["weapon_switch"]) + wait(0.45, 0.55) + keyboard.send(bc_hotkey) + wait(0.12, 0.18) + on_cta = skills.is_right_skill_selected(["BC"]) + if not on_cta: + Logger.error("_pre_buff_cta: could not confirm CTA slot after retry — casting Holy Shield from main and skipping CTA buffs") + self.cast_buffs(self._cast_duration) + return True # buffs cast from main; weapon slot is correct + + # --- Step 3: cast BC (already selected above) then BO --- mouse.click(button="right") wait(self._cta_cast_duration, self._cta_cast_duration) self._select_skill(skill="battle_orders", mouse_click_type="right", delay=(0.1, 0.2)) @@ -315,8 +369,17 @@ class IChar: if Config().char["buff_with_cta"]: self.cast_buffs(self._cta_cast_duration) wait(0.08, 0.08) + + # --- Step 4: switch back to main weapon slot and verify --- keyboard.send(Config().char["weapon_switch"]) wait(0.3, 0.35) + if skills.is_right_skill_selected(["BC"]): + Logger.warning("_pre_buff_cta: switch back to main failed — retrying") + keyboard.send(Config().char["weapon_switch"]) + wait(0.45, 0.55) + if skills.is_right_skill_selected(["BC"]): + Logger.error("_pre_buff_cta: still on CTA slot after retry — CHARACTER WILL FIGHT WITH WRONG WEAPON") + # Buffs are cast; return True so pre_buff() doesn't skip Holy Shield entirely. return True diff --git a/src/game_stats.py b/src/game_stats.py index 72ca1c2..18e22e7 100644 --- a/src/game_stats.py +++ b/src/game_stats.py @@ -6,6 +6,7 @@ import inspect import json import os import re +from collections import deque from beautifultable import BeautifulTable from logger import Logger @@ -80,6 +81,11 @@ class GameStats: self._events_filename = f'events_{time.strftime("%Y%m%d_%H%M%S")}.jsonl' self._mini_stats_filename = f'mini_stats_{time.strftime("%Y%m%d_%H%M%S")}.json' self._nopickup_active = False + # Per-game pickup / problem tracking (rolling 10-game health check) + self._current_game_had_pickup = False + self._current_game_chickens = 0 + self._current_game_merc_deaths = 0 + self._recent_games: deque[dict] = deque(maxlen=10) self._starting_exp = 0 self._current_exp = 0 self._current_lvl = 0 @@ -217,6 +223,7 @@ class GameStats: def log_chicken(self, img: str): self._chicken_counter += 1 + self._current_game_chickens += 1 if self._location is not None: self._location_stats[self._location]["chickens"] += 1 self._location_stats["totals"]["chickens"] += 1 @@ -228,6 +235,7 @@ class GameStats: def log_merc_death(self): self._merc_death_counter += 1 + self._current_game_merc_deaths += 1 if self._location is not None: self._location_stats[self._location]["merc_deaths"] += 1 self._location_stats["totals"]["merc_deaths"] += 1 @@ -259,6 +267,10 @@ class GameStats: self._game_counter += 1 self._timer = time.time() self._merc_resurrect_failed = False + # Reset per-game pickup / problem counters for the new game + self._current_game_had_pickup = False + self._current_game_chickens = 0 + self._current_game_merc_deaths = 0 Logger.info(f"Starting game #{self._game_counter}") self._log_event("game_started") self._persist_snapshot() @@ -289,6 +301,13 @@ class GameStats: self._consecutive_runs_failed = 0 Logger.info(f"End game. Elapsed time: {elapsed_time:.2f}s") self._log_event("game_ended", {"failed": False, "elapsed_seconds": round(elapsed_time, 2)}) + # Commit this game's snapshot to the rolling window, then check bot health. + self._recent_games.append({ + "had_pickup": self._current_game_had_pickup, + "chickens": self._current_game_chickens, + "merc_deaths": self._current_game_merc_deaths, + }) + self._check_pickup_health() self._persist_snapshot() def log_exp(self): @@ -351,6 +370,8 @@ class GameStats: payload = {"run_name": run_name, "failed": failed} if picked_up_items is not None: payload["picked_up_items"] = picked_up_items + if picked_up_items: + self._current_game_had_pickup = True self._log_event("run_finished", payload) self._persist_snapshot() @@ -359,6 +380,37 @@ class GameStats: if status_runs and (self._run_counter - 1) > 0 and (self._run_counter - 1) % status_runs == 0: self._send_status_update() + def _check_pickup_health(self): + """Warn when the last 10 games contained zero item pickups. + + Fires a log warning always, and also sends a Discord alert when chickens or + merc deaths are present (confirms the bot is in active combat but still + collecting nothing — strong signal of a real problem vs. a strict filter). + """ + window = list(self._recent_games) + if len(window) < 10: + return # not enough data yet + games_with_pickup = sum(1 for g in window if g["had_pickup"]) + if games_with_pickup > 0: + return # at least one game had a pickup — all good + total_chickens = sum(g["chickens"] for g in window) + total_merc_deaths = sum(g["merc_deaths"] for g in window) + msg = ( + f"HEALTH CHECK: Zero item pickups in the last {len(window)} games! " + f"Chickens: {total_chickens}, merc deaths: {total_merc_deaths}. " + "Possible causes: empty belt (no pots), pickit filter too strict, pathing/approach failure." + ) + Logger.warning(msg) + self._log_event("pickup_drought_alert", { + "window": len(window), + "games_with_pickup": 0, + "total_chickens": total_chickens, + "total_merc_deaths": total_merc_deaths, + }) + # Send Discord when combat problems are also confirmed (not just a strict filter) + if (total_chickens + total_merc_deaths) > 0 and self._messenger.enabled: + self._messenger.send_message(msg) + def _create_msg(self): elapsed_time = time.time() - self._start_time elapsed_time_str = hms(elapsed_time) diff --git a/src/inventory/personal.py b/src/inventory/personal.py index 18fee9d..8f9a147 100644 --- a/src/inventory/personal.py +++ b/src/inventory/personal.py @@ -347,8 +347,15 @@ def inspect_items(inp_img: np.ndarray = None, close_window: bool = True, game_st raise # make sure it's not a consumable - # TODO: logic for trying to add potion to belt if there are needs - box.keep &= not bool(consumables.is_consumable(item_properties)) + consumable_name = consumables.is_consumable(item_properties) + if consumable_name: + box.keep = False + # If this consumable type is currently needed for the belt, leave it in + # inventory so fill_up_belt_from_inventory can pull it in after town. + consumable_category = consumables.reduce_name(consumable_name) + if consumables.get_needs(consumable_category) > 0: + Logger.debug(f"Leaving needed {consumable_category} potion in inventory for belt restock.") + continue if box.keep: Logger.info(f"Keep {item_name}. Expression: {expression}")