Add chipped gem conversion runner
This commit is contained in:
87
docs/gems_transmute_flow.md
Normal file
87
docs/gems_transmute_flow.md
Normal 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
35
run_chipped_gems.bat
Normal 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%
|
||||
132
tools/convert_chipped_gems.py
Normal file
132
tools/convert_chipped_gems.py
Normal 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())
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user