diff --git a/config/default.bnip b/config/default.bnip index a657a42..7caf55b 100644 --- a/config/default.bnip +++ b/config/default.bnip @@ -45,6 +45,7 @@ [Name] == Grandcharm && [Quality] == Unique // Gheed n sunders [Name] == Smallcharm && [Quality] == Unique // Annihilus Charm +[Name] == Largecharm && [Quality] == Unique // Hellfire Torch //------------------------------------------ //================= Potions ================ diff --git a/scripts/inventory_scan.py b/scripts/inventory_scan.py new file mode 100644 index 0000000..b9d09fc --- /dev/null +++ b/scripts/inventory_scan.py @@ -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): + scripts/inventory_scan.py + 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": "", + "verdict": "?", "detail": str(e)[:60], "text": ""}) + continue + if box is None: + found.append({"col": col, "row": row, "name": "", + "verdict": "?", "detail": "", "text": ""}) + continue + + text = (box.ocr_result.text or "").strip() + name = text.splitlines()[0] if text else "" + 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() diff --git a/src/inventory/personal.py b/src/inventory/personal.py index b21e7c5..b44c059 100644 --- a/src/inventory/personal.py +++ b/src/inventory/personal.py @@ -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