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:
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
30
src/bot.py
30
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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user