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:
co-authored by
Claude Opus 5
parent
0ef702878f
commit
477c693703
@@ -45,6 +45,7 @@
|
||||
|
||||
[Name] == Grandcharm && [Quality] == Unique // Gheed n sunders
|
||||
[Name] == Smallcharm && [Quality] == Unique // Annihilus Charm
|
||||
[Name] == Largecharm && [Quality] == Unique // Hellfire Torch
|
||||
|
||||
//------------------------------------------
|
||||
//================= Potions ================
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user