fix: weapon swap verification, health pot selling, town healing, pickup drought alert

- 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 <noreply@anthropic.com>
This commit is contained in:
alex
2026-06-05 16:36:07 +02:00
parent 77f2092572
commit 6c8da72bf4
6 changed files with 166 additions and 7 deletions

View File

@@ -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

View File

@@ -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` |
---

View File

@@ -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")

View File

@@ -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

View File

@@ -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)

View File

@@ -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}")