Add chipped gem conversion runner

This commit is contained in:
alexpolo1
2026-06-21 16:06:58 +02:00
parent 48d8445c05
commit 6012934e66
4 changed files with 256 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
# GEMS-tab transmute flow (rewritten in commit 48d8445)
`src/transmute/transmute.py``Transmute.convert_all_gems()` and helpers.
## What changed and why
The old flow drove gem upgrades through the **Horadric Cube UI**: it required the cube to live in
the PERSONAL stash tab, opened the cube, ctrl+shift+right-clicked 3 gems into it, clicked the cube's
Transmute button, then pulled the result back out. This was fragile — it depended on cube placement,
cube-open detection (`ScreenObjects.CubeOpened` via `wait_until_visible`), and a pre-flight
"empty the cube" pass.
The rewrite uses D2R's **native GEMS-tab convert panel** instead of the cube. Gems are moved
directly between the GEMS stack grid and the convert panel, and conversion uses the GEMS tab's own
convert button. The cube is no longer required to be in the stash and, if present in the character
inventory, is left untouched.
## Fixed stack-coordinate grid
`GEMS_TAB_STACK_COORDS` is a new module-level dict mapping every gem template
(`INVENTORY_<FAMILY>_<TIER>`) to a fixed `(x, y)` **screen** coordinate at 1280×720. The grid is:
- **Columns by family** (x): diamond 85, emerald 132, ruby 180, topaz 227, amethyst 275,
sapphire 322, skull 370.
- **Rows by tier** (y): chipped 154, flawed 195, standard 234, flawless 273, perfect 311.
Because gem stacks always render at these fixed slots on the GEMS tab, the flow can click them by
coordinate instead of template-searching for them each time. `_gems_stack_monitor_for(template)`
converts the screen coord to monitor coords (returns `None` if the template isn't in the grid, so
the caller can fall back to a template search).
## Panel constants (1280×720)
| Constant | Value | Meaning |
|----------|-------|---------|
| `GEMS_TAB_X` | `223` | GEMS tab click X (was `200`) |
| `GEMS_CONVERT_BUTTON` | `(225, 500)` | native convert button in the GEMS panel |
| `GEMS_CONVERT_PANEL_ROI` | `(160, 296, 128, 160)` | ROI to search for the converted result |
| `GEMS_CONVERT_FIRST_SLOT` | `(181, 318)` | fallback slot to grab the result if the search misses |
## Per-transmute flow (new)
For each gem type/tier with enough gems to convert:
1. **Switch to GEMS tab.**
2. **Load 3 gems**`_ctrl_shift_left_click_monitor` the stack slot ×3 to move 3 gems into the
convert panel. If exactly 3 weren't loaded, abort that batch.
3. **Convert**`_click_gems_tab_convert_button()` clicks the panel's native convert button.
4. **Recover the result** — template-search `gems_out` within `GEMS_CONVERT_PANEL_ROI`; if found,
ctrl+shift+left-click it back onto the GEMS tab. If not found, fall back to ctrl+shift+left-click
on `GEMS_CONVERT_FIRST_SLOT` (instead of the old behavior of clearing the cube and bailing).
5. **Leave the inventory cube untouched** and return to the GEMS tab for the next iteration.
The old pre-flight "open cube → empty 12 cube slots → reopen stash" pass and the per-iteration
"close cube / reopen stash" steps are **removed**.
## Helper changes
- `_open_cube_from_stash()` / `_open_cube_from_gems_tab()` / `_open_available_cube_for_gems_tab()`
now operate from the **GEMS** tab rather than PERSONAL, and can open the cube from character
inventory (`_open_cube_from_inventory`) when the GEMS tab is active. These remain for older
inventory flows; the new gem flow doesn't need them.
- `_locate_cube()` can now return `'opened'` (cube UI already open on the GEMS tab) in addition to
`'stash'` / `'inventory'`.
- `_ensure_cube_available()` now treats `inventory` as usable ("it will stay there") instead of an
error — the cube no longer has to be moved to PERSONAL.
- `_ensure_cube_in_stash()` dropped the loot-column safety guard: it now ctrl+clicks any positive
`CubeInventory` match, so the cube can be moved from reserved inventory columns too.
- **Keyboard modifier handling** switched from the low-level
`win_input.key_down/key_up(_get_vk(...))` to `keyboard.send(..., do_release=False)` /
`keyboard.release(...)` wrapped in `try/finally`, so ctrl+shift are always released even if the
click raises. Applies to both `_ctrl_shift_click_monitor` (right) and
`_ctrl_shift_left_click_monitor` (left).
- `_empty_cube_to_gems_tab()` now uses `_ctrl_click_monitor` (plain ctrl) rather than ctrl+shift.
## Gem counting (`_count_gems_by_ocr`)
Counting no longer template-searches the left-inventory ROI for each gem. It now reads each fixed
slot from `GEMS_TAB_STACK_COORDS`: crop the slot icon, and **skip empty slots** by checking the
95th-percentile grayscale brightness (`< 45` ⇒ no gem present). Occupied slots still OCR the count
badge in the bottom-right quadrant. This is faster and immune to template-match drift, at the cost
of depending on the fixed 1280×720 grid.
## Resolution assumption
The whole flow is hard-coded to **1280×720**. All coordinates above are screen coordinates at that
resolution, converted to monitor coordinates at click time via `convert_screen_to_monitor`.

35
run_chipped_gems.bat Normal file
View File

@@ -0,0 +1,35 @@
@echo off
setlocal enabledelayedexpansion
rem Chipped-only GEMS-tab converter.
rem Requirements:
rem - D2R is open
rem - character is in-game
rem - stash UI is open on or near the GEMS tab
rem This script does not open or click the character-inventory cube.
set "BOTTY_DIR=%~dp0"
cd /d "%BOTTY_DIR%"
call "%BOTTY_DIR%find_python.bat"
if errorlevel 1 exit /b 1
for %%F in ("%PYTHON%") do set "_ENV=%%~dpF"
set "_ENV=%_ENV:~0,-1%"
set "PATH=%_ENV%;%_ENV%\Scripts;%_ENV%\Library\bin;%_ENV%\Library\mingw-w64\bin;%_ENV%\Library\usr\bin;%_ENV%\DLLs;%PATH%"
set "CONDA_PREFIX=%_ENV%"
set "PYTHONUTF8=1"
set "PYTHONIOENCODING=utf-8"
set "SSL_CERT_DIR="
set "TESSDATA_PREFIX=%_ENV%\Library\share"
set "PYTESSERACT_TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe"
echo Running chipped-only GEMS converter...
echo Before/after screenshots and a JSON summary will be written to fixtures\screenshots.
echo.
"%PYTHON%" "%BOTTY_DIR%tools\convert_chipped_gems.py" %*
set "RC=%ERRORLEVEL%"
pause
exit /b %RC%

View File

@@ -0,0 +1,132 @@
"""
Chipped-only GEMS-tab conversion runner.
This is intentionally narrower than tools/testbed.py gems_all:
- stash must already be open in-game
- only chipped gems are converted
- the character-inventory cube is not touched
- before/after screenshots and count snapshots are saved for review
"""
from __future__ import annotations
import ctypes
import json
import os
import ssl
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
OUT_DIR = ROOT / "fixtures" / "screenshots"
def _patch_ssl() -> None:
ssl.SSLContext.load_default_certs = lambda *args, **kwargs: None
def _setup_imports() -> None:
sys.path.insert(0, str(SRC))
_patch_ssl()
if os.name == "nt":
ctypes.windll.user32.SetProcessDPIAware()
def _save_screen(name: str):
from screen import grab
import cv2
OUT_DIR.mkdir(parents=True, exist_ok=True)
path = OUT_DIR / name
cv2.imwrite(str(path), grab())
return path
def _print_plan(plan: list[tuple[int, str, list[str], str]]) -> None:
if not plan:
print("No chipped gem stacks with 3+ gems were detected.")
return
print("Planned chipped conversions:")
for n_transmutes, gem_template, gems_out, _tier in plan:
target = gems_out[0] if gems_out else "UNKNOWN"
print(f" {n_transmutes}x {gem_template} -> {target}")
def main() -> int:
_setup_imports()
from game_stats import GameStats
from screen import start_detecting_window, stop_detecting_window
from transmute import Transmute
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
transmute = Transmute(GameStats())
start_detecting_window()
try:
before_path = _save_screen(f"chipped_script_{run_id}_before.png")
before_counts = transmute._count_gems_for_plan()
before_plan = transmute._filter_transmute_plan(
transmute._plan_transmutes(before_counts),
tier_filter=["chipped"],
)
_print_plan(before_plan)
if not before_plan:
return 0
transmute.convert_all_gems_to_perfect(tier_filter=["chipped"])
after_path = _save_screen(f"chipped_script_{run_id}_after.png")
after_counts = transmute._count_gems_for_plan()
after_plan = transmute._filter_transmute_plan(
transmute._plan_transmutes(after_counts),
tier_filter=["chipped"],
)
summary = {
"run_id": run_id,
"before_screenshot": str(before_path.relative_to(ROOT)),
"after_screenshot": str(after_path.relative_to(ROOT)),
"before_counts": before_counts,
"after_counts": after_counts,
"planned_before": [
{
"count": n,
"input": gem_template,
"output": gems_out[0] if gems_out else None,
"tier": tier,
}
for n, gem_template, gems_out, tier in before_plan
],
"remaining_chipped_plan": [
{
"count": n,
"input": gem_template,
"output": gems_out[0] if gems_out else None,
"tier": tier,
}
for n, gem_template, gems_out, tier in after_plan
],
}
summary_path = OUT_DIR / f"chipped_script_{run_id}_summary.json"
summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
print(f"Before screenshot: {before_path.relative_to(ROOT)}")
print(f"After screenshot: {after_path.relative_to(ROOT)}")
print(f"Summary: {summary_path.relative_to(ROOT)}")
if after_plan:
print("Warning: some chipped stacks still appear convertible after the run:")
_print_plan(after_plan)
else:
print("Done: no chipped stack with 3+ gems remains in the plan.")
finally:
stop_detecting_window()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -18,6 +18,8 @@ Scenarios:
standard->flawless->perfect (in a game, stash OPEN).
Optional filters: a tier and/or gem type, e.g. "gems_all diamond"
or "gems_all flawless diamond".
For chipped-only conversion with before/after analysis, use
run_chipped_gems.bat instead.
stash Force-run the stashing routine: deposit gold + stash keep-flagged
inventory items, with page rotation (in a game, stash OPEN).
keyo Verify Controls-page key bindings (.keyo) match params (anywhere;