feat(tools): inventory scanner; protect the Torch, tomes and keys from disposal

scripts/inventory_scan.py hovers every occupied inventory slot, OCRs it, runs the
same pickit rules the bot uses, and prints a grid plus a per-item verdict
(KEEP / JUNK / STUCK / needs-ID) with the loot and safe zones marked. Read-only —
it hovers and opens the panel, never clicks an item. Multi-slot items are merged,
since armour is 2x3 and hovering each slot otherwise reports one Breast Plate
three times.

Running it against the live inventory found three items one bad drop away from
being thrown on the floor, all reported as plain JUNK:

- HELLFIRE TORCH. There was no unique-largecharm rule in the pickit at all, and
  "torch" contains no "charm", so the charm guard missed it too. Added the rule
  here and to the active pickit under config/bnip/ (which is gitignored);
  expression count 519 -> 520 confirms it parsed. Name guard added behind it.
- TOME OF TOWN PORTAL / TOME OF IDENTIFY. ITEM_CONSUMABLES_MAP only knows the
  SCROLL names ("scroll of town portal"), never the tome ones, so both read as
  junk. Dropping the TP tome would strand the bot with no way home.

Tomes and keys are "essential" rather than merely protected: they are exempted
from the stash routing added for junk charms too, since the bot needs them in the
inventory, not in the stash.

Verified after the change: HELLFIRE TORCH -> KEEP via the new rule, both tomes ->
STUCK (protected), and the safe zone (columns 7-10) holds Torch, Annihilus and
the small charms, which inspect_items never scans.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
alexpolo1
2026-09-04 13:18:50 +02:00
co-authored by Claude Opus 5
parent 0ef702878f
commit 477c693703
3 changed files with 247 additions and 1 deletions
+1
View File
@@ -45,6 +45,7 @@
[Name] == Grandcharm && [Quality] == Unique // Gheed n sunders
[Name] == Smallcharm && [Quality] == Unique // Annihilus Charm
[Name] == Largecharm && [Quality] == Unique // Hellfire Torch
//------------------------------------------
//================= Potions ================
+217
View File
@@ -0,0 +1,217 @@
"""Inventory scanner — read the character's inventory and report what is in it.
Hovers every occupied slot, OCRs the item, runs the SAME pickit rules the bot
uses (bnip.actions.should_keep), and prints a grid plus a per-item verdict. Use
it to answer "why is my inventory full?" without guessing from the log.
Read-only: it moves the mouse to hover and opens the inventory panel. It never
clicks an item, so nothing is picked up, sold, dropped or stashed.
Run from the project root with D2R in a game (town is fine):
<botty-env-python> scripts/inventory_scan.py
<botty-env-python> scripts/inventory_scan.py --json log/inventory_scan.json
Verdict column:
KEEP a pickit rule matched -> will be stashed
ID needs identifying first
JUNK no rule matched -> will be sold, or stashed if protected
STUCK no rule matched AND protected from sale (charms/shields) -> it can
only leave via the stash; if that is not happening it accumulates
"""
import argparse
import json
import os
import sys
import time
_script_dir = os.path.dirname(os.path.abspath(__file__))
_root = os.path.dirname(_script_dir)
_src = os.path.join(_root, "src")
if _src not in sys.path:
sys.path.insert(0, _src)
os.chdir(_root)
import ctypes
ctypes.windll.user32.SetProcessDPIAware()
from config import Config
from screen import grab, convert_screen_to_monitor, start_detecting_window, stop_detecting_window
from ui_manager import is_visible, ScreenObjects
from input_layer import mouse
from inventory import common, personal
from d2r_image import processing as d2r_image
from bnip.actions import should_keep
from utils.misc import wait
ROWS = 4
COLS = 10
def _center_mouse():
mouse.move(*convert_screen_to_monitor((640, 360)), randomize=6, delay_factor=[0.2, 0.4])
wait(0.1, 0.2)
def _is_protected(name: str) -> str | None:
"""Mirror of personal.transfer_items._is_protected — why an item cannot be sold."""
n = (name or "").lower()
if "tome" in n or n.startswith("key of") or n == "key":
return "tome/key"
if "torch" in n:
return "torch"
if Config().char.get("protect_charms_from_sell", True) and "charm" in n:
return "charm"
if Config().char.get("protect_shields_from_sell", True) and "shield" in n:
return "shield"
for kw in str(Config().char.get("never_sell_keywords", "")).split(","):
if kw.strip() and kw.strip().lower() in n:
return f"keyword:{kw.strip()}"
return None
def scan() -> list[dict]:
img = personal.open_inventory()
if img is None:
print("Could not open the inventory panel.")
return []
found = []
for row in range(ROWS):
for col in range(COLS):
_, slot_img = common.get_slot_pos_and_img(img, col, row)
if not common.slot_has_item(slot_img):
continue
pos, _ = common.get_slot_pos_and_img(img, col, row)
x_m, y_m = convert_screen_to_monitor(pos)
_center_mouse()
mouse.move(x_m, y_m, randomize=4, delay_factor=[0.2, 0.3])
wait(0.25, 0.35)
hovered = grab(True)
try:
props, box = d2r_image.get_hovered_item(hovered)
except Exception as e:
found.append({"col": col, "row": row, "name": "<read failed>",
"verdict": "?", "detail": str(e)[:60], "text": ""})
continue
if box is None:
found.append({"col": col, "row": row, "name": "<no tooltip>",
"verdict": "?", "detail": "", "text": ""})
continue
text = (box.ocr_result.text or "").strip()
name = text.splitlines()[0] if text else "<unnamed>"
unidentified = is_visible(ScreenObjects.Unidentified, box.img)
try:
keep, expr = should_keep(props.as_dict())
except Exception as e:
keep, expr = False, f"rule error: {e}"
protected = _is_protected(name)
if keep:
verdict, detail = "KEEP", (expr or "").strip()
elif unidentified:
verdict, detail = "ID", "unidentified"
elif protected:
verdict, detail = "STUCK", f"no rule matched; protected ({protected})"
else:
verdict, detail = "JUNK", "no rule matched"
found.append({"col": col, "row": row, "name": name, "verdict": verdict,
"detail": detail, "text": text.replace("\n", " | "),
"slots": 1})
_center_mouse()
return _merge_multislot(found)
def _merge_multislot(found):
"""Collapse the slots of one multi-slot item into a single entry.
Armour is 2x3 and a Large Charm 1x2, so hovering every occupied slot reports
the same item once per slot it covers - a first scan listed one Breast Plate
three times. Slots that touch orthogonally and carry an identical tooltip are
the same item. Two identical items sitting adjacent would merge, which is why
`slots` is reported: a count that is not a plausible footprint is the tell.
"""
by_pos = {(i["col"], i["row"]): i for i in found}
seen, merged = set(), []
for item in found:
key = (item["col"], item["row"])
if key in seen:
continue
group, stack = [], [key]
while stack:
pos = stack.pop()
if pos in seen or pos not in by_pos or by_pos[pos]["text"] != item["text"]:
continue
seen.add(pos)
group.append(pos)
c, r = pos
stack += [(c + 1, r), (c - 1, r), (c, r + 1), (c, r - 1)]
top = min(group, key=lambda q: (q[1], q[0]))
entry = dict(by_pos[top])
entry["slots"] = len(group)
merged.append(entry)
return merged
def report(items: list[dict]) -> None:
occupied = {(i["col"], i["row"]): i for i in items}
total = ROWS * COLS
print()
used = sum(i.get("slots", 1) for i in items)
loot_cols = Config().char["num_loot_columns"]
print(f"Inventory: {used}/{total} slots used by {len(items)} items, {total - used} free")
print(f"Loot zone: columns 1-{loot_cols} Safe zone (bot never touches): {loot_cols + 1}-{COLS}")
print()
sym = {"KEEP": "K", "JUNK": "j", "STUCK": "X", "ID": "?", "?": "."}
print(" " + " ".join(f"{c:>2}" for c in range(COLS)))
for r in range(ROWS):
cells = []
for c in range(COLS):
it = occupied.get((c, r))
cells.append(f"{sym.get(it['verdict'], '.') if it else '.':>2}")
print(f" r{r} " + " ".join(cells))
print("\n K=keep j=junk(sellable) X=STUCK(junk but unsellable) ?=needs ID .=empty")
counts = {}
for i in items:
counts[i["verdict"]] = counts.get(i["verdict"], 0) + 1
print("\n " + " ".join(f"{k}={v}" for k, v in sorted(counts.items())))
print(f"\n{'slot':6} {'verdict':8} {'item':34} why")
print("-" * 100)
for i in sorted(items, key=lambda x: (x["verdict"] != "STUCK", x["row"], x["col"])):
zone = "loot" if i["col"] < Config().char["num_loot_columns"] else "SAFE"
print(f"c{i['col']+1}r{i['row']+1:<2} {i.get('slots',1):>2} {zone:5}{i['verdict']:8} "
f"{i['name'][:32]:32} {i['detail'][:40]}")
stuck = [i for i in items if i["verdict"] == "STUCK"]
if stuck:
print(f"\n{len(stuck)} STUCK item(s): no pickit rule matched, and they cannot be sold or")
print("dropped. They can only leave via the stash. If they are still here after a")
print("stash cycle, that is the bug — grep the log for 'marking for stash'.")
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--json", metavar="PATH", help="also write the result as JSON")
args = ap.parse_args()
start_detecting_window()
time.sleep(1.5)
if not is_visible(ScreenObjects.InGame, grab(force_new=True)):
print("Not in a game — enter a game (town is fine) and rerun. Nothing was touched.")
stop_detecting_window()
sys.exit(2)
try:
items = scan()
report(items)
if args.json:
os.makedirs(os.path.dirname(os.path.abspath(args.json)), exist_ok=True)
with open(args.json, "w", encoding="utf-8") as f:
json.dump(items, f, indent=2)
print(f"\nwrote {args.json}")
finally:
stop_detecting_window()
+29 -1
View File
@@ -444,11 +444,34 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
img = img if img is not None else grab(True)
filtered = []
left_panel_open = is_visible(ScreenObjects.LeftPanel, img)
def _is_essential(item_name: str) -> bool:
"""Kit the bot needs to function — must never leave the inventory at all.
Not merely unsellable: unlike a junk charm these must not be routed to the
stash either, or the bot loses the ability to portal home / identify.
"""
return ("tome" in item_name
or item_name.startswith("key of")
or item_name == "key")
def _is_protected(item) -> bool:
# Items that must NEVER be sold or dropped, regardless of pickit verdict.
# Charms sit in the inventory permanently (their bonuses only work there) -
# a misread/unmatched charm must not leave via the vendor or the floor.
item_name = (item.name or "").lower()
# Tomes are equipment, not loot: no pickit rule matches them, and
# ITEM_CONSUMABLES_MAP only knows the SCROLL names ("scroll of town portal"),
# never the tome ones - so a Tome of Town Portal reads as plain junk. With
# sell_junk off that verdict means the floor, which would strand the bot with
# no way to portal home. Same for keys. Never let either leave the inventory.
if _is_essential(item_name):
return True
# "HELLFIRE TORCH" contains no "charm", so the charm guard below misses it, and
# the pickit had no largecharm+unique rule - a live scan classed the Torch as
# plain JUNK. Belt and braces alongside the rule now in config/bnip/: never let
# a torch leave the inventory by sale or by the floor.
if "torch" in item_name:
return True
if Config().char.get("protect_charms_from_sell", True) and "charm" in item_name:
return True
if Config().char.get("protect_shields_from_sell", True) and "shield" in item_name:
@@ -462,8 +485,13 @@ def transfer_items(items: list, action: str = "drop", img: np.ndarray = None) ->
safe = []
for item in candidates:
if _is_protected(item):
Logger.warning(f"Blocked {action_name} for protected item: {item.name} at {item.pos} — marking for stash")
item.sell = False
if _is_essential((item.name or "").lower()):
# Leave it exactly where it is: keep=False means the stash step
# skips it too, which is what we want for the TP/ID tomes.
Logger.debug(f"Blocked {action_name} for essential item: {item.name} at {item.pos} — leaving in inventory")
continue
Logger.warning(f"Blocked {action_name} for protected item: {item.name} at {item.pos} — marking for stash")
# Send it to the stash instead of leaving it in limbo. Without this a
# protected item pickit rejected (keep=False) can be neither sold, nor
# dropped, nor stashed (the stash branch only takes keep==True), so it