diff --git a/.gitignore b/.gitignore index 00de3d5..29ac7a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,37 @@ test.png + +# Local Python environment +.venv/ + +# Python cache +__pycache__/ +*.pyc + +# Development screenshot/debug output +development/cosmic_test/ + +# Local configuration / secrets +credentials.json +*.credentials.json +.env +.env.* + +# Editor / OS files +.DS_Store +Thumbs.db +.vscode/ +.idea/ + +# Python tooling +.pytest_cache/ +.mypy_cache/ +.coverage + +# Temporary / backup files +*.bak +*.tmp +*~ + +# PyInstaller build output +build/ +TotalBattleHelper.exe diff --git a/README.md b/README.md index 0b8fdfc..d19b02c 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,332 @@ # Total Battle Automation -Total Battle is a terrible game that manipulates its players into spending as much money and time on it as possible. -This project contains some scripts intended to reduce the amount of time spent. +Automation tools for reducing repetitive tasks in Total Battle. -To maximise cross-platform applicability, the scripts are tailored to operate on the web version of the game, running in a browser such as Firefox. -Note that some browsers do not allow number inputs when for example selecting troops. -Sadly the web version of the game will crash relatively often. -To counter this, the scripts will try to refresh the game whenever they feel they got stuck. +The recommended interface is **TotalBattleHelper**, which provides a GUI for running Citadel and Crypt automation and opening the Citadel configuration tools. -## Crypting +The command-line interfaces remain available for testing, diagnostics, and advanced use. -The `crypt.py` script runs automatic crypting via the watchtower. -By default the script will recognise common crypts; use the `-t` flag to select rare or epic crypts. -Note that the watchtower needs to be aligned with this selection before running the script. +The automation interacts with the game visually, so the game must be visible and the expected UI elements must be available on screen. -The script should be started with Carter selected as the only captain while the game is on the worldmap, zoomed in to the maximum 125%. -It takes a careful approach to selecting crypts. -From the watchtower screen, a random one of the visible locations is chosen. -Once the location on the map is loaded, the script will find the location of a crypt according to the `-t` flag. -It will filter out any crypts that have pre-existing blue or red lines over them in order to not steal any crypts. -It will also refuse to march on any rare crypts that are already opened. +## TotalBattleHelper -Note that the crypt actually marched on may not be the one that was originally selected via the watchtower. -In particular, it may not be one of the same level. +TotalBattleHelper is the recommended way to run the automation. + +You can still start it directly with Python: + +```bash +python total_battle_helper.py +``` + +For normal day-to-day use, Linux and Windows can both be configured so TotalBattleHelper launches like a regular desktop application without opening a terminal. + +### Linux Application Launcher + +From the project directory, run the one-time installer: + +```bash +python install_total_battle_helper_linux.py +``` + +This creates a user application entry for **TotalBattleHelper**. After installation: + +1. Open the Applications menu. +2. Search for **TotalBattleHelper**. +3. Launch it once. +4. Pin that running application to the dock if desired. + +The launcher uses the project's `.venv` Python environment and starts TotalBattleHelper with no terminal window. + +### Windows Standalone EXE + +Windows can build a standalone `TotalBattleHelper.exe` launcher with: + +```powershell +.\build_total_battle_helper_windows.ps1 +``` + +The build script uses PyInstaller from the project's `.venv` and creates: + +```text +TotalBattleHelper.exe +``` + +in the project root. + +The EXE can be launched directly or pinned to the Windows taskbar. It opens without a PowerShell or Command Prompt window while Citadel, Crypt, and tool subprocesses still use the project's `.venv\Scripts\python.exe`. + +PyInstaller must be installed in the Windows virtual environment before the first build: + +```powershell +python -m pip install pyinstaller +``` + +TotalBattleHelper provides four main sections: + +- **Citadels** - run Citadel automation and select run options. +- **Crypts** - run Crypt automation and select Watchtower Crypt filter behavior. +- **Citadel Stacks** - open the Citadel stack configuration GUI. +- **Troop Catalog** - open the troop catalog and troop-ordering GUI. + +An **Under Development** section contains useful tools that are not yet considered part of the fully polished production workflow. **Watch Exchange** is currently available there. + +Automation output is displayed in the **Activity** area. + +Only one Citadel or Crypt automation process can run from TotalBattleHelper at a time. While automation is running, the run settings are locked to prevent accidental changes. + +The **Stop** button requests a graceful shutdown so the active automation can perform its normal cleanup before exiting. + +### Citadel Controls + +The Citadel panel provides: + +- **Successful launches** - stop after this many successful Citadel launches. Leave blank to run without a launch-count limit. +- **Loss handling** - use each Citadel configuration or override it with Stop, Revive, Delete All, or Delete Silver. +- **Apply MAX march speedup** - enabled by default. +- **Verbose diagnostic output** - display detailed automation diagnostics. + +Loss handling options: + +- **Use Citadel Setting** - use the configured `loss_policy`. +- **Stop** - leave losses untouched and stop before another Citadel is launched. +- **Revive** - revive all outstanding losses. +- **Delete All** - permanently remove all outstanding losses. +- **Delete Silver** - permanently remove Silver Troops and revive remaining protected losses. + +### Crypt Controls + +The Crypt panel provides: + +- **Successful launches** - stop after this many successful Crypt launches. Leave blank to run continuously. +- **Crypt Type** - Leave As Is, Common, Rare, or Epic. +- **Apply MAX march speedup** - enabled by default. Disable it to let Carter complete the march normally. +- **Verbose diagnostic output** - display detailed automation diagnostics. + +**Leave As Is** keeps the current Watchtower Crypt filters unchanged. + +Selecting **Common**, **Rare**, or **Epic** sets the Watchtower filters for that Crypt type. + +When MAX march speedup is disabled, Carter is still tracked until his natural return. Another Crypt is not launched while Carter is still away. + +## Crypts + +`crypt.py` runs the production Crypt loop through the Watchtower. + +```bash +# Run continuously +python crypt.py + +# Stop after 10 successful Crypt launches +python crypt.py --count 10 + +# Select a specific Watchtower Crypt type +python crypt.py --type common +python crypt.py --type rare +python crypt.py --type epic + +# Short form +python crypt.py -t rare + +# Do not apply MAX march speedup +python crypt.py --no-speedup + +# Show detailed diagnostic output +python crypt.py --verbose +python crypt.py -v + +# Flags can be combined +python crypt.py --type rare --count 10 --no-speedup --verbose +``` + +If `--type` is omitted, the current Watchtower Crypt filters are left unchanged. + +If `--type common`, `--type rare`, or `--type epic` is supplied, the automation selects that Crypt type in the Watchtower. + +The Crypt loop finds a Crypt through the Watchtower, launches Carter, applies MAX march speedup by default, waits for Carter to return, and repeats. While waiting, it also clicks Clan Help when available. + +With `--no-speedup`, MAX is not applied. Carter is still tracked until his return before another Crypt can be launched. + +## Citadels + +`citadel.py` runs the production Citadel loop. + +```bash +# Run continuously +python citadel.py + +# Stop after 10 successful launches +python citadel.py --count 10 + +# Use a different Citadel configuration +python citadel.py --config my_citadel_stacks.json + +# Do not apply MAX march speedup +python citadel.py --no-speedup + +# Override Citadel loss handling for this run +python citadel.py --loss stop +python citadel.py --loss revive +python citadel.py --loss delete +python citadel.py --loss silver + +# Show detailed diagnostic output +python citadel.py --verbose +python citadel.py -v + +# Flags can be combined +python citadel.py --count 10 --loss silver --verbose +python citadel.py --count 10 --no-speedup --loss revive --verbose +``` + +The Citadel loop finds a supported Citadel through the Watchtower, confirms its type, loads its configured troop stack, launches the attack, applies MAX march speedup by default, tracks the active march, handles configured losses, and repeats. + +While a Citadel march is active, the automation continues checking for Clan Help. + +For `revive`, `dismiss`, and `dismiss_silver` policies, it also periodically checks for outstanding Citadel losses while the march is still active. If losses are found, they are handled according to the selected policy, the automation returns to the World map, and tracking of the same active march continues. + +For the `stop` policy, losses are deliberately left untouched while the march remains active. After the march finishes, outstanding losses are detected and the automation stops before launching another Citadel. + +Citadel behavior is configured in `citadel_stacks.json`. Individual troop stacks are stored in `citadel_troops/`. + +Currently supported Citadels: + +- Cursed 20 +- Cursed 25 +- Elven 20 +- Elven 25 +- Elven 30 + +### Citadel Loss Policies + +Each Citadel configuration has a `loss_policy`. + +- `stop` - stop Citadel automation if outstanding losses are detected. +- `revive` - revive all outstanding losses before continuing. +- `dismiss` - permanently remove all outstanding losses before continuing. +- `dismiss_silver` - permanently remove Silver Troops, then revive any remaining protected losses such as mercenaries, monsters, Heroes, and Captains. + +The configured policy is used normally. It can be overridden for an entire `citadel.py` run with `--loss`: + +```bash +python citadel.py --loss stop +python citadel.py --loss revive +python citadel.py --loss delete +python citadel.py --loss silver +``` + +The command-line values map to the existing Citadel policies as follows: + +- `--loss stop` -> `stop` +- `--loss revive` -> `revive` +- `--loss delete` -> `dismiss` +- `--loss silver` -> `dismiss_silver` + +When `--loss` is supplied, it takes precedence over the `loss_policy` configured for individual Citadels for the duration of that run. Omitting `--loss` preserves normal per-Citadel configuration. + +Examples: + +```bash +# Run 18 Citadels and revive every loss +python citadel.py --count 18 --loss revive + +# Permanently remove Silver Troops and revive protected losses +python citadel.py --count 18 --loss silver + +# Permanently remove every outstanding loss +python citadel.py --count 18 --loss delete +``` + +## Citadel Stack Configuration + +The easiest way to manage Citadel troop stacks is through **Citadel Stacks** in TotalBattleHelper. + +The standalone Citadel stack GUI remains available: + +```bash +python citadel_stack_gui.py +``` + +The troop catalog and troop ordering used by Citadel stacks can be managed through **Troop Catalog** in TotalBattleHelper. + +The standalone troop catalog GUI remains available: + +```bash +python troop_catalog_gui.py +``` + +`citadel_stack_tool.py` provides the command-line configuration interface. + +```bash +# List configured Citadels +python citadel_stack_tool.py list + +# Show a Citadel configuration and troop stack +python citadel_stack_tool.py show elven_30 + +# Set a troop amount +python citadel_stack_tool.py set elven_30 vulture7 5000 + +# Remove a troop from a stack +python citadel_stack_tool.py remove elven_30 vulture7 + +# Set the loss policy +python citadel_stack_tool.py policy elven_30 dismiss + +# Enable or disable a Citadel +python citadel_stack_tool.py enable elven_30 on +python citadel_stack_tool.py enable elven_30 off + +# Validate all Citadel configuration +python citadel_stack_tool.py validate +``` ## Troop Selection -The `troops.py` script should be executed while on the troop selection window ahead of an attack. -It must be provided with the location of a JSON file that contains an object with unit names as its keys and numbers as its values. -The available unit names correspond to the images in the `samples/units/` directory. -Note that most lower level units are currently missing. +`troops.py` loads a troop selection from a JSON file while the Battle troop-selection screen is open. -## Chest Counter +```bash +python troops.py my_troops.json +``` -The `chest_counter.py` script is an experimental chest counter implementation that has not been tested in production. -For each chest counted it outputs a JSON object on a single line, which can be processed by other software. -It will also regularly click the help button. +The JSON object maps troop names to quantities. ## Login -The `login.py` script will automatically login given a JSON file containing an object with `"username"` and `"password"` keys. -The idea was that this could be combined with other scripts to fully automate workflows running in for instance Xvfb, which would then allow multiple sessions on the same machine. -This concept could use some further thought and convenience scripts, which I may or may not add here later. +`login.py` can log into the game using a JSON credentials file containing `username` and `password`. + +```bash +python login.py credentials.json + +# Login and continue to the World map +python login.py --worldmap credentials.json + +# Short form +python login.py -w credentials.json +``` + +## Other Tools + +Files prefixed with `dev_` are retained development or manual-assist tools. They may be useful for testing, diagnostics, or partially automated workflows, but are not part of the primary production automation interface. + +The project also contains sample-capture and development utilities, and experimental tools such as the chest counter. These are not part of the primary production automation interface. + +## Citadel Hero Frame Requirement + +Citadel automation requires the **standard/default Hero portrait frame** +while the automation is running. + +Custom, decorative, or event Hero frames can extend visually into the +Hero selection-checkbox area on the Battle screen. This can interfere +with the image-based safety check that verifies whether the Hero is +selected and available before a Citadel march is prepared. + +The Citadel automation verifies the Hero and all three Captain selection +slots before loading troops. A Citadel is allowed to proceed only when +all four slots are positively detected as selected. + +If any Hero/Captain slot is still on a march, is not selected, or cannot +be classified confidently, the Citadel is not launched and the +automation recycles through the World map and Watchtower. + +**Required setup:** Set the Hero portrait frame back to the +standard/default frame before running Citadel automation. diff --git a/automate.py b/automate.py index 9a3a2d8..deb6217 100644 --- a/automate.py +++ b/automate.py @@ -1,150 +1,2851 @@ -import pyautogui +from pathlib import Path from imutils.object_detection import non_max_suppression import pytesseract import cv2 import numpy -import mss import os import time import math import random +import json + +from game_monitor import detect_game_monitor, capture_game_monitor +from input_control import InputController +from ui_registry import ( + get_ui_definition, + static_items_for_screen, +) + + +DEFAULT_CONFIG = { + "timing": { + "hover_jiggle_pause": 0.05, + "state_poll_delay": 0.25, + + "crypt_tab_select_wait": 0.4, + "watchtower_refresh_wait": 1.0, + "crypt_center_wait": 1.5, + "crypt_panel_wait": 0.3, + "crypt_panel_retry_wait": 0.15, + "rare_open_wait": 0.5, + + "speedup_panel_wait": 0.5, + "speedup_click_wait": 0.5, + "troop_load_timeout": 30.0, + }, + "matching": { + "carter_state_threshold": 0.003, + "citadel_type_threshold": 0.050, + "citadel_type_margin": 0.030, + "cursed_citadel_threshold": 0.070, + "cursed_citadel_margin": 0.030, + "citadel_key_threshold": 0.050, + "citadel_key_margin": 0.050, + }, + "ui": { + "battle_scroll_anchor_x_offset": -100, + "battle_scroll_anchor_y_offset": 0, + }, + "ui_regions": { + "top_center": { + "width_ratio": 0.30, + "height_ratio": 0.45, + "center_x_ratio": 0.50, + "center_y_ratio": 0.22, + }, + "middle_center": { + "width_ratio": 0.42, + "height_ratio": 0.60, + "center_x_ratio": 0.50, + "center_y_ratio": 0.52, + }, + "bottom_center": { + "width_ratio": 0.34, + "height_ratio": 0.42, + "center_x_ratio": 0.50, + "center_y_ratio": 0.79, + }, + "bottom_right": { + "width_ratio": 0.30, + "height_ratio": 0.35, + "center_x_ratio": 0.85, + "center_y_ratio": 0.82, + }, + }, +} + class Automator: - def __init__(self, scale = 1, threshold = 0.054, kill_file = os.path.expanduser("~/.tba_stop")): + def __init__( + self, + scale=1, + threshold=0.054, + kill_file=os.path.expanduser("~/.tba_stop"), + auto_detect_monitor=True, + config_path="automation_config.json", + verbose=False, + ): self.kill_file = kill_file + self.verbose = bool(verbose) + + # Runtime configuration is loaded once when the + # Automator starts. Missing values fall back to + # known-safe defaults. + self.config = { + section: values.copy() + for section, values + in DEFAULT_CONFIG.items() + } + + config_file = Path( + config_path + ) + + if config_file.exists(): + with config_file.open( + "r", + encoding="utf-8", + ) as f: + user_config = json.load(f) + + for section, values in ( + user_config.items() + ): + if ( + section in self.config + and isinstance(values, dict) + ): + self.config[ + section + ].update(values) + else: + self.config[ + section + ] = values + + self.timing = self.config[ + "timing" + ] + + self.matching = self.config[ + "matching" + ] + + self.ui_config = self.config[ + "ui" + ] + + self.ui_regions_config = self.config[ + "ui_regions" + ] + + # Stable UI coordinates learned during this session. + # + # Only static anchors belong here. Dynamic state such as + # Carter, Explore, Open, marches, and Watchtower GO rows + # must still be detected from fresh screenshots. + self.ui_locations = {} + + # Runtime UI context established by our own actions. + # + # Unlike ui_locations, this stores state assumptions such + # as which Watchtower tab we last selected. + # + # Example: + # + # self.context_state["watchtower_tab"] = "monsters" + # + # This is intentionally session-only. + self.context_state = {} + if os.path.exists(kill_file): os.remove(kill_file) + self.scale = scale + + # Samples were captured against a 3840-pixel-wide + # reference game monitor. Keep this separate from + # self.scale, which controls processing/image scaling. + self.sample_scale = 1.0 + self.threshold = threshold / scale self.method = cv2.TM_SQDIFF_NORMED + self.samples = dict() self.image = None self.loc = None self.multi = None - def get_sample(self, name): - t = (name, self.scale) - if t in self.samples: - return self.samples[t] - sample = cv2.imread(os.path.dirname(__file__) + "/samples/" + name + ".png") - sample = cv2.resize(sample, (0, 0), fx = self.scale, fy = self.scale) - self.samples[t] = sample - return sample + # Complete game-discovery result. On Windows this may + # include the authoritative HWND/client geometry selected + # by game_monitor.py. + self.game_discovery = None + + # Exact Windows HWND selected by game discovery. + # Kept separate from generic title-based discovery so + # browser and standalone Total Battle windows cannot be + # confused during keyboard input. + self.game_window_hwnd = None + + # Physical monitor containing the game. + self.game_monitor = None + + # Actual Total Battle window geometry, expressed in + # monitor-relative coordinates. + self.game_window_geometry = None + + if auto_detect_monitor: + self.detect_game_monitor() + + # Initial template scale. + # + # Prefer scale discovered visually from the actual + # Total Battle state/HUD anchors. Window dimensions + # alone do not determine UI rendering scale, + # especially for browser-hosted or windowed games. + discovered_scale = None + + if self.game_discovery is not None: + discovered_scale = ( + self.game_discovery.get( + "sample_scale" + ) + ) + + if discovered_scale is not None: + self.sample_scale = float( + discovered_scale + ) + + self.debug( + f"Sample scale: " + f"{self.sample_scale:.6f} " + f"(visual discovery)" + ) + + else: + # Generic/Linux fallback retains the previous + # width-derived behavior when visual scale + # discovery is unavailable. + discovery_window = None + + if self.game_discovery is not None: + discovery_window = ( + self.game_discovery.get( + "window" + ) + ) + + if ( + discovery_window is not None + and discovery_window.get( + "client" + ) is not None + ): + scale_width = float( + discovery_window[ + "client" + ][ + "width" + ] + ) + else: + scale_width = float( + self.game_monitor[ + "width" + ] + ) + + self.sample_scale = ( + scale_width + / 3840.0 + ) + + self.debug( + f"Sample scale: " + f"{self.sample_scale:.6f} " + f"(width fallback)" + ) + + self.input = InputController() + self.input.set_flat_input() + + # Use the exact game/browser window selected by visual + # discovery for all later keyboard-focus operations. + self.input.game_window_hwnd = ( + self.game_window_hwnd + ) + + if auto_detect_monitor: + self.detect_game_window_geometry() + self.cache_game_geometry_locations() + + def detect_game_monitor(self): + """ + Detect which physical monitor contains the game. + + The detector uses COSMIC's full-desktop screenshot + mechanism and returns physical desktop coordinates. + """ + + result = detect_game_monitor( + verbose=self.verbose, + ) + + if result is None: + raise RuntimeError( + "Could not confidently identify the game monitor." + ) + + self.game_discovery = result + self.game_monitor = result["monitor"] + + window = result.get( + "window" + ) + + if ( + window is not None + and window.get( + "hwnd" + ) is not None + ): + self.game_window_hwnd = int( + window["hwnd"] + ) + + self.debug() + self.debug( + "============================================================" + ) + self.debug( + "=== AUTOMATOR GAME MONITOR ===" + ) + self.debug( + "============================================================" + ) + self.debug() + self.debug( + f"Monitor: {self.game_monitor['index']}" + ) + self.debug( + f"Output: {self.game_monitor.get('output')}" + ) + self.debug( + f"Position: " + f"({self.game_monitor['left']}, " + f"{self.game_monitor['top']})" + ) + self.debug( + f"Size: " + f"{self.game_monitor['width']} x " + f"{self.game_monitor['height']}" + ) + self.debug() + + return self.game_monitor + + def detect_game_window_geometry( + self, + ): + """ + Detect the actual Total Battle XWayland window and save + its geometry in monitor-relative coordinates. + + InputController returns desktop coordinates. Automator + works primarily in game-monitor-relative coordinates, + so convert here once. + """ + + desktop = None + + discovery = getattr( + self, + "game_discovery", + None, + ) + + if discovery is not None: + + window = discovery.get( + "window" + ) + + viewport = discovery.get( + "game_viewport" + ) + + if ( + discovery.get( + "window_geometry_authoritative", + False, + ) + and window is not None + and viewport is not None + ): + desktop = { + "window": window[ + "hwnd" + ], + "left": viewport[ + "left" + ], + "top": viewport[ + "top" + ], + "width": viewport[ + "width" + ], + "height": viewport[ + "height" + ], + } + + # Linux and generic visual fallback still use the + # platform input backend's existing window discovery. + if desktop is None: + desktop = ( + self.input.get_game_window_geometry() + ) + + left = ( + desktop["left"] + - self.game_monitor["left"] + ) + + top = ( + desktop["top"] + - self.game_monitor["top"] + ) + + self.game_window_geometry = { + "window": desktop[ + "window" + ], + "left": float(left), + "top": float(top), + "width": float( + desktop["width"] + ), + "height": float( + desktop["height"] + ), + "desktop_left": float( + desktop["left"] + ), + "desktop_top": float( + desktop["top"] + ), + } + + self.debug() + self.debug( + "============================================================" + ) + self.debug( + "=== TOTAL BATTLE WINDOW ===" + ) + self.debug( + "============================================================" + ) + self.debug() + + self.debug( + f"Desktop position: " + f"({desktop['left']}, " + f"{desktop['top']})" + ) + + self.debug( + f"Monitor position: " + f"({left}, {top})" + ) + + self.debug( + f"Size: " + f"{desktop['width']} x " + f"{desktop['height']}" + ) + + self.debug() + + return self.game_window_geometry + + + def monitor_to_desktop(self, x, y): + """ + Convert coordinates relative to the game monitor + into absolute desktop coordinates. + """ + + if self.game_monitor is None: + raise RuntimeError( + "Game monitor has not been detected." + ) + + return ( + self.game_monitor["left"] + x, + self.game_monitor["top"] + y, + ) + + def get_sample_group_scale( + self, + group_name, + ): + """ + Return a learned visual/template scale for a group. + + None means the group has not yet been learned. + """ + + return getattr( + self, + "sample_group_scales", + {} + ).get( + group_name + ) + + + def set_sample_group_scale( + self, + group_name, + sample_scale, + ): + """ + Remember the visual/template scale for a UI group. + """ + + if not hasattr( + self, + "sample_group_scales", + ): + self.sample_group_scales = {} + + sample_scale = float( + sample_scale + ) + + old_scale = self.sample_group_scales.get( + group_name + ) + + self.sample_group_scales[ + group_name + ] = sample_scale + + if old_scale != sample_scale: + self.debug( + f"Visual scale group " + f"{group_name!r}: " + f"{old_scale} -> " + f"{sample_scale:.6f}" + ) + + + # ======================================================== + # WATCHTOWER/GROUP SCALE FAST PATH + # ======================================================== + # + # The global sample_scale has already been visually + # established during game discovery. + # + # In the common case, Watchtower uses that same scale. + # Testing every configured scale again is unnecessarily + # expensive. + # + # Important complication: + # + # The first Watchtower render can be incomplete. + # + # Therefore: + # + # first two discovery calls: + # probe ONLY self.sample_scale + # + # if either probe satisfies the group's EXISTING + # evidence requirements: + # cache it and stop + # + # after two failed fast probes: + # run the original exhaustive discovery unchanged + # + # This preserves support for UI groups whose visual scale + # genuinely differs from the global game scale. + # ======================================================== + + def discover_sample_group_scale( + self, + *args, + **kwargs, + ): + from ui_registry import ( + get_scale_group_definition, + ) + + # ---------------------------------------------------- + # RESOLVE group_name WITHOUT ASSUMING CALL STYLE + # ---------------------------------------------------- + + group_name = kwargs.get( + "group_name" + ) + + if ( + group_name is None + and args + ): + group_name = args[0] + + if group_name is None: + return ( + self + ._discover_sample_group_scale_exhaustive( + *args, + **kwargs, + ) + ) + + # Already learned means the normal implementation can + # handle its existing cache behavior. + learned = self.get_sample_group_scale( + group_name + ) + + if learned is not None: + return ( + self + ._discover_sample_group_scale_exhaustive( + *args, + **kwargs, + ) + ) + + definition = ( + get_scale_group_definition( + group_name + ) + ) + + if not definition: + return ( + self + ._discover_sample_group_scale_exhaustive( + *args, + **kwargs, + ) + ) + + candidate_scales = definition.get( + "candidate_scales" + ) + + if not candidate_scales: + return ( + self + ._discover_sample_group_scale_exhaustive( + *args, + **kwargs, + ) + ) + + # ---------------------------------------------------- + # TRACK FAST-PROBE FAILURES PER SCALE GROUP + # ---------------------------------------------------- + + if not hasattr( + self, + "_sample_group_fast_failures", + ): + self._sample_group_fast_failures = {} + + failures = int( + self._sample_group_fast_failures.get( + group_name, + 0, + ) + ) + + # Two fast attempts allow an opening panel time to + # finish rendering before we pay for exhaustive search. + if failures < 2: + + preferred_scale = float( + self.sample_scale + ) + + original_scales = ( + candidate_scales + ) + + # Temporarily give the existing authoritative + # discovery algorithm exactly one candidate. + # + # We are NOT reproducing its matching rules here. + # It still evaluates probe_threshold, + # min_good_matches, aggregate score, regions, etc. + definition[ + "candidate_scales" + ] = ( + preferred_scale, + ) + + try: + result = ( + self + ._discover_sample_group_scale_exhaustive( + *args, + **kwargs, + ) + ) + + finally: + definition[ + "candidate_scales" + ] = original_scales + + if result is not None: + + self._sample_group_fast_failures[ + group_name + ] = 0 + + self.debug( + f"Visual scale group " + f"{group_name!r} accepted global " + f"sample scale " + f"{preferred_scale:.6f} " + f"from fast probe." + ) + + return result + + failures += 1 + + self._sample_group_fast_failures[ + group_name + ] = failures + + self.debug( + f"Visual scale group " + f"{group_name!r} fast probe " + f"{failures}/2 did not verify " + f"at global scale " + f"{preferred_scale:.6f}." + ) + + # Do NOT perform exhaustive matching against the + # same possibly-half-rendered frame. + # + # Let the caller's normal render/retry loop obtain + # another fresh screenshot. + return None + + # ---------------------------------------------------- + # EXHAUSTIVE FALLBACK + # ---------------------------------------------------- + # + # Two separate fresh-frame probes have now failed. + # The UI family may genuinely use a scale different + # from the global game scale. + # + # Restore original behavior completely. + # ---------------------------------------------------- + + self.debug( + f"Visual scale group " + f"{group_name!r}: " + f"global-scale fast probes exhausted; " + f"running full scale discovery." + ) + + result = ( + self + ._discover_sample_group_scale_exhaustive( + *args, + **kwargs, + ) + ) + + if result is not None: + self._sample_group_fast_failures[ + group_name + ] = 0 + + return result + + + def _discover_sample_group_scale_exhaustive( + self, + group_name, + region=None, + update=True, + ): + """ + Discover the visual/template scale for one UI group. + + Multiple probe samples are tested at each configured + candidate scale. + + A scale is accepted only when enough probe samples + produce strong matches at that same scale. + + Returns a result dictionary. The learned scale is also + cached in self.sample_group_scales. + """ + + from ui_registry import ( + get_scale_group_definition, + ) + + definition = get_scale_group_definition( + group_name + ) + + if definition is None: + raise KeyError( + f"Unknown sample scale group: " + f"{group_name}" + ) + + if update: + self.update() + + # ---------------------------------------------------- + # Resolve search image. + # + # For the first implementation deliberately search the + # complete game screenshot. This avoids introducing a + # second possible bug involving region coordinate + # conversion while we validate scale discovery. + # ---------------------------------------------------- + + if region is None: + search_image = self.image + region_offset = ( + 0, + 0, + ) + + else: + + if len(region) != 4: + raise ValueError( + "region must be " + "(x, y, width, height)" + ) + + x, y, width, height = region + + sx = int( + round( + x * self.scale + ) + ) + + sy = int( + round( + y * self.scale + ) + ) + + sw = int( + round( + width * self.scale + ) + ) + + sh = int( + round( + height * self.scale + ) + ) + + search_image = self.image[ + sy:sy + sh, + sx:sx + sw, + ] + + region_offset = ( + sx, + sy, + ) + + if search_image.size == 0: + raise ValueError( + "Scale discovery search region " + "produced an empty image." + ) + + candidate_scales = tuple( + definition[ + "candidate_scales" + ] + ) + + probe_samples = tuple( + definition[ + "probe_samples" + ] + ) + + threshold = float( + definition.get( + "probe_threshold", + 0.05, + ) + ) + + min_good_matches = int( + definition.get( + "min_good_matches", + 2, + ) + ) + + scale_results = [] + + # ---------------------------------------------------- + # Test every configured scale. + # ---------------------------------------------------- + + for candidate_scale in candidate_scales: + + matches = [] + + for sample_name in probe_samples: + + template = self.get_sample( + sample_name, + sample_scale=candidate_scale, + ) + + if ( + template.shape[0] + > search_image.shape[0] + or template.shape[1] + > search_image.shape[1] + ): + continue + + result = cv2.matchTemplate( + search_image, + template, + self.method, + ) + + ( + min_val, + max_val, + min_loc, + max_loc, + ) = cv2.minMaxLoc( + result + ) + + if self.method in ( + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ): + score = float( + min_val + ) + match_loc = min_loc + + else: + # Convert to lower-is-better semantics so + # candidate scales can be compared in one + # consistent way. + score = float( + 1.0 - max_val + ) + match_loc = max_loc + + matches.append({ + "sample": sample_name, + "score": score, + "location": ( + match_loc[0] + + region_offset[0], + match_loc[1] + + region_offset[1], + ), + }) + + matches.sort( + key=lambda item: item[ + "score" + ] + ) + + good_matches = [ + item + for item in matches + if item["score"] < threshold + ] + + # Compare scales using the strongest required + # number of matches. + strongest = matches[ + :min_good_matches + ] + + if ( + len(strongest) + >= min_good_matches + ): + aggregate_score = ( + sum( + item["score"] + for item in strongest + ) + / len(strongest) + ) + + else: + aggregate_score = 1.0 + + scale_results.append({ + "scale": float( + candidate_scale + ), + "score": float( + aggregate_score + ), + "good_count": len( + good_matches + ), + "matches": matches, + }) + + # ---------------------------------------------------- + # Require corroborating matches. + # ---------------------------------------------------- + + valid = [ + item + for item in scale_results + if item["good_count"] + >= min_good_matches + ] + + if not valid: + + self.debug( + f"Could not establish visual scale " + f"for group {group_name!r}." + ) + + return { + "group": group_name, + "scale": None, + "results": scale_results, + } + + valid.sort( + key=lambda item: item[ + "score" + ] + ) + + best = valid[0] + + self.set_sample_group_scale( + group_name, + best["scale"], + ) + + self.debug( + f"Visual scale group " + f"{group_name!r} discovered: " + f"{best['scale']:.6f} " + f"score={best['score']:.6f} " + f"good_matches=" + f"{best['good_count']}" + ) + + return { + "group": group_name, + "scale": best["scale"], + "score": best["score"], + "matches": best["matches"], + "results": scale_results, + } + + + def get_sample( + self, + name, + sample_scale=None, + ): + """ + Load and resize a visual sample. + + If sample_scale is omitted, use a learned scale for the + sample's visual scale group when available. Otherwise + retain the existing global self.sample_scale behavior. + """ + + from ui_registry import ( + get_sample_scale_group, + ) + + group = get_sample_scale_group( + name + ) + + if sample_scale is None: + + group_scales = getattr( + self, + "sample_group_scales", + {} + ) + + if ( + group is not None + and group in group_scales + ): + sample_scale = group_scales[ + group + ] + + else: + sample_scale = self.sample_scale + + sample_scale = float( + sample_scale + ) + + t = ( + name, + self.scale, + sample_scale, + ) + + if t in self.samples: + return self.samples[t] + + base_dir = os.path.dirname(__file__) + + # Prefer the desktop-specific sample when it exists. + desktop_sample = os.path.join( + base_dir, + "samples_desktop", + name + ".png" + ) + + # Fall back to the original sample if no desktop + # version has been created. + standard_sample = os.path.join( + base_dir, + "samples", + name + ".png" + ) + + if os.path.exists(desktop_sample): + sample_path = desktop_sample + else: + sample_path = standard_sample + + sample = cv2.imread( + sample_path + ) + + if sample is None: + raise FileNotFoundError( + f"Could not load sample '{name}'. " + f"Checked:\\n" + f" {desktop_sample}\\n" + f" {standard_sample}" + ) + + resize_scale = ( + self.scale + * sample_scale + ) + + sample = cv2.resize( + sample, + (0, 0), + fx=resize_scale, + fy=resize_scale, + interpolation=( + cv2.INTER_LINEAR + if resize_scale >= 1.0 + else cv2.INTER_AREA + ), + ) + + self.samples[t] = sample + + return sample + + def find(self, sample, threshold = None, set_loc = True): + template = self.get_sample(sample) + if set_loc: + self.loc = None + result = cv2.matchTemplate(self.image, template, self.method) + min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) + if threshold is None: + threshold = self.threshold + if self.method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]: + condition = min_val < threshold + loc = min_loc + else: + condition = max_val > threshold + loc = max_loc + if condition: + h, w, _ = template.shape + loc = ((loc[0] + w / 2) / self.scale, (loc[1] + h / 2) / self.scale) + if set_loc: + self.loc = loc + return loc + return None + + def get_battle_scroll_anchor(self): + """ + Return a monitor-relative point inside the Battle troop + list. + + The configured anchor is based on the game center: + + x = center + battle_scroll_anchor_x_offset + y = center + battle_scroll_anchor_y_offset + + Currently that means 100 px left of center. + """ + + geometry = getattr( + self, + "game_window_geometry", + None, + ) + + if geometry is not None: + center_x = ( + float( + geometry.get( + "left", + 0, + ) + ) + + float( + geometry["width"] + ) / 2 + ) + + center_y = ( + float( + geometry.get( + "top", + 0, + ) + ) + + float( + geometry["height"] + ) / 2 + ) + + else: + center_x = ( + float( + self.game_monitor[ + "width" + ] + ) / 2 + ) + + center_y = ( + float( + self.game_monitor[ + "height" + ] + ) / 2 + ) + + return ( + center_x + + float( + self.ui_config[ + "battle_scroll_anchor_x_offset" + ] + ), + center_y + + float( + self.ui_config[ + "battle_scroll_anchor_y_offset" + ] + ), + ) + + + def debug( + self, + *args, + **kwargs, + ): + """ + Print detailed diagnostics only in verbose mode. + """ + + if self.verbose: + print( + *args, + **kwargs, + ) + + + def get_ui_region(self, name): + """ + Convert a configured UI region into monitor-relative + pixel coordinates: + + (x, y, width, height) + + If game_window_geometry is available, ratios are applied + to the Total Battle window. + + Otherwise they are applied to the detected game monitor. + + This means region matching can support windowed mode + later without changing crypt.py. + """ + + settings = self.ui_regions_config[ + name + ] + + geometry = getattr( + self, + "game_window_geometry", + None, + ) + + if geometry is None: + left = 0 + top = 0 + + width = int( + self.game_monitor["width"] + ) + + height = int( + self.game_monitor["height"] + ) + + else: + left = int( + geometry.get( + "left", + 0, + ) + ) + + top = int( + geometry.get( + "top", + 0, + ) + ) + + width = int( + geometry["width"] + ) + + height = int( + geometry["height"] + ) + + region_width = int( + round( + width + * float( + settings[ + "width_ratio" + ] + ) + ) + ) + + region_height = int( + round( + height + * float( + settings[ + "height_ratio" + ] + ) + ) + ) + + center_x = ( + left + + width + * float( + settings[ + "center_x_ratio" + ] + ) + ) + + center_y = ( + top + + height + * float( + settings[ + "center_y_ratio" + ] + ) + ) + + x = int( + round( + center_x + - region_width / 2 + ) + ) + + y = int( + round( + center_y + - region_height / 2 + ) + ) + + return ( + x, + y, + region_width, + region_height, + ) + + + def find_in_region( + self, + sample, + region, + threshold=None, + set_loc=True, + ): + """ + Find one sample inside a monitor-relative region. + + region: + (x, y, width, height) + + Returned location is still monitor-relative. + """ + + x, y, width, height = region + + x = int(round(x)) + y = int(round(y)) + width = int(round(width)) + height = int(round(height)) + + sx = int(round(x * self.scale)) + sy = int(round(y * self.scale)) + sw = int(round(width * self.scale)) + sh = int(round(height * self.scale)) + + image = self.image[ + sy:sy + sh, + sx:sx + sw, + ] + + if image.size == 0: + return None + + template = self.get_sample( + sample + ) + + result = cv2.matchTemplate( + image, + template, + self.method, + ) + + if threshold is None: + threshold = self.threshold + + min_val, max_val, min_loc, max_loc = ( + cv2.minMaxLoc(result) + ) + + if self.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + if min_val >= threshold: + return None + + match_x, match_y = min_loc + + else: + if max_val <= threshold: + return None + + match_x, match_y = max_loc + + template_h, template_w = ( + template.shape[:2] + ) + + location = ( + ( + sx + + match_x + + template_w / 2 + ) / self.scale, + ( + sy + + match_y + + template_h / 2 + ) / self.scale, + ) + + if set_loc: + self.loc = location + + return location + + + def find_multi_in_region( + self, + sample, + region, + threshold=None, + ): + """ + Find all instances of a sample inside a + monitor-relative region. + + Returned locations remain monitor-relative. + """ + + x, y, width, height = region + + x = int(round(x)) + y = int(round(y)) + width = int(round(width)) + height = int(round(height)) + + sx = int(round(x * self.scale)) + sy = int(round(y * self.scale)) + sw = int(round(width * self.scale)) + sh = int(round(height * self.scale)) + + image = self.image[ + sy:sy + sh, + sx:sx + sw, + ] + + if image.size == 0: + return None + + template = self.get_sample( + sample + ) + + result = cv2.matchTemplate( + image, + template, + self.method, + ) + + if threshold is None: + threshold = self.threshold + + if self.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + ys, xs = numpy.where( + result < threshold + ) + else: + ys, xs = numpy.where( + result > threshold + ) + + if len(xs) == 0: + return None + + template_h, template_w = ( + template.shape[:2] + ) + + rects = [] + + for match_x, match_y in zip( + xs, + ys, + ): + rects.append( + ( + match_x, + match_y, + match_x + template_w, + match_y + template_h, + ) + ) + + suppressed = non_max_suppression( + numpy.array(rects) + ) + + locations = [] + + for loc in suppressed: + locations.append( + ( + ( + sx + + loc[0] + + template_w / 2 + ) / self.scale, + ( + sy + + loc[1] + + template_h / 2 + ) / self.scale, + ) + ) + + self.multi = locations + + return locations or None + + + def find_multi(self, sample, threshold = None): + template = self.get_sample(sample) + self.multi = None + result = cv2.matchTemplate(self.image, template, self.method) + if threshold is None: + threshold = self.threshold + if self.method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]: + ys, xs = numpy.where(result < threshold) + else: + ys, xs = numpy.where(result > threshold) + rects = [] + for x, y in zip(xs, ys): + rects.append((x, y, x + template.shape[:2][1], y + template.shape[:2][0])) + h, w, _ = template.shape + self.multi = [((loc[0] + w / 2) / self.scale, (loc[1] + h / 2) / self.scale) for loc in non_max_suppression(numpy.array(rects))] + if self.multi: + return self.multi + return None + + def multi_block_color(self, width, height, color): + multi = [] + for loc in self.multi: + min_x = loc[0] - width // 2 + max_x = loc[0] + (width + 1) // 2 + min_y = loc[1] - height // 2 + max_y = loc[1] + (height + 1) // 2 + cutout = self.image[int(min_y):int(max_y), int(min_x):int(max_x)] + keep = True + for row in cutout: + for pixel in row: + if (int(pixel[2]), int(pixel[1]), int(pixel[0])) == color: + keep = False + break + if not keep: + break + if keep: + multi.append(loc) + self.multi = multi + return bool(self.multi) + + def random_multi(self): + if self.multi: + self.loc = random.choice(self.multi) + return self.loc + return None + + def central_multi(self): + h, w, _ = self.image.shape + best_loc = None + best_dist = None + for loc in self.multi: + dist = math.sqrt((loc[0] / self.scale - w / 2) ** 2 + (loc[1] / self.scale - h / 2) ** 2) + if best_loc is None or dist < best_dist: + best_loc = loc + best_dist = dist + self.loc = best_loc + return best_loc + + def cut(self, min_x, min_y, max_x, max_y, show = True): + result = self.image[int(self.loc[1] + min_y):int(self.loc[1] + max_y), int(self.loc[0] + min_x):int(self.loc[0] + max_x)] + if show: + cv2.imwrite("test.png", result) + return result + + def read(self, min_x, min_y, max_x, max_y): + cut = self.cut(min_x, min_y, max_x, max_y, False) + gray = cv2.cvtColor(cut, cv2.COLOR_BGR2GRAY) + _, threshold = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) + return pytesseract.image_to_string(threshold, lang = "eng", config = "--psm 7").strip() + + def mean(self, min_x, min_y, max_x, max_y): + cut = self.cut(min_x, min_y, max_x, max_y, False) + return tuple(map(float, reversed(numpy.mean(cut, axis = (0, 1))))) + + def cache_game_geometry_locations( + self, + ): + """ + Cache locations that can be calculated directly from the + Total Battle window geometry. + + No screenshot/template matching is needed. + + game_center: + exact center of Total Battle window + + game_top_center: + center X, 100 px below top of game window + + game_safe_location: + currently same as game_top_center; used to clear + hover labels before visual searches + """ + + geometry = self.game_window_geometry + + if geometry is None: + raise RuntimeError( + "Game window geometry has not been detected." + ) + + left = float( + geometry["left"] + ) + + top = float( + geometry["top"] + ) + + width = float( + geometry["width"] + ) + + height = float( + geometry["height"] + ) + + center = ( + left + width / 2, + top + height / 2, + ) + + safe = ( + left + width / 2, + top + 100, + ) + + # The standalone Total Battle client centers Watchtower + # GO targets at the geometric center of the game window. + # + # The browser-hosted game uses a different map focus + # point. Testing shows that at reference scale 1.0 the + # GO target is 140 px above the geometric center. + # + # Keep this separate from game_center so normal UI + # geometry is unchanged. + world_target_center = center + + discovery = getattr( + self, + "game_discovery", + None, + ) + + discovery_window = ( + discovery.get("window") + if discovery is not None + else None + ) + + browser_hosted = ( + discovery_window is not None + and not discovery_window.get( + "exact_title", + True, + ) + ) + + if browser_hosted: + world_target_center = ( + center[0], + center[1] + - ( + 140.0 + * float(self.sample_scale) + ), + ) + + self.cache_ui_location( + "game_center", + center, + ) + + self.cache_ui_location( + "game_world_target_center", + world_target_center, + ) + + self.cache_ui_location( + "game_top_center", + safe, + ) + + self.cache_ui_location( + "game_safe_location", + safe, + ) + + self.debug( + "Game geometry locations cached:" + ) + + self.debug( + f" center: " + f"{center}" + ) + + self.debug( + f" world: " + f"{world_target_center}" + ) + + self.debug( + f" safe: " + f"{safe}" + ) + + return True + + + def cache_ui_location(self, name, location): + """ + Cache a stable monitor-relative UI coordinate. + """ + if location is None: + return None + + location = ( + float(location[0]), + float(location[1]), + ) + + self.ui_locations[name] = location + + return location + + + def get_ui_location(self, name): + """ + Return a cached UI location or None. + """ + return self.ui_locations.get( + name + ) + + + def use_ui_location(self, name): + """ + Set self.loc to a cached UI location. + + Returns the cached location or None. + """ + location = self.get_ui_location( + name + ) + + if location is None: + return None + + self.loc = location + + return location + + + def clear_ui_locations(self): + """ + Invalidate all learned static UI locations. + """ + self.ui_locations.clear() + + + def ensure_static_ui_locations( + self, + names, + settle_time=0.35, + ): + """ + Ensure the requested static UI locations are cached. + + If every requested location is already known: + return immediately + do not move the mouse + do not take a screenshot + + If one or more locations are missing: + move to the cached game_safe_location + allow hover/tooltips to disappear + visually cache the World HUD once + verify the requested locations were found + + `names` may be a string or iterable of strings. + """ + + if isinstance( + names, + str, + ): + names = [ + names + ] + else: + names = list( + names + ) + + missing = [ + name + for name in names + if self.get_ui_location( + name + ) is None + ] + + if not missing: + return True + + self.debug() + self.debug( + "Static UI locations missing: " + + ", ".join( + missing + ) + ) + + # ---------------------------------------------------- + # CLEAR ANY HOVER / TOOLTIP + # ---------------------------------------------------- + + safe = self.get_ui_location( + "game_safe_location" + ) + + if safe is None: + raise RuntimeError( + "game_safe_location has not " + "been cached." + ) + + self.loc = safe + + self.debug( + f"Moving to safe game location: " + f"{safe}" + ) + + self.move() + + time.sleep( + settle_time + ) + + # ---------------------------------------------------- + # CACHE STATIC WORLD HUD FROM ONE SCREENSHOT + # ---------------------------------------------------- + + self.cache_world_ui() + + # ---------------------------------------------------- + # VERIFY REQUESTED LOCATIONS + # ---------------------------------------------------- + + still_missing = [ + name + for name in names + if self.get_ui_location( + name + ) is None + ] + + if still_missing: + print( + "FAIL: static UI locations " + "still missing: " + + ", ".join( + still_missing + ) + ) + + return False + + return True + + + def get_fixed_ui_region( + self, + definition, + ): + """ + Build a monitor-relative fixed-size region anchored to + the actual Total Battle window. + + Currently supported anchors: + + top_left + top_right + bottom_left + bottom_right + center + + This is useful for controls whose location is known by + proximity to a window edge rather than by screen ratio. + """ + + fixed = definition.get( + "fixed_region" + ) + + if not fixed: + return None + + geometry = self.game_window_geometry + + if geometry is None: + raise RuntimeError( + "Game window geometry is not available." + ) + + left = float( + geometry["left"] + ) + + top = float( + geometry["top"] + ) + + width = float( + geometry["width"] + ) + + height = float( + geometry["height"] + ) + + region_width = float( + fixed["width"] + ) + + region_height = float( + fixed["height"] + ) + + anchor = fixed[ + "anchor" + ] + + if anchor == "bottom_right": + x = ( + left + + width + - region_width + ) + + y = ( + top + + height + - region_height + ) + + elif anchor == "bottom_left": + x = left + + y = ( + top + + height + - region_height + ) + + elif anchor == "top_right": + x = ( + left + + width + - region_width + ) + + y = top + + elif anchor == "top_left": + x = left + y = top + + elif anchor == "center": + x = ( + left + + width / 2 + - region_width / 2 + ) + + y = ( + top + + height / 2 + - region_height / 2 + ) + + else: + raise ValueError( + f"Unsupported fixed-region anchor: " + f"{anchor}" + ) + + return ( + x, + y, + region_width, + region_height, + ) + + + def ensure_static_ui_for_screen( + self, + screen, + settle_time=0.35, + ): + """ + Ensure all known static controls visible on `screen` + have cached locations. + + Existing cached locations are never re-searched. + + If anything is missing: + + move to game_safe_location + wait for hover/tooltips to clear + take ONE screenshot + search only missing static controls + cache all successful matches + + Static locations remain valid across City <-> World + transitions and are invalidated only when the actual + Total Battle window geometry changes. + """ + + definitions = ( + static_items_for_screen( + screen + ) + ) + + missing = { + name: definition + for ( + name, + definition, + ) in definitions.items() + if self.get_ui_location( + name + ) is None + } + + if not missing: + return True + + self.debug() + self.debug( + f"Missing static {screen} UI: " + + ", ".join( + missing + ) + ) + + # ---------------------------------------------------- + # CLEAR HOVER / TOOLTIP + # ---------------------------------------------------- + + safe = self.get_ui_location( + "game_safe_location" + ) + + if safe is None: + raise RuntimeError( + "game_safe_location is not cached." + ) + + self.loc = safe + + self.debug( + f"Moving to safe location: " + f"{safe}" + ) + + self.move() + + time.sleep( + settle_time + ) + + # ---------------------------------------------------- + # ONE SCREENSHOT + # ---------------------------------------------------- + + self.update() + + # ---------------------------------------------------- + # FIND ONLY MISSING STATIC ITEMS + # ---------------------------------------------------- + + for ( + name, + definition, + ) in missing.items(): + + sample = definition[ + "sample" + ] + + fixed_region = ( + self.get_fixed_ui_region( + definition + ) + ) + + region_name = ( + definition.get( + "region" + ) + ) + + if fixed_region is not None: + location = ( + self.find_in_region( + sample, + fixed_region, + set_loc=False, + ) + ) + + elif region_name: + location = ( + self.find_in_region( + sample, + self.get_ui_region( + region_name + ), + set_loc=False, + ) + ) + + else: + location = self.find( + sample, + set_loc=False, + ) + + if location is None: + self.debug( + f" {name:12s}: " + f"not found" + ) + + continue + + self.cache_ui_location( + name, + location, + ) + + self.debug( + f" {name:12s}: " + f"{location}" + ) + + still_missing = [ + name + for name in definitions + if self.get_ui_location( + name + ) is None + ] + + if still_missing: + self.debug( + "Static UI still missing: " + + ", ".join( + still_missing + ) + ) + + # It is not necessarily fatal that every optional static + # control was absent. Callers can check the specific + # control they actually require. + return True + + + def cache_anchored_ui_location( + self, + name, + location, + ): + """ + Cache the center location of an anchored-dynamic item. + + The state is NOT cached; only the expected location. + """ + + definition = ( + get_ui_definition( + name + ) + ) + + if ( + definition is None + or definition.get( + "location_behavior" + ) != "anchored" + ): + raise ValueError( + f"{name!r} is not an " + f"anchored UI item." + ) + + return self.cache_ui_location( + "anchor:" + name, + location, + ) + + + def get_anchored_ui_location( + self, + name, + ): + return self.get_ui_location( + "anchor:" + name + ) + + + def get_anchored_search_region( + self, + name, + ): + """ + Return a small monitor-relative search region around a + previously learned anchored-dynamic UI location. + + The crop size is based on the largest state template for + the item plus configured padding on every side. + + Example: + Citadel *_select templates may differ slightly in + dimensions, so the largest one determines the crop. + """ + + definition = ( + get_ui_definition( + name + ) + ) + + if definition is None: + raise KeyError( + f"Unknown UI item: {name}" + ) + + if definition.get( + "location_behavior" + ) != "anchored": + raise ValueError( + f"{name!r} is not anchored." + ) + + anchor = ( + self.get_anchored_ui_location( + name + ) + ) + + if anchor is None: + return None + + samples = definition.get( + "state_samples", + {} + ) - def find(self, sample, threshold = None, set_loc = True): - template = self.get_sample(sample) - if set_loc: - self.loc = None - result = cv2.matchTemplate(self.image, template, self.method) - min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) - if threshold is None: - threshold = self.threshold - if self.method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]: - condition = min_val < threshold - loc = min_loc + if not samples: + raise RuntimeError( + f"Anchored item {name!r} " + f"has no state samples." + ) + + widths = [] + heights = [] + + for sample_name in samples.values(): + + template = self.get_sample( + sample_name + ) + + height, width = ( + template.shape[:2] + ) + + # get_sample() returns the scaled template. + # Convert dimensions back to monitor-relative space. + widths.append( + float(width) + / self.scale + ) + + heights.append( + float(height) + / self.scale + ) + + padding = float( + definition.get( + "padding", + 20, + ) + ) + + width = ( + max(widths) + + padding * 2 + ) + + height = ( + max(heights) + + padding * 2 + ) + + return ( + anchor[0] + - width / 2, + + anchor[1] + - height / 2, + + width, + height, + ) + + + + def classify_anchored_ui_state( + self, + name, + threshold=None, + min_margin=0.0, + update=True, + ): + """ + Classify an anchored-dynamic UI item. + + First use: + search the item's broader discover_region + compare all state templates + remember the winning location as the anchor + + Later uses: + search only a tiny region around the learned anchor + + Returns: + + { + "state": state_name or None, + "score": best score, + "margin": second_best - best, + "location": monitor-relative location, + "region": region used, + "anchored": bool, + "scores": {...}, + } + + Lower score is better because Automator currently uses + TM_SQDIFF_NORMED. + """ + + definition = get_ui_definition( + name + ) + + if definition is None: + raise KeyError( + f"Unknown UI item: {name}" + ) + + if definition.get( + "location_behavior" + ) != "anchored": + raise ValueError( + f"{name!r} is not an anchored item." + ) + + if update: + self.update() + + states = definition.get( + "state_samples", + {} + ) + + if not states: + raise RuntimeError( + f"{name!r} has no state samples." + ) + + # ---------------------------------------------------- + # USE LEARNED SMALL REGION WHEN AVAILABLE + # ---------------------------------------------------- + + region = self.get_anchored_search_region( + name + ) + + anchored = ( + region is not None + ) + + # ---------------------------------------------------- + # OTHERWISE USE BROADER DISCOVERY REGION + # ---------------------------------------------------- + + if region is None: + + discover_region = definition.get( + "discover_region" + ) + + if discover_region: + region = self.get_ui_region( + discover_region + ) + + else: + geometry = self.game_window_geometry + + region = ( + geometry["left"], + geometry["top"], + geometry["width"], + geometry["height"], + ) + + x, y, width, height = region + + sx = int( + round( + x * self.scale + ) + ) + + sy = int( + round( + y * self.scale + ) + ) + + sw = int( + round( + width * self.scale + ) + ) + + sh = int( + round( + height * self.scale + ) + ) + + image = self.image[ + sy:sy + sh, + sx:sx + sw, + ] + + if image.size == 0: + return None + + results = [] + + for ( + state_name, + sample_name, + ) in states.items(): + + template = self.get_sample( + sample_name + ) + + result = cv2.matchTemplate( + image, + template, + self.method, + ) + + ( + min_val, + max_val, + min_loc, + max_loc, + ) = cv2.minMaxLoc( + result + ) + + if self.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + score = float( + min_val + ) + + match_loc = min_loc + + else: + # Normalize semantics so lower remains better + # to callers of this classifier. + score = float( + 1.0 - max_val + ) + + match_loc = max_loc + + template_h, template_w = ( + template.shape[:2] + ) + + location = ( + ( + sx + + match_loc[0] + + template_w / 2 + ) / self.scale, + + ( + sy + + match_loc[1] + + template_h / 2 + ) / self.scale, + ) + + results.append( + { + "state": state_name, + "sample": sample_name, + "score": score, + "location": location, + } + ) + + results.sort( + key=lambda item: item[ + "score" + ] + ) + + best = results[0] + + if len(results) > 1: + margin = ( + results[1]["score"] + - best["score"] + ) else: - condition = max_val > threshold - loc = max_loc - if condition: - h, w, _ = template.shape - loc = ((loc[0] + w / 2) / self.scale, (loc[1] + h / 2) / self.scale) - if set_loc: - self.loc = loc - return loc - return None + margin = float( + "inf" + ) - def find_multi(self, sample, threshold = None): - template = self.get_sample(sample) - self.multi = None - result = cv2.matchTemplate(self.image, template, self.method) if threshold is None: threshold = self.threshold - if self.method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]: - ys, xs = numpy.where(result < threshold) - else: - ys, xs = numpy.where(result > threshold) - rects = [] - for x, y in zip(xs, ys): - rects.append((x, y, x + template.shape[:2][1], y + template.shape[:2][0])) - h, w, _ = template.shape - self.multi = [((loc[0] + w / 2) / self.scale, (loc[1] + h / 2) / self.scale) for loc in non_max_suppression(numpy.array(rects))] - if self.multi: - return self.multi - return None - def multi_block_color(self, width, height, color): - multi = [] - for loc in self.multi: - min_x = loc[0] - width // 2 - max_x = loc[0] + (width + 1) // 2 - min_y = loc[1] - height // 2 - max_y = loc[1] + (height + 1) // 2 - cutout = self.image[int(min_y):int(max_y), int(min_x):int(max_x)] - keep = True - for row in cutout: - for pixel in row: - if (int(pixel[2]), int(pixel[1]), int(pixel[0])) == color: - keep = False - break - if not keep: - break - if keep: - multi.append(loc) - self.multi = multi - return bool(self.multi) + accepted = ( + best["score"] + < threshold + and margin >= min_margin + ) - def random_multi(self): - if self.multi: - self.loc = random.choice(self.multi) - return self.loc - return None + state = ( + best["state"] + if accepted + else None + ) - def central_multi(self): - h, w, _ = self.image.shape - best_loc = None - best_dist = None - for loc in self.multi: - dist = math.sqrt((loc[0] / self.scale - w / 2) ** 2 + (loc[1] / self.scale - h / 2) ** 2) - if best_loc is None or dist < best_dist: - best_loc = loc - best_dist = dist - self.loc = best_loc - return best_loc + # Only learn/update the anchor from a confident match. + if accepted: + self.cache_anchored_ui_location( + name, + best["location"], + ) - def cut(self, min_x, min_y, max_x, max_y, show = True): - result = self.image[int(self.loc[1] + min_y):int(self.loc[1] + max_y), int(self.loc[0] + min_x):int(self.loc[0] + max_x)] - if show: - cv2.imwrite("test.png", result) - return result + return { + "state": state, + "best_state": best[ + "state" + ], + "score": best[ + "score" + ], + "margin": margin, + "location": best[ + "location" + ], + "region": region, + "anchored": anchored, + "scores": { + item["state"]: + item["score"] + for item in results + }, + } - def read(self, min_x, min_y, max_x, max_y): - cut = self.cut(min_x, min_y, max_x, max_y, False) - gray = cv2.cvtColor(cut, cv2.COLOR_BGR2GRAY) - _, threshold = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) - return pytesseract.image_to_string(threshold, lang = "eng", config = "--psm 7").strip() - def mean(self, min_x, min_y, max_x, max_y): - cut = self.cut(min_x, min_y, max_x, max_y, False) - return tuple(map(float, reversed(numpy.mean(cut, axis = (0, 1))))) - def click(self, xoffset = 0, yoffset = 0): - pyautogui.click(self.loc[0] + xoffset, self.loc[1] + yoffset) + def cache_world_ui(self): + """ + Cache stable World-map HUD controls from ONE screenshot. + + The caller must already have established World state. + + These are static anchors only. Finding them all from the + same image is much cheaper than separately awaiting them + throughout the automation. + """ + + self.update() + + samples = { + "watchtower": "watchtower", + "army": "army", + "clan": "clan", + "map": "map", + } + + self.debug() + self.debug( + "Caching static World UI..." + ) + + for name, sample in samples.items(): + location = self.find_in_region( + sample, + self.get_ui_region( + "bottom_center" + ), + set_loc=False, + ) + + if location is None: + self.debug( + f" {name:10s}: not found" + ) + continue + + self.cache_ui_location( + name, + location, + ) + + self.debug( + f" {name:10s}: {location}" + ) + + return self.ui_locations + - def move(self, xoffset = 0, yoffset = 0): - pyautogui.moveTo(self.loc[0] + xoffset, self.loc[1] + yoffset) + def click(self, xoffset=0, yoffset=0): + if self.loc is None: + raise RuntimeError( + "No current location to click." + ) + + x = self.loc[0] + xoffset + y = self.loc[1] + yoffset + + self.input.move_to_monitor( + x, + y, + self.game_monitor, + ) + + # Total Battle/Proton does not always update its + # mouseover target from the large synthetic positioning + # movement alone. A tiny final motion causes the game + # to register the hover correctly before the click. + self.input.move_relative( + dx=-2, + dy=0, + ) + + time.sleep( + self.timing[ + "hover_jiggle_pause" + ] + ) + + self.input.move_relative( + dx=2, + dy=0, + ) + + time.sleep( + self.timing[ + "hover_jiggle_pause" + ] + ) + + self.input.click() + + def move(self, xoffset=0, yoffset=0): + if self.loc is None: + raise RuntimeError( + "No current location to move to." + ) + + x = self.loc[0] + xoffset + y = self.loc[1] + yoffset + + self.input.move_to_monitor( + x, + y, + self.game_monitor, + ) def write(self, s): - for c in str(s): - pyautogui.write(c) - time.sleep(0.005) + self.input.type_text( + str(s), + delay=0.005, + ) def update(self): - with mss.mss() as mss_instance: - monitor = mss_instance.monitors[1] - image = mss_instance.grab(monitor) - image = cv2.cvtColor(numpy.array(image), cv2.COLOR_BGRA2BGR) - image = cv2.resize(image, (0, 0), fx = self.scale, fy = self.scale) - self.image = image + if self.game_monitor is None: + raise RuntimeError( + "Game monitor has not been detected." + ) + + image = capture_game_monitor( + self.game_monitor + ) + + image = cv2.resize( + image, + (0, 0), + fx=self.scale, + fy=self.scale, + ) + + self.image = image def check_kill_file(self): if os.path.exists(self.kill_file): @@ -187,26 +2888,323 @@ def f(a): return self.loop(f, timeout) def refresh(self): - pyautogui.press("f5") + self.input.press_f5() def tab(self): - pyautogui.press("tab") + self.input.press_tab() + + def detect_game_state(self, update=True): + """ + Detect the current high-level game screen. + + Returns: + "shop" + "city" + "world" + None + + State anchors: + + shop_loaded -> shop + map -> city + city -> world + """ + + if update: + self.update() + + if self.find( + "shop_loaded", + set_loc=False, + ) is not None: + return "shop" + + if self.find( + "map", + set_loc=False, + ) is not None: + return "city" + + if self.find( + "city", + set_loc=False, + ) is not None: + return "world" + + return None + + def wait_for_game_state( + self, + expected, + timeout=10, + poll_delay=0.25, + ): + """ + Wait until one of the requested game states appears. + + expected may be a string or iterable of strings. + + Returns the detected state, or None on timeout. + """ + + if isinstance(expected, str): + expected = {expected} + else: + expected = set(expected) + + initial = time.time() + + while time.time() <= initial + timeout: + self.check_kill_file() + + state = self.detect_game_state( + update=True, + ) + + if state in expected: + return state + + time.sleep(poll_delay) + + return None + + def ensure_game_state( + self, + target, + timeout=10, + ): + """ + Ensure the game is in either CITY or WORLD state. + + The program may start from: + + shop + city + world + + Shop is treated as an overlay. ESC closes it, after + which the underlying city/world state is detected. + + target: + "city" + "world" + """ + + if target not in { + "city", + "world", + }: + raise ValueError( + f"Unsupported target game state: {target}" + ) + + self.debug() + self.debug( + f"Ensuring game state: " + f"{target.upper()}" + ) + + # ---------------------------------------------------- + # IDENTIFY CURRENT STATE + # ---------------------------------------------------- + + state = self.detect_game_state() + + self.debug( + f"Current game state: " + f"{state or 'UNKNOWN'}" + ) + + if state is None: + raise RuntimeError( + "Could not identify current game state." + ) + + # ---------------------------------------------------- + # CLOSE SHOP OVERLAY + # ---------------------------------------------------- + + if state == "shop": + self.debug( + "Shop is open; closing it..." + ) + + # Use the visible shop-close X rather than ESC. + # + # Proton/XWayland does not consistently deliver our + # virtual keyboard ESC event to Total Battle, while + # mouse movement/clicking is confirmed reliable. + self.update() + + if self.find("exit_shop") is None: + raise RuntimeError( + "Shop detected, but exit_shop " + "button could not be found." + ) + + self.debug( + f"Shop close button: {self.loc}" + ) - def start_game(self, worldmap = False): - if not self.await_samples("shop_loaded", 80): - return False - pyautogui.press("esc") - if worldmap: - if not self.await_samples("map", 3): - return False self.click() - if not self.await_samples("shop_loaded", 10): - return False - pyautogui.press("esc") - if not self.await_samples("logo", 2): - return False - self.move() - return True + + state = self.wait_for_game_state( + { + "city", + "world", + }, + timeout=timeout, + ) + + if state is None: + raise RuntimeError( + "Closed shop, but could not identify " + "the underlying City/World screen." + ) + + self.debug( + f"Underlying game state: " + f"{state.upper()}" + ) + + # ---------------------------------------------------- + # ALREADY WHERE WE NEED TO BE + # ---------------------------------------------------- + + if state == target: + self.debug( + f"Already in {target.upper()}." + ) + return True + + # ---------------------------------------------------- + # CITY -> WORLD + # ---------------------------------------------------- + + if ( + state == "city" + and target == "world" + ): + self.debug( + "Moving City -> World..." + ) + + # Refresh screenshot and locate the Map button. + self.update() + + if self.find("map") is None: + raise RuntimeError( + "City detected, but Map button " + "could not be found." + ) + + self.debug( + f"Map button: {self.loc}" + ) + + self.click() + + state = self.wait_for_game_state( + "world", + timeout=timeout, + ) + + if state != "world": + raise RuntimeError( + "Clicked Map, but World screen " + "was not detected." + ) + + self.debug( + "WORLD confirmed." + ) + + return True + + # ---------------------------------------------------- + # WORLD -> CITY + # ---------------------------------------------------- + + if ( + state == "world" + and target == "city" + ): + self.debug( + "Moving World -> City..." + ) + + # Refresh screenshot and locate the City button. + self.update() + + if self.find("city") is None: + raise RuntimeError( + "World detected, but City button " + "could not be found." + ) + + self.debug( + f"City button: {self.loc}" + ) + + self.click() + + state = self.wait_for_game_state( + "city", + timeout=timeout, + ) + + if state != "city": + raise RuntimeError( + "Clicked City, but City screen " + "was not detected." + ) + + self.debug( + "CITY confirmed." + ) + + return True + + raise RuntimeError( + f"Unhandled game transition: " + f"{state} -> {target}" + ) + + def start_game(self, worldmap=False): + """ + Ensure the requested normal gameplay screen is active. + + The application may be started while Total Battle is + already on: + + Shop + City + World Map + + worldmap=False: + ensure City + + worldmap=True: + ensure World Map + """ + + target = ( + "world" + if worldmap + else "city" + ) + + try: + return self.ensure_game_state( + target, + ) + + except RuntimeError as exc: + print() + print( + f"start_game failed: {exc}" + ) + return False def restart_game(self, worldmap = False): self.refresh() diff --git a/automation_config.json b/automation_config.json new file mode 100644 index 0000000..b7360f3 --- /dev/null +++ b/automation_config.json @@ -0,0 +1,66 @@ +{ + "timing": { + "hover_jiggle_pause": 0.05, + "state_poll_delay": 0.1, + "crypt_tab_select_wait": 0.1, + "watchtower_refresh_wait": 0.3, + "crypt_center_wait": 1.0, + "crypt_panel_wait": 0.3, + "rare_open_wait": 0.15, + "speedup_panel_wait": 0.15, + "speedup_click_wait": 0.05, + "carter_march_poll_wait": 0.2, + "explore_to_march_wait": 0.1, + "march_panel_wait": 0.1, + "explore_to_march_retry_wait": 0.1, + "march_panel_retry_wait": 0.1, + "post_max_grace_wait": 3.0, + "carter_missing_recheck_wait": 0.2, + "carter_missing_confirmations": 2, + "crypt_panel_retry_wait": 0.12, + "crypt_panel_retries": 3, + "troop_load_timeout": 30.0 + }, + "matching": { + "carter_state_threshold": 0.006, + "carter_state_margin": 0.01, + "citadel_type_threshold": 0.05, + "citadel_type_margin": 0.03, + "carter_march_threshold": 0.1, + "cursed_citadel_threshold": 0.07, + "cursed_citadel_margin": 0.03, + "citadel_key_threshold": 0.05, + "citadel_key_margin": 0.05, + "generic_march_threshold": 0.15 + }, + "ui_regions": { + "top_center": { + "width_ratio": 0.3, + "height_ratio": 0.45, + "center_x_ratio": 0.5, + "center_y_ratio": 0.22 + }, + "middle_center": { + "width_ratio": 0.42, + "height_ratio": 0.6, + "center_x_ratio": 0.5, + "center_y_ratio": 0.52 + }, + "bottom_center": { + "width_ratio": 0.34, + "height_ratio": 0.42, + "center_x_ratio": 0.5, + "center_y_ratio": 0.79 + }, + "bottom_right": { + "width_ratio": 0.3, + "height_ratio": 0.35, + "center_x_ratio": 0.85, + "center_y_ratio": 0.82 + } + }, + "ui": { + "battle_scroll_anchor_x_offset": -100, + "battle_scroll_anchor_y_offset": 0 + } +} diff --git a/build_total_battle_helper_windows.ps1 b/build_total_battle_helper_windows.ps1 new file mode 100644 index 0000000..762f0a2 --- /dev/null +++ b/build_total_battle_helper_windows.ps1 @@ -0,0 +1,80 @@ +$ErrorActionPreference = "Stop" + +$ProjectDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ProjectDir + +$Python = Join-Path $ProjectDir ".venv\Scripts\python.exe" +$Helper = Join-Path $ProjectDir "total_battle_helper.py" +$Exe = Join-Path $ProjectDir "TotalBattleHelper.exe" +$WorkDir = Join-Path $ProjectDir "build\TotalBattleHelper" +$SpecDir = Join-Path $ProjectDir "build" + +Write-Host "" +Write-Host "============================================================" +Write-Host "=== BUILD TOTALBATTLEHELPER FOR WINDOWS" +Write-Host "============================================================" +Write-Host "" + +if (-not (Test-Path $Python)) { + throw "Project virtual environment Python not found: $Python" +} + +if (-not (Test-Path $Helper)) { + throw "TotalBattleHelper source not found: $Helper" +} + +Write-Host "=== PYTHON ===" +Write-Host $Python + +Write-Host "" +Write-Host "=== PYINSTALLER ===" +& $Python -m PyInstaller --version + +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller is not installed in the project virtual environment." +} + +Write-Host "" +Write-Host "=== REMOVE OLD BUILD OUTPUT ===" + +Remove-Item ` + $WorkDir ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + +Remove-Item ` + $Exe ` + -Force ` + -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "=== BUILD EXE ===" + +& $Python -m PyInstaller ` + --noconfirm ` + --clean ` + --onefile ` + --windowed ` + --name TotalBattleHelper ` + --distpath $ProjectDir ` + --workpath $WorkDir ` + --specpath $SpecDir ` + $Helper + +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller build failed." +} + +if (-not (Test-Path $Exe)) { + throw "Build completed but TotalBattleHelper.exe was not created." +} + +Write-Host "" +Write-Host "============================================================" +Write-Host "=== BUILD COMPLETE" +Write-Host "============================================================" +Write-Host "" + +Get-Item $Exe | + Select-Object FullName, Length, LastWriteTime diff --git a/capture_sample.py b/capture_sample.py new file mode 100755 index 0000000..0eadfe6 --- /dev/null +++ b/capture_sample.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import argparse +import json +import re + +import cv2 + +from game_monitor import ( + detect_game_monitor, + capture_game_monitor, +) + + + +# ============================================================ +# TROOP CATALOG +# ============================================================ + +CATALOG_PATH = ( + Path(__file__).resolve().parent + / "troop_catalog.json" +) + + +def pretty_troop_name(name): + """ + Convert a unit sample filename into an initial display name. + + Examples: + + heavy-knight7 -> Heavy Knight 7 + fire-phoenix2 -> Fire Phoenix 2 + """ + + text = name.replace("-", " ") + text = text.replace("_", " ") + + text = re.sub( + r"([A-Za-z])(\d+)$", + r"\1 \2", + text, + ) + + return text.title() + + +def add_unit_to_catalog(sample_name): + """ + Add a newly captured units/ sample to troop_catalog.json. + + Existing catalog entries are never modified. + + Returns True if a new catalog entry was created. + """ + + prefix = "units/" + + normalized = ( + sample_name + .replace("\\", "/") + .strip("/") + ) + + if not normalized.startswith(prefix): + return False + + troop_name = normalized[len(prefix):] + + # Only direct children of units/ are troop samples. + if ( + not troop_name + or "/" in troop_name + ): + return False + + if CATALOG_PATH.exists(): + with CATALOG_PATH.open( + "r", + encoding="utf-8", + ) as f: + catalog = json.load(f) + else: + catalog = {} + + if not isinstance(catalog, dict): + raise RuntimeError( + "troop_catalog.json must contain " + "a JSON object." + ) + + if troop_name in catalog: + print() + print( + f"Troop catalog already contains: " + f"{troop_name}" + ) + return False + + existing_orders = [] + + for entry in catalog.values(): + if not isinstance(entry, dict): + continue + + try: + existing_orders.append( + int( + entry.get( + "order", + 0, + ) + ) + ) + except ( + TypeError, + ValueError, + ): + pass + + next_order = ( + max( + existing_orders, + default=0, + ) + + 10 + ) + + catalog[troop_name] = { + "enabled": False, + "display_name": pretty_troop_name( + troop_name + ), + "group": "Other", + "order": next_order, + } + + CATALOG_PATH.write_text( + json.dumps( + catalog, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + print() + print("Added troop to catalog:") + print(f" name: {troop_name}") + print( + f" display: " + f"{catalog[troop_name]['display_name']}" + ) + print(" group: Other") + print(" enabled: False") + print(f" order: {next_order}") + + return True + + +parser = argparse.ArgumentParser( + description="Capture a template sample from the detected game monitor." +) + +parser.add_argument( + "name", + help="Sample name, e.g. common_selected", +) + +parser.add_argument( + "--directory", + default="samples_desktop", + choices=[ + "samples_desktop", + "samples", + ], + help="Destination sample directory", +) + +args = parser.parse_args() + +name = args.name.strip() + +if name.lower().endswith(".png"): + name = name[:-4] + +output = Path(args.directory) / f"{name}.png" + + +print() +print("=" * 60) +print("=== TBA SAMPLE CAPTURE ===") +print("=" * 60) +print() +print(f"Sample: {name}") +print(f"Output: {output}") +print() + +print("Detecting game monitor...") + +result = detect_game_monitor() + +if not result: + raise RuntimeError( + "Could not detect game monitor." + ) + +monitor = result["monitor"] + +print() +print("Capturing game monitor...") + +image = capture_game_monitor( + monitor +) + +print( + f"Captured: " + f"{image.shape[1]} x " + f"{image.shape[0]}" +) + +print() +print("Select the sample area.") +print("ENTER or SPACE = accept") +print("C = cancel") +print() + +roi = cv2.selectROI( + f"Capture sample: {name}", + image, + showCrosshair=True, + fromCenter=False, +) + +cv2.destroyAllWindows() + +x, y, w, h = map( + int, + roi, +) + +if w <= 0 or h <= 0: + raise RuntimeError( + "No sample area selected." + ) + +sample = image[ + y:y + h, + x:x + w, +].copy() + +# ------------------------------------------------------------ +# NORMALIZE CAPTURE TO CANONICAL SAMPLE SCALE +# ------------------------------------------------------------ +# +# Repository samples are stored at their base/reference scale. +# +# A Windows/browser/standalone client may currently render the +# game at (for example) 1.333333. Saving that crop unchanged +# would cause the runtime scaling system to scale it a second +# time later. +# +# Therefore: +# +# saved_size = captured_size / detected_sample_scale +# +# Linux/reference captures at scale 1.0 remain unchanged. +# ------------------------------------------------------------ + +sample_scale = float( + result.get( + "sample_scale", + 1.0, + ) + or 1.0 +) + +raw_height, raw_width = ( + sample.shape[:2] +) + +if abs( + sample_scale - 1.0 +) > 0.001: + + normalized_width = max( + 1, + int( + raw_width + / sample_scale + + 0.5 + ), + ) + + normalized_height = max( + 1, + int( + raw_height + / sample_scale + + 0.5 + ), + ) + + sample = cv2.resize( + sample, + ( + normalized_width, + normalized_height, + ), + interpolation=( + cv2.INTER_AREA + if sample_scale > 1.0 + else cv2.INTER_LINEAR + ), + ) + + print() + print( + "Normalizing sample to canonical scale:" + ) + + print( + f" detected scale: " + f"{sample_scale:.6f}" + ) + + print( + f" captured size: " + f"{raw_width} x {raw_height}" + ) + + print( + f" saved size: " + f"{normalized_width} x " + f"{normalized_height}" + ) + +output.parent.mkdir( + parents=True, + exist_ok=True, +) + +if output.exists(): + answer = input( + f"{output} already exists. Replace it? [y/N] " + ).strip().lower() + + if answer not in { + "y", + "yes", + }: + print("Cancelled.") + raise SystemExit(0) + +if not cv2.imwrite( + str(output), + sample, +): + raise RuntimeError( + f"Could not save {output}" + ) + +print() +print("Saved:") +print(output) +print() +saved_height, saved_width = sample.shape[:2] + +print( + f"Sample size: " + f"{saved_width} x {saved_height}" +) + +add_unit_to_catalog( + name +) diff --git a/citadel.py b/citadel.py new file mode 100644 index 0000000..2a2b6cc --- /dev/null +++ b/citadel.py @@ -0,0 +1,5198 @@ +#!/usr/bin/env python3 + +import cv2 +import json +import argparse +from pathlib import Path +import numpy as np + +from automate import Automator +from troops import load_troops +from scroll_control import production_scroll +from march import ( + wait_for_active_march, + wait_for_no_active_march, + open_and_max_speedup, +) +import watchtower +import world_map +import time + + +CITADEL_WATCHTOWER_SAMPLES = { + "cursed_20": "cursed_citadel_20_wt", + "cursed_25": "cursed_citadel_25_wt", + "elven_20": "elven_citadel_20_wt", + "elven_25": "elven_citadel_25_wt", + "elven_30": "elven_citadel_30_wt", +} + + +CITADEL_SAMPLES = { + "cursed_20": "cursed_citadel_20_select", + "cursed_25": "cursed_citadel_25_select", + "elven_20": "elven_citadel_20_select", + "elven_25": "elven_citadel_25_select", + "elven_30": "elven_citadel_30_select", +} + + +CITADEL_KEY_SAMPLES = { + "20": "citadel_key_20", + "25": "citadel_key_25", + "30": "citadel_key_30", +} + + +def detect_citadel_key(a): + """ + Classify the Citadel level from the Citadel-specific key. + + Only use this after we have established that the current + panel is a Citadel Attack screen. + """ + + region = a.get_ui_region( + "middle_center" + ) + + x, y, width, height = region + + sx = int(round(x * a.scale)) + sy = int(round(y * a.scale)) + sw = int(round(width * a.scale)) + sh = int(round(height * a.scale)) + + image = a.image[ + sy:sy + sh, + sx:sx + sw, + ] + + results = [] + + for level, sample_name in ( + CITADEL_KEY_SAMPLES.items() + ): + sample = a.get_sample( + sample_name + ) + + result = cv2.matchTemplate( + image, + sample, + a.method, + ) + + score, _, location, _ = ( + cv2.minMaxLoc(result) + ) + + results.append({ + "level": level, + "score": float(score), + "location": location, + }) + + results.sort( + key=lambda item: item["score"] + ) + + best = results[0] + second = results[1] + + margin = ( + second["score"] + - best["score"] + ) + + return { + "level": best["level"], + "score": best["score"], + "margin": margin, + "scores": { + item["level"]: item["score"] + for item in results + }, + } + + +def find_citadel_watchtower_targets( + a, + max_y_delta=70, +): + """ + Find supported Citadel rows in the currently-open + Watchtower Monsters list. + + Citadel *_wt templates identify supported rows. + + GO buttons are paired to Citadel rows by nearest vertical + position. The Watchtower list is row-based, so the Citadel + icon/name and its GO button should share approximately the + same Y coordinate. + + The Watchtower detection is NOT treated as authoritative + Citadel classification. Final Citadel type is still + determined later from the cleaner Attack panel using the + *_select templates. + + Returns a list like: + + [ + { + "watchtower_type": "elven_20", + "target": (x, y), + "go": (x, y), + "y_delta": 2.5, + } + ] + """ + + region = a.get_ui_region( + "middle_center" + ) + + # One fresh screenshot for both Citadel templates and GO + # buttons. + a.update() + + citadel_matches = [] + + for ( + citadel_type, + sample_name, + ) in CITADEL_WATCHTOWER_SAMPLES.items(): + + matches = a.find_multi_in_region( + sample_name, + region, + ) + + if not matches: + continue + + for location in matches: + citadel_matches.append( + { + "watchtower_type": ( + citadel_type + ), + "target": location, + } + ) + + go_buttons = a.find_multi_in_region( + "go_watchtower", + region, + ) + + if not go_buttons: + print( + "No Watchtower GO buttons detected." + ) + return [] + + if not citadel_matches: + print( + "No supported Citadels detected " + "in Monsters list." + ) + return [] + + a.debug() + a.debug( + f"Supported Citadel rows detected: " + f"{len(citadel_matches)}" + ) + + a.debug( + f"GO buttons detected: " + f"{len(go_buttons)}" + ) + + # -------------------------------------------------------- + # PAIR EACH CITADEL WITH NEAREST-Y GO + # -------------------------------------------------------- + + results = [] + + used_go = set() + + # Process from top to bottom so pairings are deterministic. + citadel_matches.sort( + key=lambda item: ( + item["target"][1], + item["target"][0], + ) + ) + + for target in citadel_matches: + + target_y = target[ + "target" + ][1] + + candidates = [] + + for index, go in enumerate( + go_buttons + ): + if index in used_go: + continue + + y_delta = abs( + float(go[1]) + - float(target_y) + ) + + candidates.append( + ( + y_delta, + index, + go, + ) + ) + + if not candidates: + continue + + ( + y_delta, + go_index, + go_location, + ) = min( + candidates, + key=lambda item: item[0], + ) + + if y_delta > max_y_delta: + a.debug( + f"Skipping " + f"{target['watchtower_type']}: " + f"nearest GO is " + f"{y_delta:.1f}px away vertically." + ) + continue + + used_go.add( + go_index + ) + + result = { + "watchtower_type": ( + target[ + "watchtower_type" + ] + ), + "target": ( + target[ + "target" + ] + ), + "go": go_location, + "y_delta": y_delta, + } + + results.append( + result + ) + + a.debug( + f" " + f"{result['watchtower_type']:10s} " + f"target={result['target']} " + f"GO={result['go']} " + f"delta={result['y_delta']:.1f}" + ) + + return results + + +def wait_for_citadel_watchtower_target( + a, + max_refreshes=10, +): + """ + Find a supported Citadel in the Watchtower Monsters tab. + + If the current Monsters list has no supported Citadel rows, + click the Monsters tab as the normal Watchtower refresh + action and try again. + + Because clicking Monsters also establishes the selected tab, + no separate tab-state verification is needed after refresh. + """ + + for attempt in range( + 1, + max_refreshes + 1, + ): + + targets = ( + find_citadel_watchtower_targets( + a + ) + ) + + if targets: + chosen = targets[ + 0 + ] + + a.debug() + a.debug( + "Chosen Watchtower Citadel:" + ) + + print( + f"Citadel selected: " + f"{chosen['watchtower_type']}" + ) + + a.debug( + f" target: " + f"{chosen['target']}" + ) + + a.debug( + f" GO: " + f"{chosen['go']}" + ) + + return chosen + + print() + print( + "No supported Citadels currently " + "available." + ) + + print( + f"Refreshing Monsters list " + f"({attempt}/{max_refreshes})..." + ) + + if not watchtower.refresh_tab( + a, + "monsters", + ): + return None + + print( + "FAIL: no supported Citadel appeared " + "after Watchtower refreshes." + ) + + return None + + +def choose_citadel_watchtower_target( + a, +): + """ + Choose one supported Citadel from the current Watchtower + Monsters list. + + For now choose the first visible supported Citadel from + top to bottom. + + Later this can honor configured Citadel priorities/types. + """ + + targets = ( + find_citadel_watchtower_targets( + a + ) + ) + + if not targets: + return None + + chosen = targets[0] + + a.debug() + a.debug( + "Chosen Watchtower Citadel:" + ) + + print( + f"Citadel selected: " + f"{chosen['watchtower_type']}" + ) + + a.debug( + f" target: " + f"{chosen['target']}" + ) + + a.debug( + f" GO: " + f"{chosen['go']}" + ) + + return chosen + + +def detect_citadel_attack( + a, +): + """ + Classify the Citadel shown on the Attack panel. + + The first confident classification discovers and caches the + Citadel portrait anchor from the broader middle-center area. + + Subsequent classifications in the same Automator session + search only the small buffered region around that learned + anchor. + + The Attack button remains a gate: this classifier is only + valid when the Citadel Attack screen is actually open. + """ + + region = a.get_ui_region( + "middle_center" + ) + + # -------------------------------------------------------- + # ATTACK-SCREEN GATE + # -------------------------------------------------------- + + attack = a.find_in_region( + "attack", + region, + set_loc=False, + ) + + # Clicking the centered Citadel leaves the mouse sitting + # directly over it. Total Battle may display a hover popup + # that covers the Attack button. + # + # Do not move the mouse unless the gate actually failed. + # If Attack was not visible, clear the hover and retry once + # from a fresh screenshot. + if attack is None: + + safe = a.get_ui_location( + "game_safe_location" + ) + + if safe is not None: + + a.debug( + "Attack button not visible; " + "clearing possible hover popup..." + ) + + a.loc = safe + + a.move() + + time.sleep( + 0.35 + ) + + a.update() + + attack = a.find_in_region( + "attack", + region, + set_loc=False, + ) + + if attack is None: + return { + "state": "not_attack_screen", + "attack": None, + "score": float("inf"), + "margin": 0.0, + "location": None, + "anchored": False, + } + + # -------------------------------------------------------- + # ANCHORED CITADEL-TYPE CLASSIFICATION + # + # IMPORTANT: + # If hover recovery took a new screenshot, this classifier + # uses that SAME fresh image because update=False below. + # -------------------------------------------------------- + + result = ( + a.classify_anchored_ui_state( + "citadel_type", + threshold=a.matching[ + "citadel_type_threshold" + ], + min_margin=a.matching[ + "citadel_type_margin" + ], + update=False, + ) + ) + + if result is None: + return { + "state": "unknown", + "attack": attack, + "score": float("inf"), + "margin": 0.0, + "location": None, + "anchored": False, + } + + broad_best = result[ + "best_state" + ] + + broad_score = float( + result["score"] + ) + + broad_margin = float( + result["margin"] + ) + + # -------------------------------------------------------- + # CURSED CITADELS + # -------------------------------------------------------- + # + # The broad portrait classifier is reliable for the two + # supported Cursed levels on both tested platforms. + # + if broad_best in { + "cursed_20", + "cursed_25", + }: + cursed_threshold = float( + a.matching[ + "cursed_citadel_threshold" + ] + ) + + cursed_margin = float( + a.matching[ + "cursed_citadel_margin" + ] + ) + + if ( + broad_score <= cursed_threshold + and broad_margin >= cursed_margin + ): + state = broad_best + else: + state = "unknown" + + # -------------------------------------------------------- + # ELVEN CITADELS + # -------------------------------------------------------- + # + # The broad Elven portraits are too visually similar to + # safely determine 20/25/30. Once the broad classifier says + # the family is Elven, use the Citadel-specific key instead. + # + elif broad_best in { + "elven_20", + "elven_25", + "elven_30", + }: + key_result = detect_citadel_key( + a + ) + + key_threshold = float( + a.matching[ + "citadel_key_threshold" + ] + ) + + key_margin = float( + a.matching[ + "citadel_key_margin" + ] + ) + + if ( + key_result["score"] + <= key_threshold + and key_result["margin"] + >= key_margin + ): + state = ( + "elven_" + + key_result["level"] + ) + else: + state = "unknown" + + else: + state = "unknown" + + a.debug( + "Citadel classifier: " + f"attack={attack} " + f"broad_best={broad_best} " + f"broad_score={broad_score:.6f} " + f"broad_margin={broad_margin:.6f} " + f"state={state} " + f"anchored={result['anchored']}" + ) + + return { + "state": state, + "attack": attack, + "score": broad_score, + "margin": broad_margin, + "location": result[ + "location" + ], + "anchored": result[ + "anchored" + ], + "region": result[ + "region" + ], + "scores": result[ + "scores" + ], + } + + + +def ensure_citadel_world_ready(a): + """ + Establish a safe World-map starting point for Citadel work. + + Unlike the generic high-level state detector, Citadel + automation also knows about its own Battle troop-entry + screen. + + A previous interrupted/crashed Citadel run can therefore + leave the client on Battle. Detect that state explicitly, + back out once, then use the normal World-state machinery. + """ + + import time + + a.update() + + # -------------------------------------------------------- + # RECOVER FROM ABANDONED BATTLE SCREEN + # -------------------------------------------------------- + + if a.find( + "battle", + set_loc=False, + ) is not None: + + print( + "Citadel Battle screen found from previous run." + ) + + print( + "Backing out to recover World state..." + ) + + # InputController implementations already expose the + # generic key press path used elsewhere by Automator. + a.input.press_escape() + + time.sleep( + 0.75 + ) + + a.update() + + # -------------------------------------------------------- + # NORMAL SHOP / CITY / WORLD RECOVERY + # -------------------------------------------------------- + + try: + return world_map.ensure_world( + a + ) + + except RuntimeError: + print( + "World state was not immediately identifiable." + ) + + print( + "Attempting one Escape-based recovery..." + ) + + a.input.press_escape() + + time.sleep( + 0.75 + ) + + return world_map.ensure_world( + a + ) + + +def open_citadel_from_world( + a, +): + """ + Navigate from normal City/World gameplay to a supported + Citadel Attack panel. + + Flow: + + ensure World + -> open Watchtower Monsters tab + -> identify supported Citadel Watchtower row + -> click its paired GO + -> click the centered world target + -> classify the Citadel from the Attack panel + + The Watchtower *_wt classification is only a navigation + hint. The Attack-panel *_select classification is the + authoritative Citadel type. + + Does NOT click Attack. + + Returns the authoritative Citadel type on success, + otherwise None. + """ + + # -------------------------------------------------------- + # WORLD + # -------------------------------------------------------- + + if not ensure_citadel_world_ready( + a + ): + print( + "FAIL: could not establish World state." + ) + + return None + + # -------------------------------------------------------- + # WATCHTOWER -> MONSTERS + # -------------------------------------------------------- + + if not watchtower.open_watchtower( + a, + tab="monsters", + ): + print( + "FAIL: could not open Watchtower Monsters tab." + ) + + return None + + # Allow the Monsters list to settle. + time.sleep( + a.timing[ + "watchtower_refresh_wait" + ] + ) + + # -------------------------------------------------------- + # FIND CITADEL ROW + MATCHED GO + # -------------------------------------------------------- + + chosen = ( + wait_for_citadel_watchtower_target( + a + ) + ) + + if chosen is None: + print( + "FAIL: no supported Citadel " + "Watchtower row found." + ) + + return None + + a.debug() + a.debug( + f"Clicking Citadel GO: " + f"{chosen['go']}" + ) + + if not watchtower.click_go( + a, + chosen[ + "go" + ], + ): + print( + "FAIL: could not click Citadel GO." + ) + + return None + + # Preserve the currently proven centering delay. + time.sleep( + a.timing[ + "crypt_center_wait" + ] + ) + + # -------------------------------------------------------- + # CLICK CENTERED WORLD TARGET + # -------------------------------------------------------- + + world_map.click_center_target( + a + ) + + time.sleep( + a.timing[ + "crypt_panel_wait" + ] + ) + + # -------------------------------------------------------- + # AUTHORITATIVE ATTACK-PANEL CLASSIFICATION + # -------------------------------------------------------- + + a.update() + + result = detect_citadel_attack( + a + ) + + citadel_type = result[ + "state" + ] + + if citadel_type not in CITADEL_SAMPLES: + print( + "FAIL: centered target did not produce " + "a supported Citadel Attack panel. " + f"State: {citadel_type}" + ) + + print( + "Treating centered Citadel target as stale." + ) + + print( + "Returning to World and trying another " + "Watchtower Citadel..." + ) + + # ---------------------------------------------------- + # RECOVER TO WORLD BEFORE RETRYING WATCHTOWER + # ---------------------------------------------------- + # + # The Watchtower row was valid when selected, but the + # centered world target failed to produce an + # authoritative Citadel Attack panel. + # + # This can happen when the target expires, moves, or the + # centered click lands during a transient UI state. + # + # Do not terminate a long production run for this + # condition. Recover World state and tell the outer loop + # to select another Watchtower Citadel. + # ---------------------------------------------------- + + if not ensure_citadel_world_ready( + a + ): + print( + "FAIL: could not recover World state after " + "stale centered Citadel target." + ) + + return None + + return CITADEL_RETRY + + # Preserve this authoritative Attack-panel result. + # + # open_citadel_battle() is the immediate consumer when the + # normal World -> Citadel flow continues. Reusing this result + # avoids taking another screenshot and reclassifying the same + # unchanged panel. + a.current_citadel_type = citadel_type + a.current_citadel_attack = result["attack"] + + if result["attack"] is not None: + a.cache_ui_location( + "citadel_attack_button", + result["attack"], + ) + + a.debug() + a.debug( + "Citadel opened from World:" + ) + + a.debug( + f" Watchtower candidate: " + f"{chosen['watchtower_type']}" + ) + + a.debug( + f" Attack classification: " + f"{citadel_type}" + ) + + a.debug( + f" score={result['score']:.6f} " + f"margin={result['margin']:.6f}" + ) + + return citadel_type + + +def open_citadel_battle(a): + """ + From a selected Citadel attack panel: + + use authoritative result from World flow when available + -> otherwise classify Citadel normally + -> click Attack + -> wait for Battle troop-entry screen + + Returns Citadel type string on success, otherwise None. + """ + + # -------------------------------------------------------- + # REUSE AUTHORITATIVE WORLD-FLOW RESULT WHEN AVAILABLE + # -------------------------------------------------------- + + citadel_type = getattr( + a, + "current_citadel_type", + None, + ) + + attack = getattr( + a, + "current_citadel_attack", + None, + ) + + if ( + citadel_type in CITADEL_SAMPLES + and attack is not None + ): + a.debug( + f"Reusing Citadel Attack result: " + f"{citadel_type} attack={attack}" + ) + + else: + # ---------------------------------------------------- + # STANDALONE / FALLBACK CLASSIFICATION + # ---------------------------------------------------- + + a.update() + + result = detect_citadel_attack( + a + ) + + citadel_type = result[ + "state" + ] + + if citadel_type not in CITADEL_SAMPLES: + print( + "No supported Citadel attack panel detected. " + f"State: {citadel_type}" + ) + return None + + attack = result[ + "attack" + ] + + if attack is None: + print( + "FAIL: Citadel Attack button was not found." + ) + return None + + # Cache the result for the lifetime of this panel. + a.current_citadel_type = citadel_type + a.current_citadel_attack = attack + + a.cache_ui_location( + "citadel_attack_button", + attack, + ) + + a.debug( + f"Citadel identified: " + f"{citadel_type}" + ) + + # -------------------------------------------------------- + # CLICK THE ALREADY-KNOWN ATTACK BUTTON + # -------------------------------------------------------- + + a.loc = attack + + a.debug( + f"Clicking Attack: " + f"{attack}" + ) + + a.click() + + # The cached result belongs specifically to the Attack + # panel we just left. Do not allow it to leak into another + # Citadel attempt. + a.current_citadel_type = None + a.current_citadel_attack = None + + battle = a.await_samples( + "battle", + 5, + ) + + if not battle: + print( + "FAIL: Battle troop-entry screen " + "did not appear." + ) + return None + + a.debug( + f"Battle screen found: " + f"{battle}" + ) + + return citadel_type + +def load_citadel_config( + config_path="citadel_stacks.json", +): + """ + Load Citadel type -> troop-stack configuration. + """ + + path = Path( + config_path + ) + + if not path.exists(): + raise FileNotFoundError( + f"Citadel stack config not found: {path}" + ) + + with path.open( + "r", + encoding="utf-8", + ) as f: + return json.load( + f + ) + + +def get_citadel_silver_troops( + citadel_type, + config_path="citadel_stacks.json", + catalog_path="troop_catalog.json", +): + """ + Return the silver troop names actually configured to be sent + for one Citadel type. + + The Citadel stack determines which units are sent. + troop_catalog.json determines which of those units belong to + the "Silver Troops" group. + """ + + config = load_citadel_config( + config_path + ) + + entry = config.get( + citadel_type + ) + + if entry is None: + return [] + + troops_file = Path( + entry[ + "troops_file" + ] + ) + + if not troops_file.exists(): + raise FileNotFoundError( + f"Citadel troop stack not found: " + f"{troops_file}" + ) + + with troops_file.open( + "r", + encoding="utf-8", + ) as f: + troops = json.load( + f + ) + + catalog_file = Path( + catalog_path + ) + + if not catalog_file.exists(): + raise FileNotFoundError( + f"Troop catalog not found: " + f"{catalog_file}" + ) + + with catalog_file.open( + "r", + encoding="utf-8", + ) as f: + catalog = json.load( + f + ) + + silver = [] + + for troop_name in troops: + catalog_entry = catalog.get( + troop_name + ) + + if catalog_entry is None: + continue + + if ( + catalog_entry.get( + "group" + ) + == "Silver Troops" + ): + silver.append( + troop_name + ) + + return silver + + +def load_citadel_stack( + a, + citadel_type, + config_path="citadel_stacks.json", +): + """ + Load the troop stack configured for one Citadel type. + + Assumes the Battle troop-entry screen is already open. + + Returns True on success, False otherwise. + """ + + config = load_citadel_config( + config_path + ) + + entry = config.get( + citadel_type + ) + + if entry is None: + print( + f"FAIL: no troop configuration for " + f"{citadel_type}." + ) + return False + + if not entry.get( + "enabled", + False, + ): + print( + f"Citadel {citadel_type} is disabled." + ) + return False + + troops_file = Path( + entry[ + "troops_file" + ] + ) + + if not troops_file.exists(): + print( + f"FAIL: troop stack file does not exist: " + f"{troops_file}" + ) + return False + + with troops_file.open( + "r", + encoding="utf-8", + ) as f: + troops = json.load( + f + ) + + print() + print( + f"Loading Citadel troop stack: " + f"{citadel_type}" + ) + + a.debug( + f"Troop file: {troops_file}" + ) + + print( + f"Loss policy: " + f"{entry.get('loss_policy', 'stop')}" + ) + + return load_troops( + a, + troops, + ) + + + +# ============================================================ +# CITADEL HERO / CAPTAIN READINESS +# ============================================================ +# +# The Battle screen contains four commander selection slots: +# +# slot 0 = Hero +# slot 1 = Captain 1 +# slot 2 = Captain 2 +# slot 3 = Captain 3 +# +# Citadel automation must NEVER load troops or launch unless all +# four slots are positively detected as selected. +# +# Measured states with the canonical selected-box sample: +# +# selected: +# approximately 0.002 - 0.010 +# +# not selected: +# approximately 0.033 - 0.041 +# +# Measured on-march sample: +# +# approximately 0.023 when genuinely on march +# +# Lower scores are better because the matcher uses SQDIFF. +# +# The geometry below was calibrated from the centered Battle UI +# and normalized back to canonical sample scale. At runtime it +# is reconstructed from the actual Total Battle window center +# and current sample_scale, so it is not tied to one monitor +# resolution or aspect ratio. +# ============================================================ + +CITADEL_RETRY = object() + +CITADEL_COMMANDER_SELECTED_MAX_SCORE = 0.015 +CITADEL_COMMANDER_ON_MARCH_MAX_SCORE = 0.040 + +CITADEL_COMMANDER_STRIP_X_OFFSET = -470.25 +CITADEL_COMMANDER_STRIP_Y_OFFSET = -115.125 + +CITADEL_COMMANDER_STRIP_WIDTH = 470.25 +CITADEL_COMMANDER_STRIP_HEIGHT = 65.25 + + +def _citadel_game_center(a): + """ + Return the center of the actual Total Battle window in + monitor-relative coordinates. + """ + + geometry = a.game_window_geometry + + if geometry is None: + return None + + if isinstance( + geometry, + dict, + ): + left = float( + geometry.get( + "left", + geometry.get( + "x", + 0.0, + ), + ) + ) + + top = float( + geometry.get( + "top", + geometry.get( + "y", + 0.0, + ), + ) + ) + + width = float( + geometry[ + "width" + ] + ) + + height = float( + geometry[ + "height" + ] + ) + + else: + left = float( + getattr( + geometry, + "left", + getattr( + geometry, + "x", + 0.0, + ), + ) + ) + + top = float( + getattr( + geometry, + "top", + getattr( + geometry, + "y", + 0.0, + ), + ) + ) + + width = float( + getattr( + geometry, + "width", + ) + ) + + height = float( + getattr( + geometry, + "height", + ) + ) + + return ( + left + width / 2.0, + top + height / 2.0, + ) + + +def get_citadel_commander_strip_region(a): + """ + Return the dynamic Hero/Captain strip region. + + The Battle UI is center anchored. Relative dimensions are + scaled by the discovered visual sample scale. + """ + + center = _citadel_game_center( + a + ) + + if center is None: + return None + + center_x, center_y = center + + visual_scale = float( + a.sample_scale + ) + + return ( + center_x + + CITADEL_COMMANDER_STRIP_X_OFFSET + * visual_scale, + + center_y + + CITADEL_COMMANDER_STRIP_Y_OFFSET + * visual_scale, + + CITADEL_COMMANDER_STRIP_WIDTH + * visual_scale, + + CITADEL_COMMANDER_STRIP_HEIGHT + * visual_scale, + ) + + +def _best_citadel_commander_sample_score( + a, + sample_name, + region, +): + """ + Return the single best sample score within one commander + slot. + + This deliberately does NOT use find_multi(). One physical + checkbox can create several overlapping template matches; + each logical slot needs only one best score. + """ + + import cv2 + + rx, ry, rw, rh = region + + sx = int( + round( + rx * a.scale + ) + ) + + sy = int( + round( + ry * a.scale + ) + ) + + sw = int( + round( + rw * a.scale + ) + ) + + sh = int( + round( + rh * a.scale + ) + ) + + image = a.image[ + sy:sy + sh, + sx:sx + sw, + ] + + if image.size == 0: + return None + + template = a.get_sample( + sample_name + ) + + image_height, image_width = ( + image.shape[:2] + ) + + template_height, template_width = ( + template.shape[:2] + ) + + if ( + template_width > image_width + or template_height > image_height + ): + return None + + result = cv2.matchTemplate( + image, + template, + a.method, + ) + + ( + min_val, + max_val, + _, + _, + ) = cv2.minMaxLoc( + result + ) + + if a.method in ( + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ): + return float( + min_val + ) + + return float( + 1.0 - max_val + ) + + +def _classify_citadel_commander_slot( + selected_score, + on_march_score, +): + """ + Classify one Hero/Captain checkbox. + + Safety rule: + Only a strong positive selected score is considered + selected. + + Everything else is non-ready. + """ + + if ( + selected_score is None + or on_march_score is None + ): + return "unknown" + + if ( + on_march_score + < CITADEL_COMMANDER_ON_MARCH_MAX_SCORE + and on_march_score + < selected_score + ): + return "on_march" + + if ( + selected_score + < CITADEL_COMMANDER_SELECTED_MAX_SCORE + ): + return "selected" + + return "not_selected" + + +def detect_citadel_commander_states( + a, + update=True, +): + """ + Inspect Hero + three Captain checkbox states. + + Returns a result dictionary containing exactly four logical + slots. + + READY requires all four slots to be positively selected. + """ + + if update: + a.update() + + strip = get_citadel_commander_strip_region( + a + ) + + if strip is None: + return { + "ready": False, + "states": [ + "unknown", + "unknown", + "unknown", + "unknown", + ], + "slots": [], + } + + rx, ry, rw, rh = strip + + slot_width = ( + rw / 4.0 + ) + + names = ( + "hero", + "captain_1", + "captain_2", + "captain_3", + ) + + slots = [] + states = [] + + for slot_index, name in enumerate( + names + ): + region = ( + rx + + slot_width + * slot_index, + + ry, + + slot_width, + + rh, + ) + + selected_score = ( + _best_citadel_commander_sample_score( + a, + "selected", + region, + ) + ) + + on_march_score = ( + _best_citadel_commander_sample_score( + a, + "citadel_captain_on_march", + region, + ) + ) + + state = ( + _classify_citadel_commander_slot( + selected_score, + on_march_score, + ) + ) + + states.append( + state + ) + + slots.append({ + "name": name, + "state": state, + "selected_score": selected_score, + "on_march_score": on_march_score, + "region": region, + }) + + ready = all( + state == "selected" + for state in states + ) + + return { + "ready": ready, + "states": states, + "slots": slots, + "region": strip, + } + + +def await_citadel_commanders_ready( + a, + attempts=3, + settle_wait=0.30, +): + """ + Give the Battle UI a short bounded opportunity to finish + settling. + + This handles the case where the march has technically + returned but the Hero/Captain checkboxes have not all + visually re-selected yet. + + Returns the final commander-state result. + """ + + import time + + result = None + + for attempt in range( + 1, + attempts + 1, + ): + result = detect_citadel_commander_states( + a, + update=True, + ) + + a.debug( + f"Citadel commander readiness " + f"{attempt}/{attempts}:" + ) + + for slot in result[ + "slots" + ]: + selected_score = slot[ + "selected_score" + ] + + on_march_score = slot[ + "on_march_score" + ] + + a.debug( + f" {slot['name']:<10} " + f"state={slot['state']:<12} " + f"selected=" + f"{selected_score:.6f} " + f"on_march=" + f"{on_march_score:.6f}" + if ( + selected_score is not None + and on_march_score is not None + ) + else + f" {slot['name']:<10} " + f"state={slot['state']}" + ) + + if result[ + "ready" + ]: + a.debug( + "All Hero/Captain slots are selected." + ) + + return result + + if attempt < attempts: + a.debug( + "Hero/Captain selection is still settling; " + "checking again..." + ) + + time.sleep( + settle_wait + ) + + return result + + +def prepare_citadel_battle( + a, + config_path="citadel_stacks.json", +): + """ + Selected Citadel panel + -> classify Citadel + -> click Attack + -> Battle screen + -> verify Hero + all 3 Captains selected + -> load configured troop stack + + Does NOT click the final Battle attack button. + + Returns Citadel type on success, otherwise None. + """ + + citadel_type = open_citadel_battle( + a + ) + + if citadel_type is None: + return None + + # -------------------------------------------------------- + # HERO / CAPTAIN READINESS SAFETY GATE + # -------------------------------------------------------- + # + # Do not load a single troop unless Hero + all 3 Captains + # are positively selected. + # + # A returning commander may take a moment to visually settle + # after the march banner disappears, so retry a few fresh + # captures before recycling this Citadel. + # -------------------------------------------------------- + + commander_result = ( + await_citadel_commanders_ready( + a, + attempts=1, + ) + ) + + if not commander_result[ + "ready" + ]: + print() + print( + "CITADEL COMMANDERS NOT READY." + ) + + for slot in commander_result[ + "slots" + ]: + print( + f" {slot['name']}: " + f"{slot['state']}" + ) + + print( + "Troops will NOT be loaded." + ) + + print( + "Final March will NOT be clicked." + ) + + print() + print( + "Leaving Battle and returning to World..." + ) + + # ---------------------------------------------------- + # BATTLE -> WORLD + # ---------------------------------------------------- + # + # Do not rely on the generic high-level state detector + # while Battle is still open. The Citadel recovery + # helper explicitly recognizes Battle and backs out + # using the platform-supported press_escape() method. + # ---------------------------------------------------- + + if not ensure_citadel_world_ready( + a + ): + print( + "FAIL: could not return to World after " + "commander readiness failure." + ) + + return None + + print() + print( + "World restored." + ) + + # ---------------------------------------------------- + # WAIT FOR THE ACTUAL MARCH TO DISAPPEAR + # ---------------------------------------------------- + # + # Commander readiness is NOT repeatedly tested from the + # Battle screen. + # + # Instead, remain on World and watch the generic active + # march banner at the top of the game. Only after that + # banner is positively gone may the production loop + # recycle through Watchtower. + # ---------------------------------------------------- + + print( + "Waiting for active Citadel march to finish " + "before reopening Watchtower..." + ) + + if not wait_for_no_active_march( + a, + poll_wait=0.50, + consecutive_misses=2, + ): + print( + "FAIL: active Citadel march did not clear." + ) + + return None + + # Clear any stale Citadel lifecycle cache now that the + # visible march banner has positively disappeared. + a.citadel_march_active = False + a.citadel_max_speedup_time = None + a.citadel_march_loc = None + + print( + "Citadel march is gone." + ) + + print( + "Watchtower may now be reopened." + ) + + return CITADEL_RETRY + + a.debug( + "Citadel commander safety gate passed: " + "Hero + all 3 Captains selected." + ) + + if not load_citadel_stack( + a, + citadel_type, + config_path=config_path, + ): + print( + "FAIL: Citadel troop stack could not be loaded." + ) + return None + + a.debug() + a.debug( + f"CITADEL PREPARATION SUCCESS: " + f"{citadel_type}" + ) + + a.debug( + "Troops are loaded. " + "Final Battle attack was NOT clicked." + ) + + return citadel_type + + + + +def prepare_citadel_from_world( + a, + config_path="citadel_stacks.json", +): + """ + Complete the safe Citadel preparation flow: + + City / World + -> Watchtower Monsters + -> supported Citadel GO + -> centered Citadel + -> classify Attack panel + -> click Attack + -> Battle troop-entry screen + -> load configured Citadel troop stack + + Does NOT click the final Battle attack button. + + Returns the authoritative Citadel type on success, + otherwise None. + """ + + # -------------------------------------------------------- + # WORLD -> ATTACK PANEL + # -------------------------------------------------------- + + initial_type = open_citadel_from_world( + a + ) + + if initial_type is CITADEL_RETRY: + return CITADEL_RETRY + + if initial_type is None: + return None + + # -------------------------------------------------------- + # ATTACK -> BATTLE -> CONFIGURED TROOPS + # + # open_citadel_from_world() already performed the + # authoritative Attack-panel classification and preserved + # its Attack location. open_citadel_battle() reuses that + # result instead of reclassifying the unchanged panel. + # -------------------------------------------------------- + + prepared_type = prepare_citadel_battle( + a, + config_path=config_path, + ) + + if prepared_type is CITADEL_RETRY: + return CITADEL_RETRY + + if prepared_type is None: + return None + + # This should never disagree, but treat disagreement as a + # safety failure because the troop stack is type-specific. + if prepared_type != initial_type: + print( + "FAIL: Citadel classification changed " + "between World opening and Battle preparation." + ) + + print( + f" initial: {initial_type}" + ) + + print( + f" prepared: {prepared_type}" + ) + + return None + + a.debug() + a.debug( + "============================================================" + ) + + a.debug( + "CITADEL WORLD -> BATTLE PREPARATION SUCCESS" + ) + + a.debug( + "============================================================" + ) + + a.debug() + + a.debug( + f"Citadel: {prepared_type}" + ) + + a.debug( + "Configured troops loaded." + ) + + a.debug( + "FINAL BATTLE ATTACK WAS NOT CLICKED." + ) + + return prepared_type + + +def detect_outstanding_losses( + a, + update=False, + buffer=30, +): + """ + Detect the outstanding-losses HUD icon. + + The icon has a static location shared by City and World, + but its presence is dynamic. + + If the location is already cached, search only a small + region around it. Otherwise search bottom-center and cache + the first successful location. + + Returns the icon location when losses are outstanding, + otherwise None. + """ + + if update: + a.update() + + cached = a.get_ui_location( + "losses" + ) + + if cached is not None: + + sample = a.get_sample( + "losses" + ) + + height, width = sample.shape[:2] + + cx = float(cached[0]) + cy = float(cached[1]) + + region = ( + int( + cx + - width / 2 + - buffer + ), + int( + cy + - height / 2 + - buffer + ), + int( + width + + buffer * 2 + ), + int( + height + + buffer * 2 + ), + ) + + location = a.find_in_region( + "losses", + region, + set_loc=False, + ) + + if location is not None: + a.cache_ui_location( + "losses", + location, + ) + + return location + + location = a.find_in_region( + "losses", + a.get_ui_region( + "bottom_center" + ), + set_loc=False, + ) + + if location is None: + return None + + a.cache_ui_location( + "losses", + location, + ) + + print( + f"Losses icon located and cached: " + f"{location}" + ) + + return location + + + +def open_temple( + a, + losses_location=None, + retries=3, +): + """ + Reliably open the Temple. + + The losses HUD icon has occasionally ignored an otherwise + correctly positioned click on Windows, so this specific + control uses a small pointer jiggle before clicking. + + The Temple must then be positively confirmed from Temple-only + loss controls before this function returns True. + """ + + if losses_location is None: + a.update() + + losses_location = ( + detect_outstanding_losses( + a, + update=False, + ) + ) + + if losses_location is None: + print( + "No outstanding losses icon found." + ) + return False + + safe = a.get_ui_location( + "game_safe_location" + ) + + if safe is None: + print( + "FAIL: game_safe_location is not cached." + ) + return False + + for attempt in range( + 1, + retries + 1, + ): + if attempt > 1: + # Reacquire the icon from a fresh World screenshot + # rather than trusting the coordinate from the + # previous failed attempt. + a.update() + + refreshed = ( + detect_outstanding_losses( + a, + update=False, + ) + ) + + if refreshed is not None: + losses_location = refreshed + + x = float( + losses_location[0] + ) + y = float( + losses_location[1] + ) + + print( + f"Opening Temple " + f"(attempt {attempt}/{retries}): " + f"({x:.1f}, {y:.1f})" + ) + + # This is intentionally NOT global click behavior. + # The losses HUD icon is a known occasionally-missed + # control, so give only this click a small jiggle. + a.input.current_position = None + a.input.current_monitor = None + + a.loc = ( + x, + y, + ) + a.move() + + time.sleep( + 0.08 + ) + + a.loc = ( + x + 8.0, + y, + ) + a.move() + + time.sleep( + 0.05 + ) + + a.loc = ( + x, + y, + ) + a.move() + + time.sleep( + 0.10 + ) + + a.click() + + # Immediately clear hover away from the Temple controls. + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + # Give the panel a short opportunity to render, then + # check multiple fresh frames rather than one snapshot. + for confirm_attempt in range( + 4 + ): + time.sleep( + 0.15 + if confirm_attempt == 0 + else 0.10 + ) + + a.update() + + region = a.get_ui_region( + "middle_center" + ) + + revive_all = a.find_in_region( + "revive_all", + region, + set_loc=False, + ) + + revive_gold = a.find_in_region( + "revive_gold", + region, + set_loc=False, + ) + + if ( + revive_all is not None + or revive_gold is not None + ): + print( + "Temple confirmed open." + ) + + return True + + print( + "Temple was not confirmed after click." + ) + + print( + f"FAIL: Temple did not open after " + f"{retries} attempts." + ) + + return False + + +def revive_all_losses( + a, + losses_location=None, +): + """ + Revive all outstanding losses. + + Flow: + + losses HUD icon + -> click + -> move cursor to game_safe_location + -> wait for Temple panel + -> find revive_all using the full tall template + -> click inside the lower third of the template + -> return to World + -> verify losses icon is gone + + Returns True only when outstanding losses are confirmed + cleared. + """ + + # -------------------------------------------------------- + # LOCATE LOSSES ICON IF CALLER DID NOT SUPPLY IT + # -------------------------------------------------------- + + if losses_location is None: + + a.update() + + losses_location = ( + detect_outstanding_losses( + a, + update=False, + ) + ) + + if losses_location is None: + + print( + "No outstanding losses to revive." + ) + + return True + + # -------------------------------------------------------- + # OPEN + CONFIRM TEMPLE + # -------------------------------------------------------- + + if not open_temple( + a, + losses_location=losses_location, + ): + print( + "FAIL: could not open Temple " + "for Revive All." + ) + return False + + # Immediately clear any remaining hover before looking + # for the tall Revive All control. + safe = a.get_ui_location( + "game_safe_location" + ) + + if safe is None: + print( + "FAIL: game_safe_location is not cached." + ) + return False + + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + time.sleep(0.20) + + # -------------------------------------------------------- + # FIND REVIVE ALL + # -------------------------------------------------------- + + a.update() + + revive_all = a.find_in_region( + "revive_all", + a.get_ui_region( + "middle_center" + ), + set_loc=False, + ) + + if revive_all is None: + + print( + "FAIL: Revive All could not be identified." + ) + + return False + + sample = a.get_sample( + "revive_all" + ) + + height, width = sample.shape[:2] + + # The template deliberately contains extra vertical context + # to prevent false matches. The actual button is in the + # lower third, so do NOT click the template center. + revive_click = ( + float(revive_all[0]), + float(revive_all[1]) + + float(height) / 3.0, + ) + + print( + f"Revive All template: " + f"{revive_all}" + ) + + print( + f"Revive All click point: " + f"{revive_click}" + ) + + # -------------------------------------------------------- + # CLICK REVIVE ALL + # -------------------------------------------------------- + + a.loc = revive_click + a.click() + + # Give the revival action time to complete. + time.sleep(0.75) + + # -------------------------------------------------------- + # RETURN TO A KNOWN WORLD/CITY STATE + # -------------------------------------------------------- + + if not world_map.ensure_world( + a + ): + + print( + "FAIL: could not recover World " + "after Revive All." + ) + + return False + + # -------------------------------------------------------- + # VERIFY LOSSES ARE GONE + # -------------------------------------------------------- + + a.update() + + remaining = detect_outstanding_losses( + a, + update=False, + ) + + if remaining is not None: + + print( + f"FAIL: losses are still outstanding: " + f"{remaining}" + ) + + return False + + print( + "REVIVE ALL SUCCESS: " + "outstanding losses cleared." + ) + + return True + + + +def dismiss_all_losses( + a, + losses_location=None, + max_removals=100, + max_scrolls=8, +): + """ + Permanently remove all outstanding Citadel losses. + + Flow: + + losses HUD icon + -> Temple + -> find revive_gold + -> scroll when necessary + -> hover revive_gold + -> find row-specific permanent_removal X + -> click X + -> find/cache confirmation button + -> confirm + -> allow Temple list to reflow + -> rescan from scratch + -> repeat + + Returns True only when the losses HUD icon is confirmed gone. + """ + + # -------------------------------------------------------- + # OPEN TEMPLE + # -------------------------------------------------------- + + if losses_location is None: + a.update() + losses_location = detect_outstanding_losses( + a, + update=False, + ) + + if losses_location is None: + print( + "No outstanding losses to dismiss." + ) + return True + + # -------------------------------------------------------- + # OPEN + CONFIRM TEMPLE + # -------------------------------------------------------- + + if not open_temple( + a, + losses_location=losses_location, + ): + print( + "FAIL: could not open Temple " + "for Dismiss All." + ) + return False + + safe = a.get_ui_location( + "game_safe_location" + ) + + if safe is None: + print( + "FAIL: game_safe_location is not cached." + ) + return False + + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + time.sleep(0.20) + + removed = 0 + + # -------------------------------------------------------- + # REMOVE LOSS ROWS + # -------------------------------------------------------- + + while removed < max_removals: + + gold = None + temple_empty = False + + # ---------------------------------------------------- + # FIND NEXT LOSS ROW + # ---------------------------------------------------- + # + # Revive All is our authoritative Temple-empty signal. + # + # One screenshot drives both checks: + # + # revive_all absent + # -> no losses remain + # + # revive_all present + revive_gold visible + # -> process that row + # + # revive_all present + revive_gold not visible + # -> scroll and retry + # ---------------------------------------------------- + + for scroll_attempt in range( + max_scrolls + 1 + ): + a.update() + + region = a.get_ui_region( + "middle_center" + ) + + revive_all = a.find_in_region( + "revive_all", + region, + set_loc=False, + ) + + gold = a.find_in_region( + "revive_gold", + region, + set_loc=False, + ) + + # A visible Revive Gold row is sufficient evidence + # that losses remain, even if Revive All happened + # to miss on this frame. + if gold is not None: + break + + # Never treat one missed Revive All match as proof + # that the Temple is empty. Both loss indicators + # must be absent on a second fresh screenshot. + if revive_all is None: + print( + "Revive All and Revive Gold absent; " + "confirming Temple is empty..." + ) + + time.sleep( + 0.25 + ) + + a.update() + + confirm_region = a.get_ui_region( + "middle_center" + ) + + confirm_revive_all = ( + a.find_in_region( + "revive_all", + confirm_region, + set_loc=False, + ) + ) + + confirm_gold = ( + a.find_in_region( + "revive_gold", + confirm_region, + set_loc=False, + ) + ) + + if ( + confirm_revive_all is None + and confirm_gold is None + ): + # If we have already removed at least one + # row, the loss list may legitimately have + # become empty. + if removed > 0: + print( + "Temple empty confirmed on " + "two consecutive screenshots." + ) + + temple_empty = True + break + + # We entered this routine because the World + # HUD positively showed outstanding losses. + # Therefore an initially empty-looking Temple + # is not authoritative; the panel may still + # be opening or settling. + print( + "Losses are known outstanding, but " + "Temple controls are not ready yet. " + "Retrying..." + ) + + time.sleep( + 0.50 + ) + + continue + + print( + "Temple-empty confirmation failed; " + "loss controls are still present." + ) + + revive_all = ( + confirm_revive_all + ) + + gold = confirm_gold + + if gold is not None: + break + + if scroll_attempt >= max_scrolls: + print( + "FAIL: losses remain, but no " + "Revive Gold row could be found " + "after bounded scrolling." + ) + + return False + + print( + f"Losses remain; Revive Gold not visible. " + f"Scrolling " + f"{scroll_attempt + 1}/{max_scrolls}..." + ) + + production_scroll( + a, + amount=-4, + anchor=a.get_battle_scroll_anchor(), + ) + + if temple_empty: + break + + if gold is None: + print( + "FAIL: Temple still has losses, but " + "no removable loss row was identified." + ) + + return False + + print( + f"Revive Gold: {gold}" + ) + + # ---------------------------------------------------- + # HOVER GOLD REVIVE TO REVEAL X + # ---------------------------------------------------- + + a.input.current_position = None + a.input.current_monitor = None + + # revive_gold contains vertical context above the actual + # interactive control. Hover the lower third of the + # already-scaled template rather than its center. + gold_sample = a.get_sample( + "revive_gold" + ) + + gold_height, _ = ( + gold_sample.shape[:2] + ) + + gold_hover = ( + float(gold[0]), + float(gold[1]) + + float(gold_height) / 3.0, + ) + + a.loc = gold_hover + a.move() + + time.sleep(0.35) + + a.update() + + # Permanent Removal X position was measured against + # the reference UI. Scale both the expected offset and + # the local search region with the rendered game UI. + expected_x = ( + float(gold[0]) + + 64.0 * a.sample_scale + ) + + expected_y = ( + float(gold[1]) + - 50.0 * a.sample_scale + ) + + removal_half = ( + 40.0 * a.sample_scale + ) + + removal_size = int(round( + 80.0 * a.sample_scale + )) + + removal_region = ( + int(round( + expected_x - removal_half + )), + int(round( + expected_y - removal_half + )), + removal_size, + removal_size, + ) + + removal = a.find_in_region( + "permanent_removal", + removal_region, + set_loc=False, + ) + + if removal is None: + print( + "FAIL: Permanent Removal X " + "was not found for hovered row." + ) + return False + + print( + f"Permanent Removal: {removal}" + ) + + # ---------------------------------------------------- + # CLICK ROW X + # ---------------------------------------------------- + + a.loc = removal + a.click() + + # Move away before finding the confirmation. + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + time.sleep(0.35) + + a.update() + + # ---------------------------------------------------- + # FIND / REUSE CONFIRMATION BUTTON + # ---------------------------------------------------- + + confirmation = None + + # The confirmation dialog may animate in after the + # Permanent Removal X is clicked. Allow a few fresh + # screenshots before declaring failure. + for confirmation_attempt in range(5): + a.update() + + cached = a.get_ui_location( + "confirm_permanent_removal" + ) + + if cached is not None: + sample = a.get_sample( + "confirm_permanent_removal" + ) + + h, w = sample.shape[:2] + + margin = ( + 30.0 * a.sample_scale + ) + + region = ( + int(round( + float(cached[0]) + - w / 2 + - margin + )), + int(round( + float(cached[1]) + - h / 2 + - margin + )), + int(round( + w + margin * 2 + )), + int(round( + h + margin * 2 + )), + ) + + confirmation = ( + a.find_in_region( + "confirm_permanent_removal", + region, + set_loc=False, + ) + ) + + if confirmation is None: + confirmation = ( + a.find_in_region( + "confirm_permanent_removal", + a.get_ui_region( + "middle_center" + ), + set_loc=False, + ) + ) + + if confirmation is not None: + break + + if confirmation_attempt < 4: + time.sleep( + 0.20 + ) + + if confirmation is None: + print( + "FAIL: permanent-removal confirmation " + "was not found after retries." + ) + return False + + a.cache_ui_location( + "confirm_permanent_removal", + confirmation, + ) + + print( + f"Confirm Permanent Removal: " + f"{confirmation}" + ) + + # ---------------------------------------------------- + # CONFIRM ONE PERMANENT REMOVAL + # ---------------------------------------------------- + + a.loc = confirmation + a.click() + + removed += 1 + + print( + f"Permanent removals completed: " + f"{removed}" + ) + + # Move safe and let list collapse/reflow. + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + time.sleep(0.75) + + # IMPORTANT: + # Do not reuse row coordinates. + # The Temple list reflows after every deletion. + + if removed >= max_removals: + print( + f"FAIL: dismiss removal limit reached " + f"({max_removals})." + ) + return False + + # -------------------------------------------------------- + # VERIFY LOSSES CLEARED + # -------------------------------------------------------- + + if not world_map.ensure_world( + a + ): + print( + "FAIL: could not recover World " + "after dismissing losses." + ) + return False + + a.update() + + remaining = detect_outstanding_losses( + a, + update=False, + ) + + if remaining is not None: + print( + f"FAIL: outstanding losses remain: " + f"{remaining}" + ) + return False + + print( + f"DISMISS SUCCESS: " + f"{removed} loss row(s) permanently removed." + ) + + return True + + +# ============================================================ +# SELECTIVE SILVER LOSS DISMISSAL +# ============================================================ + +SILVER_TROOPS_ALL = "all" + +SILVER_PORTRAIT_X_OFFSET = -237.75 +SILVER_PORTRAIT_Y_OFFSET = -22.12 + +SILVER_PORTRAIT_HALF_WIDTH = 55.0 +SILVER_PORTRAIT_HALF_HEIGHT = 40.0 + +SILVER_TROOP_SCORE_MAX = 0.075 +SILVER_TROOP_MARGIN_MIN = 0.075 + +SILVER_BOTTOM_DIFF_THRESHOLD = 1.5 +SILVER_BOTTOM_CONFIRMATIONS = 2 + + +def _silver_catalog_troop_names( + catalog_path="troop_catalog.json", +): + """ + Return only troop names explicitly classified as + "Silver Troops" in troop_catalog.json. + + This is the authoritative eligibility list for permanent + removal under the dismiss_silver policy. + + Mercenaries and Monsters must never become candidates even + when they have perfectly valid unit portrait samples. + """ + + catalog_file = Path( + catalog_path + ) + + if not catalog_file.exists(): + raise FileNotFoundError( + f"Troop catalog not found: " + f"{catalog_file}" + ) + + with catalog_file.open( + "r", + encoding="utf-8", + ) as f: + catalog = json.load( + f + ) + + return sorted( + name + for name, entry in catalog.items() + if ( + entry.get( + "group" + ) + == "Silver Troops" + ) + ) + + +def _silver_crop_region( + a, + region, +): + """ + Crop a monitor-relative logical region from a.image. + """ + + x, y, width, height = region + + sx = int(round( + x * a.scale + )) + sy = int(round( + y * a.scale + )) + sw = int(round( + width * a.scale + )) + sh = int(round( + height * a.scale + )) + + return a.image[ + sy:sy + sh, + sx:sx + sw, + ] + + +def _silver_list_signature( + roi, +): + """ + Produce a small grayscale Temple image for bottom detection. + """ + + gray = cv2.cvtColor( + roi, + cv2.COLOR_BGR2GRAY, + ) + + return cv2.resize( + gray, + ( + 320, + 126, + ), + interpolation=cv2.INTER_AREA, + ) + + +def _silver_signature_difference( + before, + after, +): + return float( + np.mean( + cv2.absdiff( + before, + after, + ) + ) + ) + + +def _silver_gold_controls( + a, + region, +): + """ + Find all visible Revive Gold controls inside the Temple. + """ + + locations = a.find_multi( + "revive_gold" + ) + + if not locations: + return [] + + rx, ry, rw, rh = region + + return [ + location + for location in locations + if ( + rx <= location[0] <= rx + rw + and + ry <= location[1] <= ry + rh + ) + ] + + +def _silver_portrait_region( + a, + gold, +): + """ + Return the calibrated portrait ROI belonging to one + Revive Gold row. + + All stored geometry is reference-resolution geometry and + therefore scales through sample_scale at runtime. + """ + + center_x = ( + float(gold[0]) + + SILVER_PORTRAIT_X_OFFSET + * a.sample_scale + ) + + center_y = ( + float(gold[1]) + + SILVER_PORTRAIT_Y_OFFSET + * a.sample_scale + ) + + half_width = ( + SILVER_PORTRAIT_HALF_WIDTH + * a.sample_scale + ) + + half_height = ( + SILVER_PORTRAIT_HALF_HEIGHT + * a.sample_scale + ) + + return ( + int(round( + center_x - half_width + )), + int(round( + center_y - half_height + )), + int(round( + half_width * 2.0 + )), + int(round( + half_height * 2.0 + )), + ) + + +def _silver_classify_portrait( + a, + region, + troop_names, +): + """ + Classify one Gold row against the requested troop samples. + + Lower scores are better for the current SQDIFF matcher. + """ + + roi = _silver_crop_region( + a, + region, + ) + + if roi.size == 0: + return None + + rh, rw = roi.shape[:2] + + matches = [] + + for troop_name in troop_names: + try: + template = a.get_sample( + "units/" + troop_name + ) + except FileNotFoundError: + continue + + th, tw = template.shape[:2] + + if ( + th > rh + or tw > rw + ): + continue + + result = cv2.matchTemplate( + roi, + template, + a.method, + ) + + min_val, max_val, _, _ = ( + cv2.minMaxLoc( + result + ) + ) + + if a.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + score = float( + min_val + ) + else: + score = float( + max_val + ) + + matches.append( + ( + score, + troop_name, + ) + ) + + if not matches: + return None + + if a.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + matches.sort( + key=lambda item: item[0] + ) + else: + matches.sort( + key=lambda item: -item[0] + ) + + best_score, best_name = ( + matches[0] + ) + + second_score = None + second_name = None + margin = None + + if len(matches) >= 2: + second_score, second_name = ( + matches[1] + ) + + if a.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + margin = ( + second_score + - best_score + ) + else: + margin = ( + best_score + - second_score + ) + + return { + "best_name": best_name, + "best_score": best_score, + "second_name": second_name, + "second_score": second_score, + "margin": margin, + } + + +def _silver_find_permanent_removal( + a, + gold, +): + """ + Hover the correct Gold control and find its row-specific + Permanent Removal X. + """ + + a.input.current_position = None + a.input.current_monitor = None + + gold_sample = a.get_sample( + "revive_gold" + ) + + gold_height, _ = ( + gold_sample.shape[:2] + ) + + gold_hover = ( + float(gold[0]), + float(gold[1]) + + float(gold_height) / 3.0, + ) + + a.loc = gold_hover + a.move() + + time.sleep( + 0.20 + ) + + a.update() + + expected_x = ( + float(gold[0]) + + 64.0 * a.sample_scale + ) + + expected_y = ( + float(gold[1]) + - 50.0 * a.sample_scale + ) + + removal_half = ( + 40.0 * a.sample_scale + ) + + removal_size = int(round( + 80.0 * a.sample_scale + )) + + removal_region = ( + int(round( + expected_x - removal_half + )), + int(round( + expected_y - removal_half + )), + removal_size, + removal_size, + ) + + return a.find_in_region( + "permanent_removal", + removal_region, + set_loc=False, + ) + + +def _silver_find_confirmation( + a, +): + """ + Find the Permanent Removal confirmation with bounded retries. + """ + + for attempt in range(5): + a.update() + + confirmation = None + + cached = a.get_ui_location( + "confirm_permanent_removal" + ) + + if cached is not None: + sample = a.get_sample( + "confirm_permanent_removal" + ) + + h, w = sample.shape[:2] + + margin = ( + 30.0 * a.sample_scale + ) + + region = ( + int(round( + float(cached[0]) + - w / 2 + - margin + )), + int(round( + float(cached[1]) + - h / 2 + - margin + )), + int(round( + w + margin * 2 + )), + int(round( + h + margin * 2 + )), + ) + + confirmation = ( + a.find_in_region( + "confirm_permanent_removal", + region, + set_loc=False, + ) + ) + + if confirmation is None: + confirmation = ( + a.find_in_region( + "confirm_permanent_removal", + a.get_ui_region( + "middle_center" + ), + set_loc=False, + ) + ) + + if confirmation is not None: + return confirmation + + if attempt < 4: + time.sleep( + 0.20 + ) + + return None + + +def dismiss_silver_losses( + a, + silver_troops=SILVER_TROOPS_ALL, + losses_location=None, + max_removals=100, + max_scrolls=100, +): + """ + Permanently remove selected Gold-revival troop losses. + + silver_troops="all": + Classify against every available unit sample and dismiss + every strong/confident Gold-row troop match. + + silver_troops iterable: + Only search for the supplied troop names. This is the + intended production Citadel path and avoids unnecessary + template matching. + + IMPORTANT: + Success means the entire Temple was traversed and no more + requested silver rows were found. Other losses may remain. + """ + + # -------------------------------------------------------- + # RESOLVE TROOP FILTER + # -------------------------------------------------------- + + all_mode = ( + silver_troops + == SILVER_TROOPS_ALL + ) + + catalog_silver = set( + _silver_catalog_troop_names() + ) + + if all_mode: + troop_names = sorted( + catalog_silver + ) + else: + if silver_troops is None: + print( + "FAIL: silver_troops=None is invalid. " + "Use 'all' or provide troop names." + ) + + return { + "success": False, + "removed": 0, + "troops": [], + } + + requested = set( + silver_troops + ) + + non_silver = sorted( + requested + - catalog_silver + ) + + if non_silver: + print( + "FAIL: dismiss_silver was given troop " + "names that are not classified as " + "Silver Troops:" + ) + + for troop_name in non_silver: + print( + f" {troop_name}" + ) + + return { + "success": False, + "removed": 0, + "troops": [], + } + + troop_names = sorted( + requested + ) + + if not troop_names: + print( + "No silver troop samples requested." + ) + + return { + "success": True, + "removed": 0, + "troops": [], + } + + print() + print( + "Selective silver dismissal candidates:" + ) + + if all_mode: + print( + f" ALL catalog Silver Troops " + f"({len(troop_names)})" + ) + else: + for troop_name in troop_names: + print( + f" {troop_name}" + ) + + # -------------------------------------------------------- + # OPEN + CONFIRM TEMPLE + # -------------------------------------------------------- + + if losses_location is None: + a.update() + + losses_location = ( + detect_outstanding_losses( + a, + update=False, + ) + ) + + if losses_location is None: + print( + "No outstanding losses to process." + ) + + return { + "success": True, + "removed": 0, + "troops": [], + } + + if not open_temple( + a, + losses_location=losses_location, + ): + print( + "FAIL: could not open Temple for " + "selective Silver dismissal." + ) + + return { + "success": False, + "removed": 0, + "troops": [], + } + + safe = a.get_ui_location( + "game_safe_location" + ) + + if safe is None: + print( + "FAIL: game_safe_location is not cached." + ) + + return { + "success": False, + "removed": 0, + "troops": [], + } + + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + time.sleep( + 0.20 + ) + + removed = 0 + removed_troops = [] + + bottom_count = 0 + scroll_count = 0 + + # -------------------------------------------------------- + # TEMPLE TRAVERSAL + # -------------------------------------------------------- + + while True: + a.update() + + temple_region = ( + a.get_ui_region( + "middle_center" + ) + ) + + temple_roi = ( + _silver_crop_region( + a, + temple_region, + ) + ) + + if temple_roi.size == 0: + print( + "FAIL: Temple search region is empty." + ) + + return { + "success": False, + "removed": removed, + "troops": removed_troops, + } + + before_signature = ( + _silver_list_signature( + temple_roi + ) + ) + + gold_controls = ( + _silver_gold_controls( + a, + temple_region, + ) + ) + + target = None + + # ---------------------------------------------------- + # FIND ONE ACTIONABLE ROW + # ---------------------------------------------------- + + for gold in gold_controls: + portrait_region = ( + _silver_portrait_region( + a, + gold, + ) + ) + + result = ( + _silver_classify_portrait( + a, + portrait_region, + troop_names, + ) + ) + + if result is None: + continue + + best_name = result[ + "best_name" + ] + + best_score = result[ + "best_score" + ] + + margin = result[ + "margin" + ] + + # Absolute score is always mandatory. + if ( + best_score + > SILVER_TROOP_SCORE_MAX + ): + continue + + # In "all" mode we have the complete comparison + # library, so require the proven separation margin. + # + # In filtered production mode the actual row may be + # a merc/monster intentionally absent from our + # candidate list. The absolute score therefore does + # the safety gating. + if all_mode: + if ( + margin is None + or margin + < SILVER_TROOP_MARGIN_MIN + ): + continue + + target = { + "troop": best_name, + "score": best_score, + "margin": margin, + "gold": gold, + "portrait_region": portrait_region, + } + + break + + # ---------------------------------------------------- + # REMOVE ONE ROW, THEN COMPLETELY RESCAN + # ---------------------------------------------------- + + if target is not None: + print() + print( + f"Silver loss target: " + f"{target['troop']} " + f"score={target['score']:.6f}" + ) + + if target[ + "margin" + ] is not None: + print( + f" margin=" + f"{target['margin']:.6f}" + ) + + print( + f" Gold: " + f"{target['gold']}" + ) + + removal = ( + _silver_find_permanent_removal( + a, + target["gold"], + ) + ) + + if removal is None: + print( + "FAIL: confident silver troop was " + "identified, but Permanent Removal " + "was not found." + ) + + return { + "success": False, + "removed": removed, + "troops": removed_troops, + } + + print( + f"Permanent Removal: " + f"{removal}" + ) + + a.loc = removal + a.click() + + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + time.sleep( + 0.20 + ) + + confirmation = ( + _silver_find_confirmation( + a + ) + ) + + if confirmation is None: + print( + "FAIL: permanent-removal " + "confirmation was not found." + ) + + return { + "success": False, + "removed": removed, + "troops": removed_troops, + } + + a.cache_ui_location( + "confirm_permanent_removal", + confirmation, + ) + + print( + f"Confirm Permanent Removal: " + f"{confirmation}" + ) + + a.loc = confirmation + a.click() + + removed += 1 + + removed_troops.append( + target["troop"] + ) + + print( + f"Selective permanent removals: " + f"{removed}" + ) + + if removed >= max_removals: + print( + f"FAIL: selective removal limit " + f"reached ({max_removals})." + ) + + return { + "success": False, + "removed": removed, + "troops": removed_troops, + } + + # List reflows after every deletion. Never reuse + # any coordinates from the prior screenshot. + a.input.current_position = None + a.input.current_monitor = None + a.loc = safe + a.move() + + time.sleep( + 0.35 + ) + + bottom_count = 0 + + continue + + # ---------------------------------------------------- + # NO ACTIONABLE ROW HERE -> SCROLL + # ---------------------------------------------------- + + if scroll_count >= max_scrolls: + print( + "FAIL: selective Temple traversal " + "reached its scroll safety limit." + ) + + return { + "success": False, + "removed": removed, + "troops": removed_troops, + } + + production_scroll( + a, + amount=-4, + anchor=( + a.get_battle_scroll_anchor() + ), + focus_wait=0.05, + settle_wait=0.08, + ) + + scroll_count += 1 + + a.update() + + after_roi = ( + _silver_crop_region( + a, + temple_region, + ) + ) + + after_signature = ( + _silver_list_signature( + after_roi + ) + ) + + difference = ( + _silver_signature_difference( + before_signature, + after_signature, + ) + ) + + if ( + difference + <= SILVER_BOTTOM_DIFF_THRESHOLD + ): + bottom_count += 1 + + print( + f"Possible Temple bottom: " + f"{bottom_count}/" + f"{SILVER_BOTTOM_CONFIRMATIONS}" + ) + else: + bottom_count = 0 + + if ( + bottom_count + >= SILVER_BOTTOM_CONFIRMATIONS + ): + print() + print( + "Selective silver Temple traversal " + "complete." + ) + + print( + f"Silver rows permanently removed: " + f"{removed}" + ) + + return { + "success": True, + "removed": removed, + "troops": removed_troops, + } + + +def get_citadel_loss_policy( + citadel_type, + config_path="citadel_stacks.json", +): + """ + Return the configured loss policy for one Citadel type. + """ + + config = load_citadel_config( + config_path + ) + + entry = config.get( + citadel_type + ) + + if entry is None: + return "stop" + + return entry.get( + "loss_policy", + "stop", + ) + + +def resolve_citadel_loss_policy( + a, + config_path="citadel_stacks.json", + loss_policy_override=None, +): + """ + Resolve the loss policy for the most recently launched + Citadel. + + Command-line / GUI override wins over the Citadel's stored + configuration. With no previous Citadel type, default to + STOP. + """ + + previous_type = getattr( + a, + "last_citadel_type", + None, + ) + + if loss_policy_override is not None: + return ( + loss_policy_override, + "command-line override", + ) + + if previous_type is not None: + return ( + get_citadel_loss_policy( + previous_type, + config_path=config_path, + ), + "Citadel configuration", + ) + + return ( + "stop", + "default", + ) + + +def process_citadel_losses( + a, + losses, + policy, + policy_source="default", +): + """ + Process already-detected outstanding Citadel losses. + + Returns True only when the selected non-STOP policy + completes successfully. + + STOP intentionally returns False without touching losses. + """ + + print() + print( + f"OUTSTANDING CITADEL LOSSES DETECTED: " + f"{losses}" + ) + + if policy_source == "command-line override": + print( + f"Loss policy: {policy} " + "(command-line override)" + ) + else: + print( + f"Loss policy: {policy}" + ) + + if policy == "stop": + print( + "STOP policy active. " + "No further Citadels will be launched." + ) + + return False + + if policy == "revive": + + if not revive_all_losses( + a, + losses_location=losses, + ): + print( + "FAIL: revive policy could not " + "clear outstanding losses." + ) + + return False + + print( + "Revive policy completed. " + "Continuing Citadel automation." + ) + + return True + + if policy == "dismiss": + + if not dismiss_all_losses( + a, + losses_location=losses, + ): + print( + "FAIL: dismiss policy could not " + "clear outstanding losses." + ) + + return False + + print( + "Dismiss policy completed. " + "Continuing Citadel automation." + ) + + return True + + if policy == "dismiss_silver": + + print() + print( + "Processing mixed Citadel losses:" + ) + print( + " permanently dismiss Silver Troops" + ) + print( + " revive everything remaining" + ) + + result = dismiss_silver_losses( + a, + silver_troops=SILVER_TROOPS_ALL, + losses_location=losses, + ) + + if not result["success"]: + print( + "FAIL: dismiss_silver policy could " + "not complete Silver dismissal." + ) + + return False + + print() + print( + f"Dismiss Silver completed: " + f"{result['removed']} row(s) removed." + ) + + if result["troops"]: + print( + "Removed Silver Troops:" + ) + + for troop_name in result["troops"]: + print( + f" {troop_name}" + ) + + if not world_map.ensure_world( + a + ): + print( + "FAIL: could not recover World after " + "Silver dismissal." + ) + + return False + + a.update() + + remaining_losses = ( + detect_outstanding_losses( + a, + update=False, + ) + ) + + if remaining_losses is None: + print() + print( + "All outstanding losses were Silver." + ) + print( + "Loss processing complete." + ) + + else: + print() + print( + f"Protected losses remain: " + f"{remaining_losses}" + ) + print( + "Reviving remaining losses..." + ) + + if not revive_all_losses( + a, + losses_location=remaining_losses, + ): + print( + "FAIL: remaining protected losses " + "could not be revived." + ) + + return False + + print() + print( + "Protected losses revived." + ) + print( + "Mixed loss processing complete." + ) + + print( + "Continuing Citadel automation." + ) + + return True + + print( + f"Unknown loss policy: " + f"{policy}" + ) + + return False + + +def check_citadel_losses_while_marching( + a, + _march_location, + policy, + policy_source, +): + """ + Periodically check for and process losses while a Citadel + march is positively known to still be active. + + The generic march/Clan Help loop runs every 0.5 seconds, + but loss detection is throttled to once every 2 seconds. + + STOP never reaches this function; STOP losses remain + untouched for the normal post-march safety check. + """ + + now = time.monotonic() + + last_check = getattr( + a, + "citadel_loss_check_time", + None, + ) + + if ( + last_check is not None + and now - last_check < 2.0 + ): + return True + + a.citadel_loss_check_time = now + + losses = detect_outstanding_losses( + a, + update=False, + ) + + if losses is None: + return True + + print() + print( + "Citadel losses detected while march " + "is still active." + ) + + if not process_citadel_losses( + a, + losses, + policy, + policy_source=policy_source, + ): + return False + + # Loss handling navigates through the Temple. Make sure + # the next march poll begins from a known World state. + if not world_map.ensure_world( + a + ): + print( + "FAIL: could not recover World after " + "processing Citadel losses during march." + ) + + return False + + return True + + +def wait_for_citadel_finish( + a, + config_path="citadel_stacks.json", + loss_policy_override=None, +): + """ + Wait for the current Citadel march to finish. + + Current Citadel configuration sends all captains, so there + should not be another independent march competing with it. + + We therefore use generic march presence as the lifecycle + gate rather than identifying a specific captain. + + Returns True once the active Citadel march is confirmed gone. + """ + + if not getattr( + a, + "citadel_march_active", + False, + ): + return True + + print() + print( + "Waiting for Citadel march to finish..." + ) + + max_time = getattr( + a, + "citadel_max_speedup_time", + None, + ) + + # Give the banner time to remain stable immediately after + # MAX. This mirrors the Carter lifecycle safeguard. + grace = 3.0 + + if max_time is not None: + + elapsed = ( + time.monotonic() + - max_time + ) + + if elapsed < grace: + + time.sleep( + grace - elapsed + ) + + policy, policy_source = ( + resolve_citadel_loss_policy( + a, + config_path=config_path, + loss_policy_override=loss_policy_override, + ) + ) + + while_active = None + + a.citadel_loss_check_time = None + + # STOP means exactly that: do not touch losses while the + # march is active. The normal post-march safety check will + # detect them and stop before another Citadel is launched. + if policy != "stop": + + def while_active( + automator, + march_location, + ): + return check_citadel_losses_while_marching( + automator, + march_location, + policy, + policy_source, + ) + + if not wait_for_no_active_march( + a, + poll_wait=0.50, + consecutive_misses=2, + while_active=while_active, + ): + return False + + a.citadel_march_active = False + a.citadel_max_speedup_time = None + a.citadel_march_loc = None + + a.debug( + "Citadel march confirmed finished." + ) + + return True + + +def launch_prepared_citadel( + a, + speedup=False, +): + """ + Launch a Citadel from the already-prepared Battle screen. + + Assumptions: + - Battle screen is open + - configured troops are already entered + + Flow: + locate final March button + -> click it + -> wait for generic active march banner + + Loss handling occurs later while the launched march is + being tracked. If no active march appears here, return False. + + Returns True only when a generic active march is detected. + """ + + # -------------------------------------------------------- + # CONFIRM BATTLE SCREEN + # -------------------------------------------------------- + + if not a.await_samples( + "battle", + 2, + ): + print( + "FAIL: Battle screen not detected " + "before Citadel launch." + ) + + return False + + # -------------------------------------------------------- + # FIND FINAL MARCH BUTTON + # -------------------------------------------------------- + + a.update() + + march_button = a.find( + "march", + set_loc=False, + ) + + if march_button is None: + print( + "FAIL: final March button not found." + ) + + return False + + a.debug() + a.debug( + f"Final March button: " + f"{march_button}" + ) + + # -------------------------------------------------------- + # CLICK FINAL MARCH + # -------------------------------------------------------- + + a.loc = march_button + + a.debug( + f"Clicking final March: " + f"{march_button}" + ) + + a.click() + + # -------------------------------------------------------- + # CONFIRM GENERIC ACTIVE MARCH + # -------------------------------------------------------- + + a.debug( + "Waiting for launched Citadel march " + "to appear..." + ) + + active = wait_for_active_march( + a, + timeout=10.0, + poll_wait=0.20, + ) + + # -------------------------------------------------------- + # TRANSIENT LAUNCH-CONFIRMATION RECOVERY + # -------------------------------------------------------- + # + # Missing generic_march does not necessarily prove that the + # final March click failed. + # + # Safe recovery: + # + # Battle still open: + # retry final March exactly once + # + # Battle closed: + # NEVER click March again + # use march-speedup controls as independent evidence + # + # If launch ownership remains ambiguous, stop rather than + # risk sending another Citadel. + # -------------------------------------------------------- + + if active is None: + + print( + "Generic march banner was not detected " + "after the initial March click." + ) + + print( + "Checking whether Battle is still open..." + ) + + a.update() + + battle_still_open = ( + a.find( + "battle", + set_loc=False, + ) + is not None + ) + + # ---------------------------------------------------- + # CASE 1: BATTLE IS STILL OPEN + # ---------------------------------------------------- + + if battle_still_open: + + print( + "Battle screen is still open." + ) + + print( + "Retrying final March click exactly once..." + ) + + retry_button = a.find( + "march", + set_loc=False, + ) + + if retry_button is None: + + print( + "FAIL: Battle remains open but final " + "March button could not be found." + ) + + return False + + a.loc = retry_button + + a.debug( + f"Retry final March button: " + f"{retry_button}" + ) + + a.click() + + active = wait_for_active_march( + a, + timeout=5.0, + poll_wait=0.20, + ) + + if active is not None: + + print( + "Citadel march detected after " + "one March-click retry." + ) + + # ---------------------------------------------------- + # CASE 2: BATTLE CLOSED BUT BANNER MATCH WAS MISSED + # ---------------------------------------------------- + + if active is None: + + a.update() + + battle_still_open = ( + a.find( + "battle", + set_loc=False, + ) + is not None + ) + + # ------------------------------------------------ + # TARGET DISAPPEARED / MARCH NEVER LAUNCHED + # ------------------------------------------------ + # + # We clicked March twice and the Battle screen is + # positively still open. That means the client never + # transitioned into a launched march. + # + # This commonly happens when another player kills the + # Citadel after we opened Battle but before our march + # could be sent. + # + # Because Battle is positively identified, this is NOT + # an ambiguous launch state and there is no duplicate- + # march risk. Back out to World and let the production + # loop choose another Watchtower target. + # ------------------------------------------------ + + if battle_still_open: + + print( + "Battle screen remains open after the " + "March retry." + ) + + print( + "Citadel was not launched; target may " + "no longer be available." + ) + + print( + "Returning to World and recycling through " + "Watchtower..." + ) + + if not ensure_citadel_world_ready( + a + ): + print( + "FAIL: could not return to World after " + "the Citadel launch was rejected." + ) + + return False + + print( + "World restored. Citadel target will " + "be reselected." + ) + + return CITADEL_RETRY + + if not battle_still_open: + + print( + "Battle screen has closed." + ) + + print( + "Checking march speedup controls as " + "independent launch evidence..." + ) + + speedup_buttons = ( + a.find_multi_in_region( + "march_speedup_button", + a.get_ui_region( + "top_center" + ), + ) + ) + + if speedup_buttons: + + a.debug( + f"Recovery speedup buttons detected: " + f"{len(speedup_buttons)}" + ) + + for button in speedup_buttons: + + a.debug( + " recovery button=" + f"({float(button[0]):.1f}, " + f"{float(button[1]):.1f})" + ) + + if ( + speedup_buttons + and len(speedup_buttons) == 1 + ): + + button = speedup_buttons[0] + + active = ( + float(button[0]), + float(button[1]), + ) + + print( + "Citadel launch recovered from the " + "unique march speedup control." + ) + + a.debug( + "Recovered Citadel march row: " + f"{active}" + ) + + elif ( + speedup_buttons + and len(speedup_buttons) > 1 + ): + + print( + "WARNING: multiple march speedup " + "buttons are visible." + ) + + print( + "Citadel launch may have occurred, " + "but ownership cannot be confirmed " + "safely without the march banner." + ) + + else: + + print( + "No march speedup controls were " + "detected either." + ) + + if active is None: + + print( + "FAIL: Citadel launch state could not be " + "confirmed after March was clicked." + ) + + print( + "Stopping rather than risking a duplicate " + "Citadel launch." + ) + + return False + + a.debug() + a.debug( + f"Active march detected: " + f"{active}" + ) + + a.citadel_march_active = True + a.citadel_march_loc = active + + print( + "Citadel march launched." + ) + + # -------------------------------------------------------- + # OPTIONAL GENERIC MAX SPEEDUP + # -------------------------------------------------------- + + if speedup: + + if not open_and_max_speedup( + a, + active, + ): + print( + "WARNING: Citadel march launched, but " + "MAX speedup could not be applied." + ) + + print( + "The active march will still be tracked " + "until it finishes." + ) + + a.citadel_max_speedup_time = None + + else: + a.citadel_max_speedup_time = ( + time.monotonic() + ) + + a.debug( + "CITADEL MAX SPEEDUP CONFIRMED." + ) + + return True + + +# ============================================================ +# PRODUCTION CITADEL PROCESS +# ============================================================ + +def run_citadel_once( + a, + config_path="citadel_stacks.json", + speedup=True, + loss_policy_override=None, +): + """ + Perform one complete production Citadel attack. + + Flow: + + wait for previous Citadel march, if necessary + -> establish World / Watchtower + -> choose supported Citadel + -> authoritative Attack-panel classification + -> configured troop stack + -> final March + -> optional MAX speedup + + Returns the Citadel type on success. + + Returns None when a Citadel cannot be prepared or launched. + """ + + # -------------------------------------------------------- + # NEVER OVERLAP CITADEL ATTACKS + # -------------------------------------------------------- + + if getattr( + a, + "citadel_march_active", + False, + ): + if not wait_for_citadel_finish( + a, + config_path=config_path, + loss_policy_override=loss_policy_override, + ): + print( + "FAIL: previous Citadel march " + "could not be resolved." + ) + return None + + # -------------------------------------------------------- + # OUTSTANDING LOSSES SAFETY CHECK + # -------------------------------------------------------- + # + # At this point any previous Citadel march has finished. + # Use one fresh City/World screenshot to determine whether + # unresolved losses are present before starting another run. + # -------------------------------------------------------- + + a.update() + + losses = detect_outstanding_losses( + a, + update=False, + ) + + if losses is not None: + + policy, policy_source = ( + resolve_citadel_loss_policy( + a, + config_path=config_path, + loss_policy_override=loss_policy_override, + ) + ) + + if not process_citadel_losses( + a, + losses, + policy, + policy_source=policy_source, + ): + return None + + # -------------------------------------------------------- + # WORLD -> CONFIGURED BATTLE SCREEN + # -------------------------------------------------------- + + citadel_type = prepare_citadel_from_world( + a, + config_path=config_path, + ) + + if citadel_type is CITADEL_RETRY: + print() + print( + "Citadel preparation recycled because " + "Hero/Captains were not all ready." + ) + + return CITADEL_RETRY + + if citadel_type is None: + print( + "No runnable Citadel could be prepared." + ) + return None + + # -------------------------------------------------------- + # LAUNCH + OPTIONAL MAX + # -------------------------------------------------------- + + launch_result = launch_prepared_citadel( + a, + speedup=speedup, + ) + + if launch_result is CITADEL_RETRY: + print() + print( + f"{citadel_type} was prepared, but the target " + "was no longer launchable." + ) + print( + "Recycling to another Watchtower Citadel " + "without incrementing the completed count." + ) + + return CITADEL_RETRY + + if not launch_result: + print( + f"FAIL: {citadel_type} was prepared " + "but could not be launched." + ) + + return None + + a.last_citadel_type = citadel_type + + print() + print( + "============================================================" + ) + print( + "CITADEL RUN SUCCESS" + ) + print( + "============================================================" + ) + print() + print( + f"Citadel: {citadel_type}" + ) + + if speedup: + print( + "MAX speedup applied." + ) + + return citadel_type + + +def run_citadels( + a, + config_path="citadel_stacks.json", + speedup=True, + max_runs=None, + loss_policy_override=None, +): + """ + Production Citadel loop. + + Repeatedly run configured Citadels until: + + - no runnable Citadel can be prepared, + - a launch/recovery step fails, + - max_runs is reached, or + - the process is interrupted. + + max_runs=None means no fixed run limit. + + Returns the number of successfully launched Citadels. + """ + + completed = 0 + + print() + print( + "============================================================" + ) + print( + "=== PRODUCTION CITADEL LOOP ===" + ) + print( + "============================================================" + ) + print() + + while True: + + # ---------------------------------------------------- + # OPTIONAL RUN LIMIT + # ---------------------------------------------------- + + if ( + max_runs is not None + and completed >= max_runs + ): + print() + print( + f"Citadel run limit reached: " + f"{completed}" + ) + break + + print() + print( + "------------------------------------------------------------" + ) + print( + f"Starting Citadel run " + f"{completed + 1}" + ) + print( + "------------------------------------------------------------" + ) + print() + + result = run_citadel_once( + a, + config_path=config_path, + speedup=speedup, + loss_policy_override=loss_policy_override, + ) + + if result is CITADEL_RETRY: + print() + print( + "Commander readiness prevented this launch." + ) + + print( + "Recycling to another Watchtower Citadel " + "without incrementing the completed count." + ) + + continue + + if result is None: + print() + print( + "Citadel production loop stopping: " + "no successful Citadel launch." + ) + break + + completed += 1 + + print() + print( + f"Completed Citadel launches: " + f"{completed}" + ) + + # Do not explicitly wait here. + # + # The NEXT run enters run_citadel_once(), sees + # citadel_march_active, and waits for completion before + # allowing another Citadel to start. + + + print() + print( + "============================================================" + ) + print( + "=== CITADEL LOOP FINISHED ===" + ) + print( + "============================================================" + ) + print() + print( + f"Successful Citadel launches: " + f"{completed}" + ) + + return completed + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Run Total Battle Citadel automation." + ) + ) + + parser.add_argument( + "--config", + default="citadel_stacks.json", + help=( + "Citadel stack configuration file " + "(default: citadel_stacks.json)" + ), + ) + + parser.add_argument( + "--count", + type=int, + default=None, + help=( + "Stop after this many successful Citadel launches. " + "Default is to continue until no runnable Citadel " + "is available or a step fails." + ), + ) + + parser.add_argument( + "--no-speedup", + action="store_true", + help=( + "Launch Citadels without applying MAX march speedup." + ), + ) + + parser.add_argument( + "--loss", + choices=( + "stop", + "revive", + "delete", + "silver", + ), + default=None, + help=( + "Override configured Citadel loss handling for this " + "run: stop, revive, delete all losses, or delete " + "Silver Troops and revive remaining protected losses." + ), + ) + + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Show detailed diagnostic output.", + ) + + args = parser.parse_args() + + if ( + args.count is not None + and args.count < 1 + ): + parser.error( + "--count must be at least 1" + ) + + loss_policy_override = None + + if args.loss is not None: + loss_policy_override = { + "stop": "stop", + "revive": "revive", + "delete": "dismiss", + "silver": "dismiss_silver", + }[args.loss] + + print() + print( + "Citadel loss policy override enabled:" + ) + print( + f" --loss {args.loss}" + ) + print( + f" internal policy: " + f"{loss_policy_override}" + ) + + a = Automator( + verbose=args.verbose, + ) + + try: + run_citadels( + a, + config_path=args.config, + speedup=not args.no_speedup, + max_runs=args.count, + loss_policy_override=loss_policy_override, + ) + + except KeyboardInterrupt: + print() + print( + "Citadel automation interrupted." + ) + + finally: + a.input.close() + + +if __name__ == "__main__": + main() diff --git a/citadel_stack_gui.py b/citadel_stack_gui.py new file mode 100755 index 0000000..e9b8d25 --- /dev/null +++ b/citadel_stack_gui.py @@ -0,0 +1,1512 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import json +import re +import tkinter as tk +from tkinter import messagebox +from tkinter import ttk + + +BASE_DIR = Path(__file__).resolve().parent + +CONFIG_PATH = ( + BASE_DIR + / "citadel_stacks.json" +) + +TROOP_CATALOG_PATH = ( + BASE_DIR + / "troop_catalog.json" +) + +DESKTOP_UNITS_DIR = ( + BASE_DIR + / "samples_desktop" + / "units" +) + +STANDARD_UNITS_DIR = ( + BASE_DIR + / "samples" + / "units" +) + + +LOSS_POLICIES = { + "stop": "Stop", + "revive": "Revive", + "dismiss": "Dismiss", + "dismiss_silver": ( + "Dismiss Silver / Revive Protected" + ), +} + + +def pretty_name(name): + """ + Convert: + heavy-knight7 + into: + Heavy Knight 7 + """ + + text = name.replace( + "-", + " ", + ) + + text = text.replace( + "_", + " ", + ) + + text = re.sub( + r"([A-Za-z])(\d+)$", + r"\1 \2", + text, + ) + + return text.title() + + +def pretty_citadel(name): + """ + Convert: + cursed_25 + into: + Cursed 25 + """ + + return pretty_name( + name + ) + + +def load_json( + path, + default=None, +): + if not path.exists(): + if default is not None: + return default + + raise FileNotFoundError( + path + ) + + with path.open( + "r", + encoding="utf-8", + ) as f: + return json.load( + f + ) + + +def save_json( + path, + data, +): + path.parent.mkdir( + parents=True, + exist_ok=True, + ) + + path.write_text( + json.dumps( + data, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def load_or_create_troop_catalog( + discovered, +): + """ + Load troop display configuration. + + Catalog fields: + enabled + order + display_name + group + + Newly discovered troop samples are added automatically. + """ + + if TROOP_CATALOG_PATH.exists(): + catalog = load_json( + TROOP_CATALOG_PATH, + default={}, + ) + else: + catalog = {} + + changed = False + + existing_orders = [ + int( + entry.get( + "order", + 0, + ) + ) + for entry in catalog.values() + if isinstance( + entry, + dict, + ) + ] + + next_order = ( + max( + existing_orders, + default=0, + ) + + 10 + ) + + for troop_name in discovered: + + if troop_name not in catalog: + catalog[ + troop_name + ] = { + "enabled": True, + "order": next_order, + "display_name": pretty_name( + troop_name + ), + "group": "Other", + } + + next_order += 10 + changed = True + continue + + entry = catalog[ + troop_name + ] + + if not isinstance( + entry, + dict, + ): + catalog[ + troop_name + ] = { + "enabled": True, + "order": next_order, + "display_name": pretty_name( + troop_name + ), + "group": "Other", + } + + next_order += 10 + changed = True + continue + + if "enabled" not in entry: + entry[ + "enabled" + ] = True + changed = True + + if "order" not in entry: + entry[ + "order" + ] = next_order + next_order += 10 + changed = True + + if "display_name" not in entry: + entry[ + "display_name" + ] = pretty_name( + troop_name + ) + changed = True + + if "group" not in entry: + entry[ + "group" + ] = "Other" + changed = True + + if ( + changed + or not TROOP_CATALOG_PATH.exists() + ): + save_json( + TROOP_CATALOG_PATH, + catalog, + ) + + return catalog + + +def apply_troop_catalog( + discovered, + catalog, +): + """ + Filter and order discovered troop samples. + + Sort order: + group + order + display name + """ + + visible = [] + + for ( + troop_name, + image_path, + ) in discovered.items(): + + entry = catalog.get( + troop_name, + {}, + ) + + if not entry.get( + "enabled", + True, + ): + continue + + group = str( + entry.get( + "group", + "Other", + ) + ).strip() + + if not group: + group = "Other" + + display_name = str( + entry.get( + "display_name", + pretty_name( + troop_name + ), + ) + ).strip() + + if not display_name: + display_name = pretty_name( + troop_name + ) + + order = int( + entry.get( + "order", + 999999, + ) + ) + + visible.append( + ( + group.lower(), + order, + display_name.lower(), + troop_name, + image_path, + ) + ) + + visible.sort() + + return { + troop_name: image_path + for ( + group, + order, + display_name, + troop_name, + image_path, + ) in visible + } + + +def discover_troops(): + """ + Return: + { + troop_name: image_path + } + + Desktop samples take precedence. + """ + + troops = {} + + if STANDARD_UNITS_DIR.exists(): + for path in sorted( + STANDARD_UNITS_DIR.glob( + "*.png" + ) + ): + troops[ + path.stem + ] = path + + if DESKTOP_UNITS_DIR.exists(): + for path in sorted( + DESKTOP_UNITS_DIR.glob( + "*.png" + ) + ): + troops[ + path.stem + ] = path + + return dict( + sorted( + troops.items(), + key=lambda item: pretty_name( + item[0] + ).lower(), + ) + ) + + +class ScrollableFrame( + ttk.Frame +): + def __init__( + self, + parent, + *args, + **kwargs, + ): + super().__init__( + parent, + *args, + **kwargs, + ) + + self.canvas = tk.Canvas( + self, + highlightthickness=0, + ) + + self.scrollbar = ( + ttk.Scrollbar( + self, + orient="vertical", + command=self.canvas.yview, + ) + ) + + self.inner = ttk.Frame( + self.canvas + ) + + self.inner_window = ( + self.canvas.create_window( + ( + 0, + 0, + ), + window=self.inner, + anchor="nw", + ) + ) + + self.canvas.configure( + yscrollcommand=( + self.scrollbar.set + ) + ) + + self.canvas.grid( + row=0, + column=0, + sticky="nsew", + ) + + self.scrollbar.grid( + row=0, + column=1, + sticky="ns", + ) + + self.rowconfigure( + 0, + weight=1, + ) + + self.columnconfigure( + 0, + weight=1, + ) + + self.inner.bind( + "", + self._on_inner_configure, + ) + + self.canvas.bind( + "", + self._on_canvas_configure, + ) + + # Tk wheel events. + self.canvas.bind_all( + "", + self._on_mousewheel, + ) + + self.canvas.bind_all( + "", + self._on_linux_wheel_up, + ) + + self.canvas.bind_all( + "", + self._on_linux_wheel_down, + ) + + def _on_inner_configure( + self, + event, + ): + self.canvas.configure( + scrollregion=( + self.canvas.bbox( + "all" + ) + ) + ) + + def _on_canvas_configure( + self, + event, + ): + self.canvas.itemconfigure( + self.inner_window, + width=event.width, + ) + + def _on_mousewheel( + self, + event, + ): + delta = int( + -event.delta / 120 + ) + + if delta: + self.canvas.yview_scroll( + delta, + "units", + ) + + def _on_linux_wheel_up( + self, + event, + ): + self.canvas.yview_scroll( + -3, + "units", + ) + + def _on_linux_wheel_down( + self, + event, + ): + self.canvas.yview_scroll( + 3, + "units", + ) + + +class CitadelStackEditor: + def __init__( + self, + root, + ): + self.root = root + + self.root.title( + "TBA Citadel Stack Editor" + ) + + self.root.geometry( + "900x850" + ) + + self.root.minsize( + 700, + 600, + ) + + self.config = load_json( + CONFIG_PATH + ) + + discovered_troops = ( + discover_troops() + ) + + self.troop_catalog = ( + load_or_create_troop_catalog( + discovered_troops + ) + ) + + self.troop_images = ( + apply_troop_catalog( + discovered_troops, + self.troop_catalog, + ) + ) + + if not self.config: + raise RuntimeError( + "citadel_stacks.json " + "contains no Citadels." + ) + + if not self.troop_images: + raise RuntimeError( + "No troop PNG samples " + "were found." + ) + + self.current_citadel = None + + self.quantity_vars = {} + + # Keep PhotoImage objects alive. + self.photo_images = {} + + self.dirty = False + + self._loading = False + + self._build_ui() + + first = sorted( + self.config.keys() + )[0] + + self.citadel_var.set( + first + ) + + self.load_citadel( + first + ) + + self.root.protocol( + "WM_DELETE_WINDOW", + self.close_window, + ) + + # ======================================================== + # UI + # ======================================================== + + def _build_ui( + self, + ): + main = ttk.Frame( + self.root, + padding=12, + ) + + main.pack( + fill="both", + expand=True, + ) + + main.columnconfigure( + 0, + weight=1, + ) + + main.rowconfigure( + 2, + weight=1, + ) + + # ---------------------------------------------------- + # HEADER + # ---------------------------------------------------- + + title = ttk.Label( + main, + text=( + "Citadel Troop Stack " + "Configuration" + ), + font=( + "", + 16, + "bold", + ), + ) + + title.grid( + row=0, + column=0, + sticky="w", + pady=( + 0, + 12, + ), + ) + + # ---------------------------------------------------- + # CITADEL OPTIONS + # ---------------------------------------------------- + + options = ttk.LabelFrame( + main, + text="Citadel", + padding=10, + ) + + options.grid( + row=1, + column=0, + sticky="ew", + pady=( + 0, + 10, + ), + ) + + options.columnconfigure( + 1, + weight=1, + ) + + ttk.Label( + options, + text="Citadel:", + ).grid( + row=0, + column=0, + sticky="w", + padx=( + 0, + 8, + ), + ) + + self.citadel_var = ( + tk.StringVar() + ) + + self.citadel_combo = ( + ttk.Combobox( + options, + textvariable=( + self.citadel_var + ), + state="readonly", + values=sorted( + self.config.keys() + ), + width=24, + ) + ) + + self.citadel_combo.grid( + row=0, + column=1, + sticky="w", + ) + + self.citadel_combo.bind( + "<>", + self.on_citadel_changed, + ) + + self.enabled_var = ( + tk.BooleanVar( + value=True + ) + ) + + self.enabled_check = ( + ttk.Checkbutton( + options, + text="Enabled", + variable=( + self.enabled_var + ), + command=( + self.mark_dirty + ), + ) + ) + + self.enabled_check.grid( + row=0, + column=2, + sticky="e", + padx=( + 20, + 0, + ), + ) + + # ---------------------------------------------------- + # TROOPS + # ---------------------------------------------------- + + troop_box = ttk.LabelFrame( + main, + text="Troop Stack", + padding=8, + ) + + troop_box.grid( + row=2, + column=0, + sticky="nsew", + ) + + troop_box.rowconfigure( + 0, + weight=1, + ) + + troop_box.columnconfigure( + 0, + weight=1, + ) + + self.scroll_frame = ( + ScrollableFrame( + troop_box + ) + ) + + self.scroll_frame.grid( + row=0, + column=0, + sticky="nsew", + ) + + self._build_troop_rows() + + # ---------------------------------------------------- + # BOTTOM OPTIONS + # ---------------------------------------------------- + + bottom = ttk.Frame( + main + ) + + bottom.grid( + row=3, + column=0, + sticky="ew", + pady=( + 12, + 0, + ), + ) + + bottom.columnconfigure( + 0, + weight=1, + ) + + loss_box = ttk.LabelFrame( + bottom, + text="Loss Policy", + padding=8, + ) + + loss_box.grid( + row=0, + column=0, + sticky="w", + ) + + self.loss_policy_var = ( + tk.StringVar( + value="stop" + ) + ) + + column = 0 + + for value, label in ( + LOSS_POLICIES.items() + ): + ttk.Radiobutton( + loss_box, + text=label, + value=value, + variable=( + self.loss_policy_var + ), + command=( + self.mark_dirty + ), + ).grid( + row=0, + column=column, + padx=( + 0, + 15, + ), + sticky="w", + ) + + column += 1 + + # ---------------------------------------------------- + # STATUS + # ---------------------------------------------------- + + self.status_var = ( + tk.StringVar( + value="" + ) + ) + + status = ttk.Label( + bottom, + textvariable=( + self.status_var + ), + ) + + status.grid( + row=1, + column=0, + sticky="w", + pady=( + 8, + 0, + ), + ) + + # ---------------------------------------------------- + # BUTTONS + # ---------------------------------------------------- + + buttons = ttk.Frame( + bottom + ) + + buttons.grid( + row=0, + column=1, + rowspan=2, + sticky="se", + padx=( + 20, + 0, + ), + ) + + ttk.Button( + buttons, + text="Cancel", + command=( + self.cancel_changes + ), + ).grid( + row=0, + column=0, + padx=( + 0, + 8, + ), + ) + + ttk.Button( + buttons, + text="Save", + command=( + self.save_current + ), + ).grid( + row=0, + column=1, + ) + + def _build_troop_rows( + self, + ): + parent = ( + self.scroll_frame.inner + ) + + parent.columnconfigure( + 1, + weight=1, + ) + + ttk.Label( + parent, + text="Unit", + font=( + "", + 10, + "bold", + ), + ).grid( + row=0, + column=1, + sticky="w", + padx=8, + pady=6, + ) + + ttk.Label( + parent, + text="Quantity", + font=( + "", + 10, + "bold", + ), + ).grid( + row=0, + column=2, + sticky="w", + padx=8, + pady=6, + ) + + row = 1 + current_group = None + + for ( + troop_name, + image_path, + ) in self.troop_images.items(): + + catalog_entry = ( + self.troop_catalog.get( + troop_name, + {}, + ) + ) + + group = str( + catalog_entry.get( + "group", + "Other", + ) + ).strip() + + if not group: + group = "Other" + + if group != current_group: + current_group = group + + ttk.Label( + parent, + text=current_group, + font=( + "", + 11, + "bold", + ), + ).grid( + row=row, + column=0, + columnspan=3, + sticky="w", + padx=8, + pady=( + 12, + 6, + ), + ) + + row += 1 + + # ----------------------------------------------- + # IMAGE + # ----------------------------------------------- + + try: + image = tk.PhotoImage( + file=str( + image_path + ) + ) + + # Keep images reasonably compact. + max_dimension = max( + image.width(), + image.height(), + ) + + if max_dimension > 70: + factor = max( + 1, + int( + max_dimension + / 60 + ), + ) + + image = image.subsample( + factor, + factor, + ) + + self.photo_images[ + troop_name + ] = image + + image_label = ttk.Label( + parent, + image=image, + ) + + except tk.TclError: + image_label = ttk.Label( + parent, + text="[image]", + ) + + image_label.grid( + row=row, + column=0, + padx=8, + pady=4, + ) + + # ----------------------------------------------- + # NAME + # ----------------------------------------------- + + catalog_entry = ( + self.troop_catalog.get( + troop_name, + {}, + ) + ) + + display_name = ( + catalog_entry.get( + "display_name", + pretty_name( + troop_name + ), + ) + ) + + ttk.Label( + parent, + text=display_name, + ).grid( + row=row, + column=1, + sticky="w", + padx=8, + pady=4, + ) + + # ----------------------------------------------- + # QUANTITY + # ----------------------------------------------- + + var = tk.StringVar() + + var.trace_add( + "write", + self._quantity_changed, + ) + + self.quantity_vars[ + troop_name + ] = var + + entry = ttk.Entry( + parent, + textvariable=var, + width=14, + ) + + entry.grid( + row=row, + column=2, + sticky="w", + padx=8, + pady=4, + ) + + ttk.Separator( + parent, + orient="horizontal", + ).grid( + row=row + 1, + column=0, + columnspan=3, + sticky="ew", + padx=4, + ) + + row += 2 + + # ======================================================== + # STATE + # ======================================================== + + def _quantity_changed( + self, + *args, + ): + self.mark_dirty() + + def mark_dirty( + self, + ): + """ + Mark the current Citadel stack as having unsaved changes. + + While dirty, lock the Citadel selector so the user must + explicitly Save or Cancel before switching configurations. + + This deliberately avoids modal Unsaved Changes dialogs. + """ + + if self._loading: + return + + self.dirty = True + + self.citadel_combo.configure( + state="disabled" + ) + + self.status_var.set( + "Unsaved changes — Save or Cancel" + ) + + + def on_citadel_changed( + self, + event=None, + ): + """ + Load the newly selected Citadel. + + The selector is disabled whenever the current Citadel has + unsaved changes, so no modal confirmation is necessary. + """ + + if self._loading: + return + + new_citadel = ( + self.citadel_var.get() + ) + + if not new_citadel: + return + + if ( + new_citadel + == self.current_citadel + ): + return + + self.load_citadel( + new_citadel + ) + + + def load_citadel( + self, + citadel, + ): + if citadel not in self.config: + return + + self._loading = True + + try: + self.current_citadel = ( + citadel + ) + + entry = self.config[ + citadel + ] + + self.enabled_var.set( + bool( + entry.get( + "enabled", + True, + ) + ) + ) + + policy = entry.get( + "loss_policy", + "stop", + ) + + # Preserve the policy already stored in the + # configuration. Do not silently change an + # unfamiliar/new policy to "stop". + self.loss_policy_var.set( + policy + ) + + troop_path = ( + BASE_DIR + / entry[ + "troops_file" + ] + ) + + troops = load_json( + troop_path, + default={}, + ) + + for ( + troop_name, + variable, + ) in self.quantity_vars.items(): + + amount = troops.get( + troop_name, + "", + ) + + if ( + amount is None + or amount == 0 + ): + value = "" + else: + value = str( + amount + ) + + variable.set( + value + ) + + self.dirty = False + + self.citadel_combo.configure( + state="readonly" + ) + + self.status_var.set( + "Loaded " + + pretty_citadel( + citadel + ) + ) + + self.scroll_frame.canvas.yview_moveto( + 0 + ) + + finally: + self._loading = False + + # ======================================================== + # SAVE / CANCEL + # ======================================================== + + def collect_troops( + self, + ): + troops = {} + + errors = [] + + for ( + troop_name, + variable, + ) in self.quantity_vars.items(): + + raw = variable.get().strip() + + if not raw: + continue + + try: + amount = int( + raw.replace( + ",", + "", + ) + ) + + except ValueError: + errors.append( + f"{pretty_name(troop_name)}: " + f"'{raw}' is not a valid number" + ) + continue + + if amount < 0: + errors.append( + f"{pretty_name(troop_name)}: " + "quantity cannot be negative" + ) + continue + + if amount == 0: + continue + + troops[ + troop_name + ] = amount + + if errors: + self.status_var.set( + "Invalid quantity: " + + errors[0] + ) + + return None + + return troops + + def save_current( + self, + ): + citadel = ( + self.current_citadel + ) + + if not citadel: + return False + + troops = ( + self.collect_troops() + ) + + if troops is None: + return False + + entry = self.config[ + citadel + ] + + entry[ + "enabled" + ] = bool( + self.enabled_var.get() + ) + + entry[ + "loss_policy" + ] = ( + self.loss_policy_var.get() + ) + + troop_path = ( + BASE_DIR + / entry[ + "troops_file" + ] + ) + + save_json( + troop_path, + troops, + ) + + save_json( + CONFIG_PATH, + self.config, + ) + + self.dirty = False + + self.citadel_combo.configure( + state="readonly" + ) + + self.status_var.set( + "Saved " + + pretty_citadel( + citadel + ) + ) + + return True + + def cancel_changes( + self, + ): + if not self.current_citadel: + return + + self.load_citadel( + self.current_citadel + ) + + self.status_var.set( + "Changes discarded" + ) + + def close_window( + self, + ): + """ + Close only when there are no unsaved changes. + + No modal popup is created. The user must explicitly + Save or Cancel before closing a dirty configuration. + """ + + if self.dirty: + self.status_var.set( + "Unsaved changes — Save or Cancel before closing" + ) + + return + + self.root.destroy() + + + + +def main(): + root = tk.Tk() + + try: + CitadelStackEditor( + root + ) + + except Exception as exc: + root.withdraw() + + messagebox.showerror( + "Citadel Stack Editor", + str(exc), + ) + + root.destroy() + raise + + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/citadel_stack_tool.py b/citadel_stack_tool.py new file mode 100755 index 0000000..438d44d --- /dev/null +++ b/citadel_stack_tool.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import argparse +import json +import sys + + +CONFIG_PATH = Path( + "citadel_stacks.json" +) + + +def load_config(): + if not CONFIG_PATH.exists(): + raise FileNotFoundError( + f"Missing {CONFIG_PATH}" + ) + + return json.loads( + CONFIG_PATH.read_text() + ) + + +def save_json(path, data): + path.write_text( + json.dumps( + data, + indent=2, + ) + + "\n" + ) + + +def get_entry(config, citadel): + entry = config.get( + citadel + ) + + if entry is None: + raise KeyError( + f"Unknown Citadel: {citadel}" + ) + + return entry + + +def troop_file_for(entry): + return Path( + entry[ + "troops_file" + ] + ) + + +def load_troops(entry): + path = troop_file_for( + entry + ) + + if not path.exists(): + return {} + + return json.loads( + path.read_text() + ) + + +def cmd_list(config, args): + for name in sorted(config): + entry = config[name] + + print( + f"{name:12s} " + f"enabled={entry.get('enabled', False)} " + f"policy={entry.get('loss_policy', 'stop')} " + f"file={entry.get('troops_file')}" + ) + + +def cmd_show(config, args): + entry = get_entry( + config, + args.citadel, + ) + + troops = load_troops( + entry + ) + + print( + f"Citadel: {args.citadel}" + ) + + print( + f"Enabled: " + f"{entry.get('enabled', False)}" + ) + + print( + f"Loss policy: " + f"{entry.get('loss_policy', 'stop')}" + ) + + print( + f"Troop file: " + f"{entry['troops_file']}" + ) + + print() + + if not troops: + print( + "No troops configured." + ) + return + + for name, amount in troops.items(): + print( + f"{name:25s} " + f"{amount}" + ) + + +def cmd_set(config, args): + entry = get_entry( + config, + args.citadel, + ) + + troops = load_troops( + entry + ) + + troops[ + args.troop + ] = args.amount + + path = troop_file_for( + entry + ) + + path.parent.mkdir( + parents=True, + exist_ok=True, + ) + + save_json( + path, + troops, + ) + + print( + f"{args.citadel}: " + f"{args.troop} = " + f"{args.amount}" + ) + + +def cmd_remove(config, args): + entry = get_entry( + config, + args.citadel, + ) + + troops = load_troops( + entry + ) + + if args.troop not in troops: + print( + f"{args.troop} is not configured." + ) + return + + del troops[ + args.troop + ] + + save_json( + troop_file_for(entry), + troops, + ) + + print( + f"Removed {args.troop} " + f"from {args.citadel}." + ) + + +def cmd_policy(config, args): + entry = get_entry( + config, + args.citadel, + ) + + entry[ + "loss_policy" + ] = args.policy + + save_json( + CONFIG_PATH, + config, + ) + + print( + f"{args.citadel} loss policy = " + f"{args.policy}" + ) + + +def cmd_enable(config, args): + entry = get_entry( + config, + args.citadel, + ) + + entry[ + "enabled" + ] = args.value == "on" + + save_json( + CONFIG_PATH, + config, + ) + + print( + f"{args.citadel} enabled = " + f"{entry['enabled']}" + ) + + +def cmd_validate(config, args): + errors = [] + + valid_policies = { + "stop", + "revive", + "dismiss", + } + + for citadel, entry in config.items(): + + if "troops_file" not in entry: + errors.append( + f"{citadel}: missing troops_file" + ) + continue + + policy = entry.get( + "loss_policy", + "stop", + ) + + if policy not in valid_policies: + errors.append( + f"{citadel}: invalid loss policy " + f"{policy}" + ) + + path = troop_file_for( + entry + ) + + if not path.exists(): + errors.append( + f"{citadel}: missing troop file " + f"{path}" + ) + continue + + try: + troops = json.loads( + path.read_text() + ) + + except Exception as exc: + errors.append( + f"{citadel}: invalid JSON: " + f"{exc}" + ) + continue + + if not isinstance( + troops, + dict, + ): + errors.append( + f"{citadel}: troop file must " + f"contain a JSON object" + ) + continue + + for troop, amount in troops.items(): + + if ( + not isinstance( + amount, + int, + ) + or amount <= 0 + ): + errors.append( + f"{citadel}: " + f"{troop} has invalid " + f"amount {amount}" + ) + + sample_desktop = ( + Path( + "samples_desktop/units" + ) + / f"{troop}.png" + ) + + sample_standard = ( + Path( + "samples/units" + ) + / f"{troop}.png" + ) + + if not ( + sample_desktop.exists() + or sample_standard.exists() + ): + errors.append( + f"{citadel}: " + f"missing troop sample " + f"for {troop}" + ) + + if errors: + print( + "VALIDATION FAILED" + ) + + print() + + for error in errors: + print( + f"- {error}" + ) + + return 1 + + print( + "VALIDATION PASS" + ) + + return 0 + + +parser = argparse.ArgumentParser() + +sub = parser.add_subparsers( + dest="command", + required=True, +) + + +p = sub.add_parser( + "list" +) +p.set_defaults( + func=cmd_list +) + + +p = sub.add_parser( + "show" +) +p.add_argument( + "citadel" +) +p.set_defaults( + func=cmd_show +) + + +p = sub.add_parser( + "set" +) +p.add_argument( + "citadel" +) +p.add_argument( + "troop" +) +p.add_argument( + "amount", + type=int, +) +p.set_defaults( + func=cmd_set +) + + +p = sub.add_parser( + "remove" +) +p.add_argument( + "citadel" +) +p.add_argument( + "troop" +) +p.set_defaults( + func=cmd_remove +) + + +p = sub.add_parser( + "policy" +) +p.add_argument( + "citadel" +) +p.add_argument( + "policy", + choices=[ + "stop", + "revive", + "dismiss", + ], +) +p.set_defaults( + func=cmd_policy +) + + +p = sub.add_parser( + "enable" +) +p.add_argument( + "citadel" +) +p.add_argument( + "value", + choices=[ + "on", + "off", + ], +) +p.set_defaults( + func=cmd_enable +) + + +p = sub.add_parser( + "validate" +) +p.set_defaults( + func=cmd_validate +) + + +args = parser.parse_args() + +config = load_config() + +result = args.func( + config, + args, +) + +if isinstance( + result, + int, +): + sys.exit( + result + ) diff --git a/citadel_stacks.json b/citadel_stacks.json new file mode 100644 index 0000000..81b68c2 --- /dev/null +++ b/citadel_stacks.json @@ -0,0 +1,27 @@ +{ + "cursed_20": { + "enabled": true, + "troops_file": "citadel_troops/cursed_20.json", + "loss_policy": "stop" + }, + "cursed_25": { + "enabled": true, + "troops_file": "citadel_troops/cursed_25.json", + "loss_policy": "stop" + }, + "elven_20": { + "enabled": true, + "troops_file": "citadel_troops/elven_20.json", + "loss_policy": "stop" + }, + "elven_25": { + "enabled": true, + "troops_file": "citadel_troops/elven_25.json", + "loss_policy": "stop" + }, + "elven_30": { + "enabled": true, + "troops_file": "citadel_troops/elven_30.json", + "loss_policy": "revive" + } +} diff --git a/citadel_troops/cursed_20.json b/citadel_troops/cursed_20.json new file mode 100644 index 0000000..6cc3c94 --- /dev/null +++ b/citadel_troops/cursed_20.json @@ -0,0 +1,10 @@ +{ + "wyvern": 5, + "jago2": 10, + "warregal2": 10, + "fire-phoenix2": 5, + "fire-phoenix1": 6, + "josephine1": 5, + "ballistae7": 10, + "ballistae6": 15 +} diff --git a/citadel_troops/cursed_25.json b/citadel_troops/cursed_25.json new file mode 100644 index 0000000..d650016 --- /dev/null +++ b/citadel_troops/cursed_25.json @@ -0,0 +1,17 @@ +{ + "kraken2": 1, + "fire-phoenix2": 3, + "devastator2": 1, + "trickster2": 1, + "kraken1": 1, + "fire-phoenix1": 3, + "devastator1": 1, + "trickster1": 1, + "royal-lion1": 20, + "griffin7": 30, + "griffin6": 40, + "josephine1": 120, + "griffin5": 60, + "ballistae7": 200, + "vulture7": 500 +} diff --git a/citadel_troops/elven_20.json b/citadel_troops/elven_20.json new file mode 100644 index 0000000..0e117d7 --- /dev/null +++ b/citadel_troops/elven_20.json @@ -0,0 +1,10 @@ +{ + "wyvern": 4, + "jago2": 5, + "warregal2": 5, + "fire-phoenix2": 5, + "fire-phoenix1": 5, + "josephine1": 3, + "ballistae7": 5, + "ballistae6": 10 +} diff --git a/citadel_troops/elven_25.json b/citadel_troops/elven_25.json new file mode 100644 index 0000000..1963a1d --- /dev/null +++ b/citadel_troops/elven_25.json @@ -0,0 +1,17 @@ +{ + "wyvern": 2, + "fire-phoenix2": 3, + "devastator2": 1, + "trickster2": 1, + "fire-phoenix1": 2, + "devastator1": 1, + "trickster1": 1, + "corax1": 29, + "royal-lion1": 29, + "griffin7": 51, + "griffin6": 91, + "josephine1": 50, + "griffin5": 173, + "ballistae7": 100, + "vulture7": 1090 +} diff --git a/citadel_troops/elven_30.json b/citadel_troops/elven_30.json new file mode 100644 index 0000000..1209028 --- /dev/null +++ b/citadel_troops/elven_30.json @@ -0,0 +1,10 @@ +{ + "fire-phoenix2": 12, + "fire-phoenix1": 11, + "corax1": 5000, + "royal-lion1": 144, + "griffin7": 219, + "griffin6": 391, + "griffin5": 744, + "catapult_4": 5100 +} diff --git a/clan.py b/clan.py new file mode 100644 index 0000000..0debdec --- /dev/null +++ b/clan.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 + + +def click_help_if_available( + a, + update=False, + buffer=30, + retries=3, +): + """ + Click Clan Help if it is available. + + The Help/hand control has a static HUD location shared by + City and World, but its availability is dynamic. + + Windows occasionally ignores an otherwise correctly + positioned click on this control. Use a small targeted + pointer jiggle and positively confirm that the hand + disappeared after clicking. + + The caller may pass update=False when it has already taken + a fresh screenshot. This is the normal march-wait path and + avoids an unnecessary initial screenshot. + + Returns: + True if Help was found and successfully clicked + False if Help was not available or could not be clicked + """ + + import time + + if update: + a.update() + + # -------------------------------------------------------- + # LOCATE HELP + # -------------------------------------------------------- + + cached = a.get_ui_location( + "clan_help" + ) + + if cached is not None: + + sample = a.get_sample( + "hand" + ) + + height, width = ( + sample.shape[:2] + ) + + cx = float( + cached[0] + ) + + cy = float( + cached[1] + ) + + scaled_buffer = ( + float(buffer) + * float(a.sample_scale) + ) + + region = ( + int( + cx + - width / 2 + - scaled_buffer + ), + int( + cy + - height / 2 + - scaled_buffer + ), + int( + width + + scaled_buffer * 2 + ), + int( + height + + scaled_buffer * 2 + ), + ) + + location = a.find_in_region( + "hand", + region, + set_loc=False, + ) + + else: + + region = a.get_ui_region( + "bottom_center" + ) + + location = a.find_in_region( + "hand", + region, + set_loc=False, + ) + + if location is not None: + + a.cache_ui_location( + "clan_help", + location, + ) + + a.debug( + f"Clan Help located and cached: " + f"{location}" + ) + + if location is None: + return False + + # Refresh the cached center from the actual match. + a.cache_ui_location( + "clan_help", + location, + ) + + # -------------------------------------------------------- + # CLICK + POSITIVE CONFIRMATION + # -------------------------------------------------------- + + for attempt in range( + 1, + retries + 1, + ): + + x = float( + location[0] + ) + + y = float( + location[1] + ) + + a.debug( + f"Clan Help available: " + f"({x:.1f}, {y:.1f})" + ) + + # Targeted jiggle only for this occasionally missed + # Windows HUD control. + a.input.current_position = None + a.input.current_monitor = None + + a.loc = ( + x, + y, + ) + a.move() + + time.sleep( + 0.08 + ) + + a.loc = ( + x + 8.0, + y, + ) + a.move() + + time.sleep( + 0.05 + ) + + a.loc = ( + x, + y, + ) + a.move() + + time.sleep( + 0.10 + ) + + a.click() + + time.sleep( + 0.25 + ) + + # Positive confirmation from a fresh frame. + a.update() + + remaining = a.find_in_region( + "hand", + region, + set_loc=False, + ) + + if remaining is None: + + a.debug( + f"Clan Help click confirmed " + f"(attempt {attempt}/{retries})." + ) + + return True + + a.debug( + f"Clan Help still visible after click " + f"(attempt {attempt}/{retries})." + ) + + location = remaining + + a.cache_ui_location( + "clan_help", + location, + ) + + print( + "WARNING: Clan Help remained visible " + f"after {retries} click attempts." + ) + + return False diff --git a/crypt.py b/crypt.py index 887a938..426a498 100755 --- a/crypt.py +++ b/crypt.py @@ -3,93 +3,1974 @@ from automate import Automator import time import argparse +import watchtower +import world_map +import march as march_control +import clan common_crypts = ["crypts/" + name for name in ["cave", "cog", "dune", "greek", "grotto", "ice", "lava", "rune", "skull", "snake", "temple", "pyramid"]] rare_crypts = ["crypts/" + name for name in ["left", "right"]] epic_crypts = ["crypts/" + name for name in ["darktree", "tomb", "labyrinth", "rock", "staired", "spike", "ring", "lighttree", "steppyramid", "epicpyramid", "redtree", "cathedral", "crystal", "palace", "pit", "scorpion", "haunted"]] -def crypt(a): - if not a.await_samples("watchtower", 2): - return False - a.click() - if not a.await_samples(["crypts_selected", "crypts_unselected"], 2): - return False - a.click() - time.sleep(0.4) - if not a.await_samples_multi("go_watchtower", 2): - return False - if not a.random_multi(): - return False - a.click() - if not a.await_samples("logo", 2): - return False - a.move() - time.sleep(0.4) - if not a.await_samples_multi(crypts, 4): - return False - a.multi_block_color(140, 100, (48, 37, 130)) - a.multi_block_color(140, 100, (254, 74, 32)) - if not a.central_multi(): - return False - a.click() - if not a.await_samples("logo", 2): + +# ============================================================ +# WATCHTOWER CRYPT FILTERS +# ============================================================ + +CRYPT_FILTERS = { + "common": { + "selected": "common_selected", + "unselected": "common_unselected", + "min_x": 1680, + "max_x": 1840, + }, + "rare": { + "selected": "rare_selected", + "unselected": "rare_unselected", + "min_x": 1840, + "max_x": 1970, + }, + "epic": { + "selected": "epic_selected", + "unselected": "epic_unselected", + "min_x": 1970, + "max_x": 2130, + }, +} + + +def detect_crypt_filters(a): + """ + Determine Common/Rare/Epic Watchtower filter states. + + IMPORTANT: + a.update() must have been called immediately before this + function. All six template comparisons use that SAME image. + + Returns: + { + "common": True/False, + "rare": True/False, + "epic": True/False, + } + """ + + import cv2 + + states = {} + + for crypt_type, info in CRYPT_FILTERS.items(): + + scores = {} + + for state in ( + "selected", + "unselected", + ): + template = a.get_sample( + info[state] + ) + + result = cv2.matchTemplate( + a.image, + template, + a.method, + ) + + restricted = result[ + :, + info["min_x"]:info["max_x"], + ] + + min_val, max_val, min_loc, max_loc = ( + cv2.minMaxLoc( + restricted + ) + ) + + if a.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + score = float(min_val) + match_loc = min_loc + else: + # Convert higher-is-better methods into a + # lower-is-better comparison value. + score = -float(max_val) + match_loc = max_loc + + location = ( + match_loc[0] + info["min_x"], + match_loc[1], + ) + + scores[state] = { + "score": score, + "location": location, + "template": template, + } + + selected = scores["selected"] + unselected = scores["unselected"] + + is_selected = ( + selected["score"] + < unselected["score"] + ) + + winner = ( + selected + if is_selected + else unselected + ) + + # Convert template top-left to center coordinates in + # the same coordinate system used by Automator clicks. + template = winner["template"] + + h, w = template.shape[:2] + + x = ( + winner["location"][0] + + w / 2 + ) / a.scale + + y = ( + winner["location"][1] + + h / 2 + ) / a.scale + + states[crypt_type] = { + "selected": is_selected, + "location": (x, y), + "selected_score": selected["score"], + "unselected_score": unselected["score"], + } + + return states + + +def ensure_crypt_filters( + a, + requested_types, +): + """ + Crypt-specific wrapper around the shared Watchtower filter + engine. + + Crypt owns: + filter names + Common/Rare/Epic visual classifier + + Watchtower owns: + requested-state handling + clicking mismatches + final verification + """ + + return watchtower.ensure_filters( + a, + filter_names=( + "common", + "rare", + "epic", + ), + requested=requested_types, + detect=detect_crypt_filters, + label="Watchtower crypt", + ) + + +def get_right_half_region( + region, +): + x, y, width, height = region + + return ( + x + width / 2, + y, + width / 2, + height, + ) + + +def find_crypt_explore( + a, + set_loc=False, +): + return a.find_in_region( + "explore", + get_right_half_region( + a.get_ui_region( + "middle_center" + ) + ), + set_loc=set_loc, + ) + + +def detect_carter_state(a): + """ + Classify Carter's current crypt-panel state from ONE screenshot. + + Expected states: + "selected" + "on_march" + "unknown" + + carter_selected.png and carter_on_march.png are visually + similar enough that both may pass the normal global threshold. + Therefore, compare their match scores directly and use the + better match rather than independent found/not-found checks. + """ + + import cv2 + + samples = { + "selected": "carter_selected", + "on_march": "carter_on_march", + } + + results = {} + + # Carter's crypt-panel state can only appear in the + # configured middle-center region. + region = a.get_ui_region( + "middle_center" + ) + + rx, ry, rw, rh = region + + sx = int( + round( + rx * a.scale + ) + ) + + sy = int( + round( + ry * a.scale + ) + ) + + sw = int( + round( + rw * a.scale + ) + ) + + sh = int( + round( + rh * a.scale + ) + ) + + region_image = a.image[ + sy:sy + sh, + sx:sx + sw, + ] + + for state, sample_name in samples.items(): + template = a.get_sample( + sample_name + ) + + result = cv2.matchTemplate( + region_image, + template, + a.method, + ) + + min_val, max_val, min_loc, max_loc = ( + cv2.minMaxLoc(result) + ) + + if a.method in [ + cv2.TM_SQDIFF, + cv2.TM_SQDIFF_NORMED, + ]: + score = float(min_val) + location = min_loc + else: + # Convert higher-is-better methods into a + # lower-is-better comparison. + score = -float(max_val) + location = max_loc + + results[state] = { + "score": score, + "location": location, + } + + selected_score = results[ + "selected" + ]["score"] + + march_score = results[ + "on_march" + ]["score"] + + if selected_score < march_score: + winner = "selected" + best = selected_score + other = march_score + + else: + winner = "on_march" + best = march_score + other = selected_score + + margin = ( + other + - best + ) + + a.debug( + f"Carter state scores: " + f"selected={selected_score:.6f} " + f"on_march={march_score:.6f} " + f"winner={winner} " + f"margin={margin:.6f}" + ) + + # Carter state needs a much stricter threshold than the + # normal global template threshold. + # + # Measured examples: + # + # Carter on march: + # on_march ~= 0.000000 + # + # Carter selected: + # selected ~= 0.000069 + # + # Another captain selected: + # Carter-selected template ~= 0.009032 + # + # Read the Carter-specific threshold from configuration. + carter_state_threshold = float( + a.matching[ + "carter_state_threshold" + ] + ) + + carter_state_margin = float( + a.matching.get( + "carter_state_margin", + 0.01, + ) + ) + + if ( + best > carter_state_threshold + or margin < carter_state_margin + ): + return { + "state": "unknown", + "selected_score": selected_score, + "on_march_score": march_score, + "margin": margin, + "threshold": carter_state_threshold, + "required_margin": carter_state_margin, + } + + return { + "state": winner, + "selected_score": selected_score, + "on_march_score": march_score, + "margin": margin, + "threshold": carter_state_threshold, + "required_margin": carter_state_margin, + } + + +def find_carter_march(a, region_only=True): + """Find Carter active march using its dedicated threshold.""" + + threshold = float( + a.matching.get( + "carter_march_threshold", + a.threshold, + ) + ) + + if region_only: + return a.find_in_region( + "carter_march", + a.get_ui_region("top_center"), + threshold=threshold, + set_loc=False, + ) + + return a.find( + "carter_march", + threshold=threshold, + set_loc=False, + ) + + +def await_carter_march( + a, + timeout=2.0, +): + """Wait briefly for Carter's march using its dedicated matcher.""" + + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + a.update() + + march = find_carter_march( + a + ) + + if march: + return march + + time.sleep(0.10) + + return None + + +def probe_carter_available(a): + """ + Positively verify whether Carter is available by opening one + crypt panel and inspecting Carter's state. + + This is deliberately READ-ONLY with respect to the crypt: + - Watchtower may be opened. + - A GO result may be selected. + - The centered crypt may be opened. + - OPEN is NEVER clicked. + - EXPLORE is NEVER clicked. + + Returns: + True: + Carter is positively classified as selected. + + False: + Carter is positively classified as on_march. + + None: + Availability could not be proven either way. + + Safety rule: + Only True is sufficient to clear carter_march_active. + """ + + a.debug( + "March banner is absent; " + "verifying Carter from a crypt panel..." + ) + + # -------------------------------------------------------- + # RETURN TO WORLD + # -------------------------------------------------------- + + if not world_map.ensure_world( + a + ): + a.debug( + "Carter availability probe could not " + "establish World state." + ) + + return None + + # -------------------------------------------------------- + # OPEN WATCHTOWER / CRYPTS + # -------------------------------------------------------- + + if not watchtower.open_watchtower( + a, + tab="crypts", + ): + a.debug( + "Carter availability probe could not " + "open Watchtower Crypts." + ) + + return None + + # Do NOT use wait_for_go_buttons() here because that helper + # intentionally refreshes forever when the list is empty. + # + # A completion probe must remain finite. + go_buttons = a.await_samples_multi( + "go_watchtower", + 3, + ) + + if not go_buttons: + a.debug( + "Carter availability probe found " + "no current crypt GO buttons." + ) + + a.start_game( + worldmap=True, + ) + + return None + + chosen_go = ( + watchtower.choose_random_go( + a + ) + ) + + if chosen_go is None: + a.start_game( + worldmap=True, + ) + + return None + + if not watchtower.click_go( + a, + chosen_go, + ): + a.start_game( + worldmap=True, + ) + + return None + + # -------------------------------------------------------- + # OPEN THE CENTERED CRYPT PANEL + # -------------------------------------------------------- + + time.sleep( + a.timing[ + "crypt_center_wait" + ] + ) + + world_map.click_center_target( + a + ) + + time.sleep( + a.timing[ + "crypt_panel_wait" + ] + ) + + panel_retries = int( + a.timing[ + "crypt_panel_retries" + ] + ) + + result = None + + try: + for panel_try in range( + 1, + panel_retries + 1, + ): + a.update() + + carter = detect_carter_state( + a + ) + + state = carter[ + "state" + ] + + # Confirm that this is actually a crypt action + # panel before trusting Carter's portrait state. + explore = find_crypt_explore( + a, + set_loc=False, + ) + + rare_open = a.find_in_region( + "rare_open", + a.get_ui_region( + "middle_center" + ), + set_loc=False, + ) + + panel_visible = bool( + explore + or rare_open + ) + + a.debug( + f"Carter availability probe " + f"{panel_try}/{panel_retries}: " + f"state={state} " + f"selected=" + f"{carter['selected_score']:.6f} " + f"on_march=" + f"{carter['on_march_score']:.6f} " + f"margin=" + f"{carter['margin']:.6f} " + f"panel={'yes' if panel_visible else 'no'}" + ) + + if panel_visible: + if state == "selected": + result = True + break + + if state == "on_march": + result = False + break + + # UNKNOWN is intentionally NOT interpreted as + # available. This is the important safety rule. + result = None + + if panel_try < panel_retries: + time.sleep( + a.timing[ + "crypt_panel_retry_wait" + ] + ) + + finally: + # Never leave the availability probe sitting on a crypt. + # + # No OPEN or EXPLORE click has occurred. + a.start_game( + worldmap=True, + ) + + return result + + +def wait_for_carter_finish(a): + """ + Block until Carter's return has been positively verified. + + Carter completion now uses two independent signals: + + 1. carter_march banner + 2. Carter state on an actual crypt panel + + A missing march banner is NOT sufficient to declare Carter + returned. + + Only a crypt panel that positively classifies Carter as + "selected" clears the active-march state. + """ + + if not getattr( + a, + "carter_march_active", + False, + ): + return True + + print( + "Waiting for Carter to return..." + ) + + # -------------------------------------------------------- + # POST-MAX GRACE + # -------------------------------------------------------- + + max_time = getattr( + a, + "carter_max_speedup_time", + None, + ) + + if max_time is not None: + elapsed = ( + time.monotonic() + - max_time + ) + + grace = 3.0 + + if elapsed < grace: + remaining = ( + grace + - elapsed + ) + + a.debug( + f"Post-MAX grace: " + f"{remaining:.2f}s remaining" + ) + + time.sleep( + remaining + ) + + # -------------------------------------------------------- + # WAIT FOR POSITIVE CARTER AVAILABILITY + # -------------------------------------------------------- + + while True: + a.update() + + march = find_carter_march( + a, + region_only=False, + ) + + if march: + a.carter_march_loc = march + + # Carter is definitely still active. + clan.click_help_if_available( + a, + update=False, + ) + + time.sleep( + 0.25 + ) + + continue + + # ---------------------------------------------------- + # MARCH BANNER ABSENT + # ---------------------------------------------------- + # + # Do not clear Carter here. + # + # The banner may disappear/change while Carter is still + # unavailable. Require an independent crypt-panel check. + # ---------------------------------------------------- + + time.sleep( + 0.20 + ) + + a.update() + + march = find_carter_march( + a, + region_only=False, + ) + + if march: + a.carter_march_loc = march + + clan.click_help_if_available( + a, + update=False, + ) + + time.sleep( + 0.25 + ) + + continue + + # Two banner misses only make Carter a candidate for + # completion. They do NOT prove completion. + available = ( + probe_carter_available( + a + ) + ) + + if available is True: + a.carter_march_active = False + a.carter_max_speedup_applied = False + a.carter_max_speedup_time = None + a.carter_march_loc = None + + print( + "Carter returned." + ) + + return True + + if available is False: + a.debug( + "Carter panel confirms Carter " + "is still on march." + ) + + else: + a.debug( + "Carter availability remains " + "unconfirmed; continuing to wait." + ) + + # Do not hammer Watchtower continuously when the march + # banner is absent but Carter is still unavailable. + time.sleep( + 0.75 + ) + +def crypt( + a, + requested_types=None, + apply_max_speedup=True, +): + """ + Launch one crypt exploration in the standalone app. + + Common / Epic: + Watchtower + -> GO + -> centered target + -> Explore + -> verify Carter + -> Explore + + Rare: + Watchtower + -> GO + -> centered target + -> Open + -> Explore + -> verify Carter + -> Explore + """ + + # Hard safety invariant: + # never begin another crypt while the previous Carter + # march remains unresolved. + if getattr( + a, + "carter_march_active", + False, + ): + if not wait_for_carter_finish(a): + return False + + + + max_attempts = 5 + + for attempt in range( + 1, + max_attempts + 1, + ): + a.debug() + a.debug( + f"Crypt attempt " + f"{attempt}/{max_attempts}" + ) + + # ---------------------------------------------------- + # SHARED WORLD / WATCHTOWER NAVIGATION + # ---------------------------------------------------- + + if not world_map.ensure_world( + a + ): + print( + "FAIL: could not establish World state." + ) + return False + + if not watchtower.open_watchtower( + a, + tab="crypts", + ): + return False + + # None means leave the player's existing Common/Rare/Epic + # Watchtower filter selections exactly as they are. + # + # When a specific type was requested, make that the only + # enabled Crypt filter before choosing a GO result. + if not ensure_crypt_filters( + a, + requested_types, + ): + return False + + go_buttons = ( + watchtower.wait_for_go_buttons( + a, + tab="crypts", + target_name="crypts", + ) + ) + + if not go_buttons: + return False + + chosen_go = ( + watchtower.choose_random_go( + a + ) + ) + + if chosen_go is None: + return False + + if not watchtower.click_go( + a, + chosen_go, + ): + return False + + # Preserve the existing timing exactly. + time.sleep( + a.timing[ + "crypt_center_wait" + ] + ) + + world_map.click_center_target( + a + ) + + time.sleep( + a.timing[ + "crypt_panel_wait" + ] + ) + + # ---------------------------------------------------- + # IDENTIFY / HANDLE CRYPT PANEL + # ---------------------------------------------------- + # + # Use ONE screenshot for: + # + # * Carter state + # * Explore + # * Rare Open + # + # Only Carter state == "selected" is allowed to click + # either Explore or Open. + + # ---------------------------------------------------- + # INITIAL PANEL DETECTION + # ---------------------------------------------------- + # + # The crypt panel can still be animating on the first + # screenshot. Carter appearing as "unknown" in that + # situation is not a reason to abort immediately. + # + # Retry the SAME state detection with fresh screenshots + # before deciding Carter is actually unavailable. + + panel_retries = int( + a.timing[ + "crypt_panel_retries" + ] + ) + + carter_state = None + explore = None + rare_open = None + stale_center_target = False + + for panel_try in range( + 1, + panel_retries + 1, + ): + a.update() + + carter_state = detect_carter_state( + a + ) + + state = carter_state[ + "state" + ] + + # Check both possible buttons from this SAME image. + explore = find_crypt_explore( + a, + set_loc=False, + ) + + rare_open = a.find( + "rare_open", + set_loc=False, + ) + + a.debug( + f"Panel frame {panel_try}: " + f"Carter={state}, " + f"Explore={'yes' if explore else 'no'}, " + f"Open={'yes' if rare_open else 'no'}" + ) + + # If neither action is found on the first capture, + # take one immediate second capture before deciding + # the target is stale. + # + # This diagnostic tells us whether the first + # screenshot is simply happening before the crypt + # panel becomes visible to the capture path. + if ( + not explore + and not rare_open + ): + print( + "No Explore or Rare Open on first capture." + ) + print( + "Waiting briefly before second " + "panel capture..." + ) + + time.sleep( + a.timing[ + "crypt_panel_retry_wait" + ] + ) + + a.update() + + carter_state = detect_carter_state( + a + ) + + state = carter_state[ + "state" + ] + + explore = find_crypt_explore( + a, + set_loc=False, + ) + + rare_open = a.find_in_region( + "rare_open", + a.get_ui_region( + "middle_center" + ), + set_loc=False, + ) + + print( + f"Second capture: " + f"Carter={state}, " + f"Explore={'yes' if explore else 'no'}, " + f"Open={'yes' if rare_open else 'no'}" + ) + + if ( + not explore + and not rare_open + ): + print( + "Second capture also has no crypt action." + ) + print( + "Treating centered target as stale." + ) + + stale_center_target = True + break + + print( + "Crypt panel appeared on second capture." + ) + + # Continue using this newer screenshot. + if state != "unknown": + break + + # We have evidence this is a crypt panel. + # + # If Carter is confidently classified, continue + # immediately. If Carter is unknown, allow the panel + # a short settling retry. + if state != "unknown": + break + + if panel_try < panel_retries: + a.debug( + "Crypt action detected but Carter state " + "is still settling; retrying..." + ) + + time.sleep( + a.timing[ + "crypt_panel_retry_wait" + ] + ) + + if stale_center_target: + print( + "Recovering to World and choosing " + "another Watchtower crypt..." + ) + + if not a.start_game( + worldmap=True, + ): + print( + "FAIL: could not recover World state " + "after stale centered target." + ) + return False + + continue + + state = carter_state[ + "state" + ] + + if state == "on_march": + print( + "Carter is still on a march." + ) + print( + "Do not send another captain." + ) + return False + + if state != "selected": + print( + "Carter state remained unknown " + "after panel retries." + ) + print( + "Explore will NOT be clicked." + ) + return False + + # ---------------------------------------------------- + # COMMON / EPIC + # ---------------------------------------------------- + + if explore: + # Restore the target from the successful screenshot. + a.loc = explore + + if explore: + a.debug( + f"Explore found: {explore}" + ) + + a.debug( + f"Clicking Explore: {explore}" + ) + + a.click( + 0, + 10, + ) + + else: + # ------------------------------------------------ + # POSSIBLE RARE CRYPT + # ------------------------------------------------ + # + # This uses the SAME screenshot. No extra wait or + # capture occurs between Explore and Open lookup. + + rare_open = a.find( + "rare_open" + ) + + if rare_open: + a.debug( + f"Rare Open found: {rare_open}" + ) + + # a.loc already points at Open. + a.click() + + # Clicking Open genuinely changes the panel, + # so this is the first place a rare-specific + # wait is needed. + time.sleep( + a.timing[ + "rare_open_wait" + ] + ) + + # Fresh screenshot after Open. + a.update() + + carter_state = detect_carter_state( + a + ) + + state = carter_state[ + "state" + ] + + if state != "selected": + print( + f"Carter state after Open is " + f"{state}." + ) + print( + "Explore will NOT be clicked." + ) + return False + + explore = find_crypt_explore( + a, + set_loc=True, + ) + + if not explore: + print( + "Rare crypt opened, but Explore " + "was not detected yet." + ) + print( + "Waiting for Explore to appear " + "after Open..." + ) + + for rare_explore_try in range( + 1, + 4, + ): + time.sleep( + a.timing[ + "crypt_panel_retry_wait" + ] + ) + + a.update() + + explore = find_crypt_explore( + a, + set_loc=True, + ) + + if explore: + print( + "Explore found after Open " + f"retry {rare_explore_try}/3." + ) + break + + print( + "Explore still not detected " + f"after Open retry " + f"{rare_explore_try}/3." + ) + + if not explore: + print( + "Rare crypt is open, but Explore " + "never appeared." + ) + print( + "Trying another crypt..." + ) + continue + + a.debug( + f"Explore found after Open: " + f"{explore}" + ) + + # a.loc already points at Explore. + a.debug( + f"Clicking Explore: {explore}" + ) + + a.click( + 0, + 10, + ) + + else: + # ------------------------------------------------ + # PANEL MAY HAVE BEEN CAPTURED TOO EARLY + # ------------------------------------------------ + + print( + "Neither Explore nor Rare Open was " + "detected on first panel frame." + ) + + time.sleep( + a.timing[ + "crypt_panel_retry_wait" + ] + ) + + a.update() + + carter_state = detect_carter_state( + a + ) + + state = carter_state[ + "state" + ] + + if state != "selected": + print( + f"Carter state after panel retry is " + f"{state}." + ) + print( + "Do not send another captain." + ) + return False + + explore = find_crypt_explore( + a, + set_loc=True, + ) + + if explore: + print( + f"Explore found after retry: " + f"{explore}" + ) + + a.click( + 0, + 10, + ) + + else: + rare_open = a.find( + "rare_open" + ) + + if not rare_open: + print( + "Neither Explore nor Rare Open " + "was found after retry." + ) + print( + "Trying another crypt..." + ) + continue + + a.debug( + f"Rare Open found after retry: " + f"{rare_open}" + ) + + a.click() + + time.sleep( + a.timing[ + "rare_open_wait" + ] + ) + + a.update() + + carter_state = detect_carter_state( + a + ) + + if ( + carter_state["state"] + != "selected" + ): + print( + f"Carter state after Open is " + f"{carter_state['state']}." + ) + print( + "Explore will NOT be clicked." + ) + return False + + explore = find_crypt_explore( + a, + set_loc=True, + ) + + if not explore: + print( + "Rare crypt opened, but Explore " + "was not detected yet." + ) + print( + "Waiting for Explore to appear " + "after Open..." + ) + + for rare_explore_try in range( + 1, + 4, + ): + time.sleep( + a.timing[ + "crypt_panel_retry_wait" + ] + ) + + a.update() + + explore = find_crypt_explore( + a, + set_loc=True, + ) + + if explore: + print( + "Explore found after Open " + f"retry " + f"{rare_explore_try}/3." + ) + break + + print( + "Explore still not detected " + f"after Open retry " + f"{rare_explore_try}/3." + ) + + if not explore: + print( + "Rare crypt is open, but Explore " + "never appeared." + ) + print( + "Trying another crypt..." + ) + continue + + a.debug( + f"Explore found after Open: " + f"{explore}" + ) + + a.click( + 0, + 10, + ) + + # ---------------------------------------------------- + # FAST EXPLORE -> MAX SPEEDUP + # ---------------------------------------------------- + + if not apply_max_speedup: + march = await_carter_march( + a, + timeout=3.0, + ) + + if march: + a.carter_march_loc = march + a.carter_max_speedup_applied = False + a.carter_march_active = True + a.carter_max_speedup_time = None + + a.debug( + f"Carter march: {march}" + ) + print( + "Crypt march launched." + ) + print( + "MAX speedup disabled; " + "waiting for Carter's natural return." + ) + + return True + + print( + "WARNING: Carter march could not be confirmed " + "after Explore was clicked." + ) + print( + "Launch state is uncertain. " + "Stopping Crypt automation rather than " + "selecting another crypt." + ) + + raise RuntimeError( + "Carter launch state uncertain after Explore." + ) + + if fast_explore_to_max(a): + print( + "Crypt march launched." + ) + print( + "MAX speedup applied." + ) + return True + + # ---------------------------------------------------- + # FALLBACK / STALE CRYPT CHECK + # ---------------------------------------------------- + # + # The fast path failed. Determine whether Carter + # actually launched or whether the crypt disappeared. + + print( + "Fast Explore -> MAX path did not complete." + ) + print( + "Checking Carter march..." + ) + + march = await_carter_march( + a, + timeout=2.0, + ) + + if march: + # Carter launched, but the speedup panel simply + # wasn't ready quickly enough. Preserve the march + # so normal speedup() can handle it. + a.carter_max_speedup_applied = False + a.carter_march_active = True + a.carter_march_loc = march + + a.debug( + f"Carter march: {march}" + ) + print( + "Crypt march launched " + "(MAX speedup deferred)." + ) + + return True + + print( + "WARNING: Carter march could not be confirmed " + "after Explore was clicked." + ) + print( + "Launch state is uncertain. " + "Stopping Crypt automation rather than " + "selecting another crypt." + ) + + raise RuntimeError( + "Carter launch state uncertain after Explore." + ) + + print() + print( + "FAIL: no valid crypt opened " + f"after {max_attempts} attempts." + ) + + return False + +def fast_explore_to_max(a): + """ + Fast path immediately after clicking Explore. + + Crypt owns: + Carter-specific march detection + Carter lifecycle state + + march.py owns: + pairing the march with its speedup control + opening the speedup panel + locating the 50% row + pairing the aligned MAX control + clicking MAX + + Returns True only when Carter's march was found and + shared MAX speedup was successfully applied. + """ + + fast_start = time.perf_counter() + + # -------------------------------------------------------- + # WAIT FOR CARTER MARCH TO APPEAR + # -------------------------------------------------------- + + time.sleep( + a.timing[ + "explore_to_march_wait" + ] + ) + + op_start = time.perf_counter() + + march_location = await_carter_march( + a, + timeout=3.0, + ) + + a.debug( + f"TIMING Carter march wait: " + f"{time.perf_counter() - op_start:.3f}s" + ) + + if not march_location: + + a.debug( + "Fast path: Carter march not detected." + ) + return False - a.move() - time.sleep(0.3) - if not a.await_samples("carter_selected", 2): + + a.debug( + f"Carter march: {march_location}" + ) + + # -------------------------------------------------------- + # CARTER-SPECIFIC STATE + # -------------------------------------------------------- + + a.carter_march_loc = march_location + a.carter_max_speedup_applied = False + a.carter_march_active = True + + # -------------------------------------------------------- + # SHARED MARCH SPEEDUP + # -------------------------------------------------------- + + if not march_control.open_and_max_speedup( + a, + march_location, + ): + + print( + "Fast path: shared MAX speedup failed." + ) + + # Carter DID launch. Keep the active march state and + # location so the normal fallback can retry speedup. + a.carter_max_speedup_applied = False + return False - if not a.await_samples("selected", 2): + + # -------------------------------------------------------- + # CARTER-SPECIFIC SUCCESS STATE + # -------------------------------------------------------- + + a.carter_max_speedup_applied = True + a.carter_march_active = True + a.carter_max_speedup_time = time.monotonic() + + elapsed = ( + time.perf_counter() + - fast_start + ) + + a.debug( + f"EXPLORE -> MAX SUCCESS " + f"in {elapsed:.3f}s" + ) + + return True + + +def speedup( + a, + apply_max_speedup=True, +): + """ + Handle an active Carter crypt march. + + Carter ownership and lifecycle remain Crypt-specific. + Generic march speedup mechanics are delegated to march.py. + + Returns: + True while Carter is active / speedup handled + False when Carter is no longer marching or cannot be found + """ + + # -------------------------------------------------------- + # MAX ALREADY APPLIED: WAIT FOR CONFIRMED CARTER RETURN + # -------------------------------------------------------- + # + # SAFETY INVARIANT: + # + # Once Carter has launched and MAX has been applied, never + # allow another crypt to begin until Carter's return has + # been positively confirmed. + # + # The old code performed one narrow top-center march check: + # + # find_carter_march(a) + # + # which defaults to region_only=True. A single missed frame + # could therefore clear Carter's active state even while the + # march was still running. + # + # wait_for_carter_finish() is deliberately more conservative: + # + # * observes the post-MAX grace period + # * searches for Carter globally + # * keeps following the moving march banner + # * requires two consecutive absent screenshots + # * only then clears Carter's active-march state + # + # Block here until that confirmation is complete. Returning + # False afterward tells main() that Carter is now available + # and a new crypt may safely begin. + # -------------------------------------------------------- + + if getattr( + a, + "carter_max_speedup_applied", + False, + ): + + wait_for_carter_finish( + a + ) + return False - if rare: - if not a.await_samples("rare_open", 2): - return False - a.click() - if not a.await_samples("explore", 2): + + # When MAX speedup is disabled, Carter still owns the Crypt + # lifecycle. Wait for his confirmed return before allowing + # another Crypt to launch. + if ( + getattr( + a, + "carter_march_active", + False, + ) + and not apply_max_speedup + ): + wait_for_carter_finish( + a + ) + return False - a.click(0, 10) - if not a.await_samples("carter_march", 2): + + # -------------------------------------------------------- + # CONFIRM CARTER'S MARCH IS STILL ACTIVE + # -------------------------------------------------------- + # + # Do not blindly reuse carter_march_loc here. That location + # describes where Carter's banner WAS when the march began. + # Carter may have returned between speedup attempts. + # + # One fresh screenshot determines whether there is still a + # Carter march to speed up. + + a.update() + + march_location = find_carter_march( + a, + region_only=False, + ) + + if not march_location: + + a.debug( + "Carter march no longer present; " + "clearing stale march state." + ) + + a.carter_march_active = False + a.carter_max_speedup_applied = False + a.carter_max_speedup_time = None + a.carter_march_loc = None + return False - return True -def speedup(a): - if a.await_samples("carter_march", 2): - a.click(190, 8) - if not a.await_samples("speedup", 2): - return True - for _ in range(6): - a.click(360, 36) - time.sleep(1.5) + # Refresh the cached location because the banner may have + # moved as other marches appeared or disappeared. + a.carter_march_loc = march_location + a.carter_march_active = True + + a.debug() + a.debug( + f"Carter march found: " + f"{march_location}" + ) + + # -------------------------------------------------------- + # SHARED MARCH SPEEDUP + # -------------------------------------------------------- + + if not march_control.open_and_max_speedup( + a, + march_location, + ): + + print( + "Shared Carter MAX speedup failed." + ) + return True - return False -parser = argparse.ArgumentParser() -parser.add_argument("-t", "--type", default = "common", help = "crypt type") -args = parser.parse_args() - -crypt_type = args.type - -rare = False -if crypt_type == "common": - crypts = common_crypts -elif crypt_type == "epic": - crypts = epic_crypts -elif crypt_type == "rare": - crypts = rare_crypts - rare = True -else: - print("Unknown crypt type") - exit(1) - -a = Automator() -fails = 0 -last_restart = time.time() -while True: - if fails >= 10 or time.time() > last_restart + 900: - a.restart_game(True) - last_restart = time.time() + # -------------------------------------------------------- + # CARTER-SPECIFIC SUCCESS STATE + # -------------------------------------------------------- + + a.carter_max_speedup_applied = True + a.carter_march_active = True + a.carter_max_speedup_time = time.monotonic() + + return True + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "-t", + "--type", + choices=( + "common", + "rare", + "epic", + ), + default=None, + help=( + "Crypt type to select in the Watchtower. " + "If omitted, leave the current Crypt filters unchanged." + ), + ) + + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Show detailed diagnostic output.", + ) + + parser.add_argument( + "--no-speedup", + action="store_true", + help=( + "Launch Crypts without applying MAX march speedup. " + "Carter is still tracked until his return." + ), + ) + + parser.add_argument( + "--count", + type=int, + default=None, + help=( + "Stop after this many successful Crypt launches. " + "Default is to run continuously." + ), + ) + args = parser.parse_args() + + crypt_type = args.type + + if ( + args.count is not None + and args.count < 1 + ): + parser.error( + "--count must be at least 1" + ) + + requested_types = ( + None + if crypt_type is None + else { + crypt_type, + } + ) + + a = Automator( + verbose=args.verbose, + ) + + try: + print() + print("=" * 60) + print("=== CRYPT AUTOMATION STARTUP ===") + print("=" * 60) + print() + + # Crypting operates from the World Map. + # + # Valid starting states are Shop, City, or World. + if not a.start_game(worldmap=True): + raise RuntimeError( + "Could not establish World Map state " + "for crypting." + ) + fails = 0 - if not speedup(a): - if crypt(a): - fails = 0 - else: - fails += 1 + last_recovery = time.time() + completed = 0 + + while True: + # ------------------------------------------------ + # OPTIONAL SUCCESSFUL-LAUNCH LIMIT + # ------------------------------------------------ + + if ( + args.count is not None + and completed >= args.count + ): + print( + f"Crypt run limit reached: " + f"{completed}" + ) + break + + # ------------------------------------------------ + # RECOVERY + # ------------------------------------------------ + + if ( + fails >= 10 + or time.time() > last_recovery + 900 + ): + print() + print( + "Re-establishing World Map state..." + ) + + if not a.start_game( + worldmap=True, + ): + raise RuntimeError( + "Could not recover World Map state." + ) + + last_recovery = time.time() + fails = 0 + + # ------------------------------------------------ + # NORMAL CRYPT LOOP + # ------------------------------------------------ + + if not speedup( + a, + apply_max_speedup=not args.no_speedup, + ): + if crypt( + a, + requested_types=requested_types, + apply_max_speedup=not args.no_speedup, + ): + fails = 0 + completed += 1 + + print( + f"Completed Crypt launches: " + f"{completed}" + ) + else: + fails += 1 + + # crypt() has already exhausted its own + # internal attempt limit before returning + # False. + # + # For an explicitly bounded --count run, + # do not immediately start another complete + # retry batch. This makes diagnostic/bounded + # runs actually terminate after that failure. + # + # Normal continuous mode (no --count) keeps + # the existing recovery behavior. + if args.count is not None: + print() + print( + "Bounded Crypt run stopped after " + "the internal attempt limit failed." + ) + break + + except KeyboardInterrupt: + print() + print( + "Crypt automation interrupted." + ) + + finally: + a.input.close() + + +if __name__ == "__main__": + main() diff --git a/dev_citadel_prefill.py b/dev_citadel_prefill.py new file mode 100644 index 0000000..75c6e88 --- /dev/null +++ b/dev_citadel_prefill.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +from automate import Automator +from citadel import ( + open_citadel_battle, + load_citadel_stack, +) + + +a = Automator() + +try: + print() + print("=" * 70) + print("=== CITADEL CLASSIFY -> BATTLE -> TROOPS TEST ===") + print("=" * 70) + print() + print( + "Start on a supported Citadel screen with " + "the Attack button visible." + ) + print() + print( + "This test WILL click Attack and load the " + "configured Citadel troop stack." + ) + print( + "It WILL NOT launch the final Battle march." + ) + + # ======================================================== + # 1. CLASSIFY CITADEL AND OPEN BATTLE + # ======================================================== + + citadel_type = open_citadel_battle( + a + ) + + if citadel_type is None: + raise RuntimeError( + "Citadel classification / Attack -> Battle " + "transition failed." + ) + + print() + print( + f"Remembered Citadel type: " + f"{citadel_type}" + ) + + # ======================================================== + # 2. LOAD REAL CONFIGURED CITADEL STACK + # ======================================================== + + if not load_citadel_stack( + a, + citadel_type, + ): + raise RuntimeError( + "Configured Citadel troop loading failed." + ) + + # ======================================================== + # 3. STOP BEFORE FINAL ATTACK + # ======================================================== + + print() + print("=" * 70) + print("=== SUCCESS ===") + print("=" * 70) + + print( + f"Citadel: {citadel_type}" + ) + + print( + "Battle screen opened." + ) + + print( + "Configured Citadel troop stack loaded." + ) + + print( + "FINAL BATTLE ATTACK WAS NOT CLICKED." + ) + +finally: + a.input.close() diff --git a/dev_samples/exchange/ExchangeFreeform.png b/dev_samples/exchange/ExchangeFreeform.png new file mode 100644 index 0000000..a10e1b8 Binary files /dev/null and b/dev_samples/exchange/ExchangeFreeform.png differ diff --git a/dev_samples/exchange/ExchangeFreeform2.png b/dev_samples/exchange/ExchangeFreeform2.png new file mode 100644 index 0000000..01d9beb Binary files /dev/null and b/dev_samples/exchange/ExchangeFreeform2.png differ diff --git a/dev_samples/exchange/ExchangeFreeform3.png b/dev_samples/exchange/ExchangeFreeform3.png new file mode 100644 index 0000000..1787d93 Binary files /dev/null and b/dev_samples/exchange/ExchangeFreeform3.png differ diff --git a/dev_samples/exchange/ExchangeFreeform4.png b/dev_samples/exchange/ExchangeFreeform4.png new file mode 100644 index 0000000..2c9d4b4 Binary files /dev/null and b/dev_samples/exchange/ExchangeFreeform4.png differ diff --git a/dev_samples/exchange/ExchangeFreeform5.png b/dev_samples/exchange/ExchangeFreeform5.png new file mode 100644 index 0000000..5fc0cfd Binary files /dev/null and b/dev_samples/exchange/ExchangeFreeform5.png differ diff --git a/dev_watch_exchange.py b/dev_watch_exchange.py new file mode 100644 index 0000000..28ab1f7 --- /dev/null +++ b/dev_watch_exchange.py @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import sys +from pathlib import Path + +import os + +# OpenCV's bundled Qt package on Linux includes the X11/xcb +# platform plugin but not the native Wayland plugin. Force xcb +# before importing cv2 so the Exchange screenshot window works +# under Pop!_OS/COSMIC through XWayland. +if os.name != "nt": + os.environ.setdefault( + "QT_QPA_PLATFORM", + "xcb", + ) + +import cv2 +import numpy as np + +from automate import Automator + + +SAMPLE_NAME = "exchange" + +# The current samples_desktop/exchange.png does not match the +# live Exchange control. The older standard sample does. +EXCHANGE_SAMPLE_PATH = ( + Path(__file__).resolve().parent + / "samples" + / "exchange.png" +) + +# Live Windows standalone testing: +# +# game sample_scale = 1.333333 +# Exchange best = ~1.48 +# +# Preserve that relationship so the watcher can follow other +# visually discovered game scales. +EXCHANGE_SCALE_MULTIPLIER = 1.11 + +EXCHANGE_SCORE_MAX = 0.075 + +WINDOW_NAME = ( + "Exchange Found - press any key to continue" +) + +# Require Exchange to disappear before triggering again. +triggered = False + + +def play_sound(): + """ + Play a notification sound without changing game focus. + """ + + if sys.platform == "win32": + import winsound + + winsound.MessageBeep( + winsound.MB_ICONEXCLAMATION + ) + + return + + subprocess.Popen( + [ + "paplay", + "/usr/share/sounds/sound-icons/pisk-down.wav", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def handle_match_screenshot( + image, + save_screenshot=True, + open_screenshot=False, +): + """ + Save the exact frame that produced the Exchange match. + + Opening the screenshot is optional so normal monitoring can + remain unobtrusive. + """ + + if ( + not save_screenshot + and not open_screenshot + ): + return None + + screenshot_path = ( + Path(__file__).resolve().parent + / "exchange_found.png" + ) + + cv2.imwrite( + str(screenshot_path), + image, + ) + + print( + f"Exchange screenshot saved: " + f"{screenshot_path}" + ) + + if not open_screenshot: + return screenshot_path + + try: + if sys.platform == "win32": + os.startfile( + str(screenshot_path) + ) + + elif sys.platform == "darwin": + subprocess.Popen( + [ + "open", + str(screenshot_path), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + else: + subprocess.Popen( + [ + "xdg-open", + str(screenshot_path), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + print( + "Exchange screenshot opened." + ) + + except Exception as exc: + print( + "WARNING: Could not open Exchange " + f"screenshot: {exc}" + ) + + return screenshot_path + + +EXCHANGE_SAMPLE_DIR = ( + Path(__file__).resolve().parent + / "dev_samples" + / "exchange" +) + +COARSE_SCALE = 0.50 + +# Coarse matching only selects a verification candidate. +# It does NOT trigger the alert. +COARSE_SCORE_MAX = 0.26 + +# Full-resolution masked verification is authoritative. +VERIFY_SCORE_MAX = 0.13 + +# Current Linux rendering matches ExchangeFreeform5.png most +# reliably at 65% of its stored size. +EXCHANGE_TEMPLATE_NAME = "ExchangeFreeform5.png" +EXCHANGE_TEMPLATE_SCALE = 0.65 + +VERIFY_PADDING = 30 + + +def masked_match( + image, + template, + mask, +): + result = cv2.matchTemplate( + image, + template, + cv2.TM_SQDIFF_NORMED, + mask=mask, + ) + + result = np.nan_to_num( + result, + nan=1.0, + posinf=1.0, + neginf=1.0, + ) + + min_val, _, min_loc, _ = ( + cv2.minMaxLoc( + result + ) + ) + + return ( + float(min_val), + min_loc, + ) + + +def prepare_exchange_templates(): + """ + Load and prepare all Exchange templates once. + + No template resizing occurs in the watcher hot loop. + """ + + prepared = [] + + paths = [ + EXCHANGE_SAMPLE_DIR + / EXCHANGE_TEMPLATE_NAME + ] + + if not paths: + raise RuntimeError( + "No ExchangeFreeform PNG files found in " + f"{EXCHANGE_SAMPLE_DIR}" + ) + + for path in paths: + source = cv2.imread( + str(path), + cv2.IMREAD_UNCHANGED, + ) + + if ( + source is None + or source.ndim != 3 + or source.shape[2] != 4 + ): + print( + f"Skipping invalid Exchange sample: " + f"{path.name}" + ) + continue + + full_template = ( + source[:, :, :3].copy() + ) + + full_mask = np.where( + source[:, :, 3] > 0, + 255, + 0, + ).astype( + np.uint8 + ) + + scaled_w = max( + 1, + int(round( + full_template.shape[1] + * EXCHANGE_TEMPLATE_SCALE + )), + ) + + scaled_h = max( + 1, + int(round( + full_template.shape[0] + * EXCHANGE_TEMPLATE_SCALE + )), + ) + + full_template = cv2.resize( + full_template, + ( + scaled_w, + scaled_h, + ), + interpolation=cv2.INTER_AREA, + ) + + full_mask = cv2.resize( + full_mask, + ( + scaled_w, + scaled_h, + ), + interpolation=cv2.INTER_NEAREST, + ) + + coarse_template = cv2.resize( + full_template, + (0, 0), + fx=COARSE_SCALE, + fy=COARSE_SCALE, + interpolation=cv2.INTER_AREA, + ) + + coarse_mask = cv2.resize( + full_mask, + ( + coarse_template.shape[1], + coarse_template.shape[0], + ), + interpolation=cv2.INTER_NEAREST, + ) + + prepared.append({ + "name": path.name, + "full_template": + full_template, + "full_mask": + full_mask, + "coarse_template": + coarse_template, + "coarse_mask": + coarse_mask, + }) + + if not prepared: + raise RuntimeError( + "No valid RGBA Exchange templates loaded." + ) + + print() + print( + f"Prepared {len(prepared)} " + f"Exchange templates." + ) + + for item in prepared: + fh, fw = ( + item[ + "full_template" + ].shape[:2] + ) + + ch, cw = ( + item[ + "coarse_template" + ].shape[:2] + ) + + print( + f" {item['name']}: " + f"{fw}x{fh} -> " + f"{cw}x{ch}" + ) + + return prepared + + +def find_exchange( + a, + prepared, +): + """ + Fast full-screen Exchange detector. + + One screen downscale per frame. + Five small masked coarse searches. + One full-resolution masked verification when warranted. + """ + + full_image = a.image + + # -------------------------------------------------------- + # PREPARE COARSE SCREEN ONCE PER FRAME + # -------------------------------------------------------- + + small_image = cv2.resize( + full_image, + (0, 0), + fx=COARSE_SCALE, + fy=COARSE_SCALE, + interpolation=cv2.INTER_AREA, + ) + + # -------------------------------------------------------- + # FIND BEST COARSE CANDIDATE + # -------------------------------------------------------- + + best = None + + for item in prepared: + score, loc = masked_match( + small_image, + item["coarse_template"], + item["coarse_mask"], + ) + + if ( + best is None + or score < best["score"] + ): + best = { + "score": score, + "loc": loc, + "item": item, + } + + if ( + best is None + or best["score"] + > COARSE_SCORE_MAX + ): + return None + + item = best["item"] + + # Coarse top-left mapped to full-resolution coordinates. + candidate_x = ( + best["loc"][0] + / COARSE_SCALE + ) + + candidate_y = ( + best["loc"][1] + / COARSE_SCALE + ) + + template = item[ + "full_template" + ] + + mask = item[ + "full_mask" + ] + + th, tw = ( + template.shape[:2] + ) + + image_h, image_w = ( + full_image.shape[:2] + ) + + # -------------------------------------------------------- + # SMALL FULL-RES VERIFICATION ROI + # -------------------------------------------------------- + + left = max( + 0, + int(round( + candidate_x + - VERIFY_PADDING + )), + ) + + top = max( + 0, + int(round( + candidate_y + - VERIFY_PADDING + )), + ) + + right = min( + image_w, + int(round( + candidate_x + + tw + + VERIFY_PADDING + )), + ) + + bottom = min( + image_h, + int(round( + candidate_y + + th + + VERIFY_PADDING + )), + ) + + roi = full_image[ + top:bottom, + left:right, + ] + + if ( + roi.shape[1] < tw + or roi.shape[0] < th + ): + return None + + verify_score, verify_loc = ( + masked_match( + roi, + template, + mask, + ) + ) + + if ( + verify_score + > VERIFY_SCORE_MAX + ): + return None + + center = ( + float( + left + + verify_loc[0] + + tw / 2.0 + ), + float( + top + + verify_loc[1] + + th / 2.0 + ), + ) + + a.debug( + f"Exchange verified: " + f"{item['name']} " + f"coarse={best['score']:.6f} " + f"verify={verify_score:.6f} " + f"location={center}" + ) + + return center + + +parser = argparse.ArgumentParser( + description=( + "Watch the Total Battle screen for " + "Mercenary Exchange controls." + ) +) + +parser.add_argument( + "--no-sound", + action="store_true", + help="Do not play a sound when Exchange is found.", +) + +parser.add_argument( + "--no-save-screenshot", + action="store_true", + help="Do not save the triggering Exchange screenshot.", +) + +parser.add_argument( + "--open-screenshot", + action="store_true", + help="Automatically open the triggering screenshot.", +) + +args = parser.parse_args() + +a = Automator() + +prepared_exchange = ( + prepare_exchange_templates() +) + +try: + print() + print("=" * 60) + print("=== EXCHANGE WATCHER ===") + print("=" * 60) + print() + print("Analyzing continuously with no intentional delay.") + print() + print("When Exchange is found:") + + if args.no_sound: + print(" Sound alert: disabled") + else: + print(" Sound alert: enabled") + + if args.no_save_screenshot: + if args.open_screenshot: + print( + " Screenshot saving: enabled because " + "automatic opening requires a file" + ) + else: + print(" Screenshot saving: disabled") + else: + print(" Screenshot saving: enabled") + + if args.open_screenshot: + print(" Automatic screenshot opening: enabled") + else: + print(" Automatic screenshot opening: disabled") + + print(" Watching continues automatically") + print() + print( + "The mouse will NOT be moved or clicked." + ) + print() + print("Press Ctrl+C to stop.") + print() + + while True: + + # Fresh production-compatible screenshot. + a.update() + + # Search using the calibrated Exchange matcher. + location = find_exchange( + a, + prepared_exchange, + ) + + if location is not None: + + if not triggered: + + print( + f"Exchange found: {location}" + ) + + # Preserve the exact frame that caused the + # match. Do not update the screenshot or move + # the mouse before displaying it. + match_screenshot = ( + a.image.copy() + ) + + if not args.no_sound: + play_sound() + + triggered = True + + handle_match_screenshot( + match_screenshot, + save_screenshot=( + not args.no_save_screenshot + ), + open_screenshot=( + args.open_screenshot + ), + ) + + else: + + if triggered: + print( + "Exchange disappeared; " + "watcher re-armed." + ) + + triggered = False + + +except KeyboardInterrupt: + print() + print() + print("Exchange watcher stopped.") + +finally: + a.input.close() diff --git a/development/capture_sample.py b/development/capture_sample.py new file mode 100755 index 0000000..d056e46 --- /dev/null +++ b/development/capture_sample.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 + +import sys +from pathlib import Path +import tkinter as tk + +from PIL import Image, ImageTk +import cv2 + +from sample_tools.screenshots import capture_and_save + + +# ============================================================ +# PROJECT +# ============================================================ + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +OUTPUT_DIR = PROJECT_ROOT / "samples_desktop" + + +# ============================================================ +# HELP +# ============================================================ + +if len(sys.argv) != 2 or sys.argv[1] in ("-h", "--help"): + print("Usage:") + print(" python development/capture_sample.py NAME") + print() + print("Example:") + print(" python development/capture_sample.py city") + sys.exit(0 if len(sys.argv) == 2 else 1) + +name = sys.argv[1] + + +# ============================================================ +# CAPTURE +# ============================================================ + +print() +print("==================================================") +print("CAPTURE SAMPLE") +print("==================================================") +print() +print("Taking production-compatible COSMIC screenshot...") +print() + +monitor, screenshot_path = capture_and_save() + +height, width = monitor.shape[:2] + +print("Screenshot:") +print(f" {screenshot_path}") +print() +print("Working monitor:") +print(f" {width} x {height}") +print() +print("Select the template with the mouse.") +print() +print(" Drag = select") +print(" ENTER = save") +print(" ESC = cancel") +print() + + +# ============================================================ +# PREPARE IMAGE +# ============================================================ + +rgb = cv2.cvtColor( + monitor, + cv2.COLOR_BGR2RGB, +) + +pil_image = Image.fromarray(rgb) + +original_width, original_height = pil_image.size + +MAX_DISPLAY_WIDTH = 1800 +MAX_DISPLAY_HEIGHT = 900 + +scale = min( + MAX_DISPLAY_WIDTH / original_width, + MAX_DISPLAY_HEIGHT / original_height, + 1.0, +) + +display_width = int(original_width * scale) +display_height = int(original_height * scale) + +display_image = pil_image.resize( + (display_width, display_height), + Image.Resampling.LANCZOS, +) + + +# ============================================================ +# TK WINDOW +# ============================================================ + +root = tk.Tk() + +root.title( + f"Select Template - {name}" +) + +root.geometry( + f"{display_width}x{display_height}" +) + +root.resizable( + False, + False, +) + +canvas = tk.Canvas( + root, + width=display_width, + height=display_height, + cursor="crosshair", +) + +canvas.pack() + +photo = ImageTk.PhotoImage(display_image) + +canvas.create_image( + 0, + 0, + anchor=tk.NW, + image=photo, +) + + +# ============================================================ +# SELECTION STATE +# ============================================================ + +start_x = None +start_y = None + +selection_rectangle = None +selection = None + + +# ============================================================ +# MOUSE DOWN +# ============================================================ + +def mouse_down(event): + global start_x + global start_y + global selection_rectangle + + start_x = max( + 0, + min(display_width, event.x), + ) + + start_y = max( + 0, + min(display_height, event.y), + ) + + if selection_rectangle is not None: + canvas.delete(selection_rectangle) + + selection_rectangle = canvas.create_rectangle( + start_x, + start_y, + start_x, + start_y, + outline="red", + width=3, + ) + + +# ============================================================ +# MOUSE MOVE +# ============================================================ + +def mouse_move(event): + if ( + start_x is None + or start_y is None + or selection_rectangle is None + ): + return + + x = max( + 0, + min(display_width, event.x), + ) + + y = max( + 0, + min(display_height, event.y), + ) + + canvas.coords( + selection_rectangle, + start_x, + start_y, + x, + y, + ) + + +# ============================================================ +# MOUSE UP +# +# IMPORTANT: +# Do NOT close the window here. +# +# We wait for ENTER. +# ============================================================ + +def mouse_up(event): + global selection + + if start_x is None or start_y is None: + return + + end_x = max( + 0, + min(display_width, event.x), + ) + + end_y = max( + 0, + min(display_height, event.y), + ) + + left = min(start_x, end_x) + top = min(start_y, end_y) + + right = max(start_x, end_x) + bottom = max(start_y, end_y) + + original_left = int(left / scale) + original_top = int(top / scale) + + original_right = int(right / scale) + original_bottom = int(bottom / scale) + + width = original_right - original_left + height = original_bottom - original_top + + if width <= 0 or height <= 0: + selection = None + return + + selection = ( + original_left, + original_top, + width, + height, + ) + + print() + print("Selection made:") + print( + f" x={original_left}" + f" y={original_top}" + f" width={width}" + f" height={height}" + ) + print() + print("Press ENTER to save.") + print("Press ESC to cancel.") + + +# ============================================================ +# ACCEPT +# ============================================================ + +def accept(event=None): + if selection is None: + print() + print("No selection has been made yet.") + print("Drag a rectangle first.") + return + + root.destroy() + + +# ============================================================ +# CANCEL +# ============================================================ + +def cancel(event=None): + global selection + + selection = None + root.destroy() + + +# ============================================================ +# BINDINGS +# ============================================================ + +canvas.bind( + "", + mouse_down, +) + +canvas.bind( + "", + mouse_move, +) + +canvas.bind( + "", + mouse_up, +) + +root.bind( + "", + accept, +) + +root.bind( + "", + accept, +) + +root.bind( + "", + cancel, +) + +root.protocol( + "WM_DELETE_WINDOW", + cancel, +) + + +# ============================================================ +# RUN +# ============================================================ + +root.mainloop() + + +# ============================================================ +# CHECK RESULT +# ============================================================ + +if selection is None: + print() + print("Selection cancelled.") + sys.exit(1) + + +x, y, width, height = selection + + +# ============================================================ +# CROP +# ============================================================ + +crop = monitor[ + y:y + height, + x:x + width, +].copy() + + +# ============================================================ +# SAVE +# ============================================================ + +OUTPUT_DIR.mkdir( + parents=True, + exist_ok=True, +) + +output_path = ( + OUTPUT_DIR / f"{name}.png" +) + +if not cv2.imwrite( + str(output_path), + crop, +): + print("Could not save template:") + print(output_path) + sys.exit(1) + + +# ============================================================ +# RESULT +# ============================================================ + +print() +print("==================================================") +print("TEMPLATE SAVED") +print("==================================================") +print() +print("Template:") +print(f" {output_path}") +print() +print("Template size:") +print(f" {width} x {height}") +print() +print("Position in 3840x1080 monitor:") +print(f" x={x}") +print(f" y={y}") +print() +print("Source screenshot:") +print(f" {screenshot_path}") +print() +print("The template is now in the same coordinate") +print("system and screenshot pipeline as production.") +print() diff --git a/development/game_monitor_detector.py b/development/game_monitor_detector.py new file mode 100755 index 0000000..e251d39 --- /dev/null +++ b/development/game_monitor_detector.py @@ -0,0 +1,685 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +import cv2 +import re +import subprocess + +from .sample_tools.config import ( + SAMPLES_DIR, + DESKTOP_SAMPLES_DIR, +) +from .sample_tools.screenshots import capture_full_desktop + + +# ------------------------------------------------------------ +# DETECTOR CONFIGURATION +# ------------------------------------------------------------ + +# These are deliberately samples that represent distinctive +# pieces of the actual game UI. +# +# We do NOT require all of them to be visible. +# +# One excellent match is allowed to identify the monitor. +ANCHOR_SAMPLES = [ + "army", + "lookup", + "clan", + "city", + "watchtower", + "exit_shop", + "gifts", + "exchange", +] + +# TM_SQDIFF_NORMED: +# lower score = better match +# +# <= this is considered an excellent match. +EXCELLENT_THRESHOLD = 0.01 + +# <= this is considered potentially useful, but not enough +# by itself to automatically select a monitor. +REVIEW_THRESHOLD = 0.20 + +# If the best monitor beats the second-best monitor by at +# least this amount, that increases confidence. +MIN_MARGIN = 0.05 + + +# ------------------------------------------------------------ +# SAMPLE LOADING +# ------------------------------------------------------------ + +def sample_path(name): + """ + Return the desktop-specific sample when available, + otherwise fall back to the standard sample. + """ + + if not name.endswith(".png"): + name = f"{name}.png" + + desktop_path = DESKTOP_SAMPLES_DIR / name + + if desktop_path.exists(): + return desktop_path + + standard_path = SAMPLES_DIR / name + + if standard_path.exists(): + return standard_path + + return None + + +def load_samples(names): + """ + Load all available detector samples. + + Missing samples are skipped rather than making the + entire detector fail. + """ + + samples = {} + + for name in names: + path = sample_path(name) + + if path is None: + print(f" WARNING: sample not found: {name}") + continue + + image = cv2.imread(str(path)) + + if image is None: + print(f" WARNING: could not read sample: {path}") + continue + + samples[name] = image + + return samples + + +# ------------------------------------------------------------ +# MONITOR GEOMETRY +# ------------------------------------------------------------ + +def get_monitors(): + """ + Return enabled COSMIC monitor geometry using cosmic-randr. + + cosmic-randr reports: + - logical desktop position + - physical/current mode + - output scale + + The COSMIC screenshot is in logical desktop coordinates, + so current mode dimensions are divided by scale. + + Example: + + 4096 x 2160 at scale 1.50 + becomes + 2731 x 1440 logical pixels. + + The Xwayland-primary output is placed first so monitor + numbering remains consistent with the previous behavior. + """ + + result = subprocess.run( + [ + "cosmic-randr", + "list", + "--kdl", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError( + "cosmic-randr failed.\n\n" + f"stdout:\n{result.stdout}\n\n" + f"stderr:\n{result.stderr}" + ) + + monitors = [] + current = None + + output_pattern = re.compile( + r'^output "([^"]+)" enabled=#(true|false) \{$' + ) + + position_pattern = re.compile( + r'^\s*position (-?\d+) (-?\d+)\s*$' + ) + + scale_pattern = re.compile( + r'^\s*scale ([0-9.]+)\s*$' + ) + + primary_pattern = re.compile( + r'^\s*xwayland_primary #(true|false)\s*$' + ) + + mode_pattern = re.compile( + r'^\s*mode (\d+) (\d+) \d+.*current=#true' + ) + + def finish_monitor(monitor): + if monitor is None: + return + + if not monitor["enabled"]: + return + + required = [ + "left", + "top", + "scale", + "mode_width", + "mode_height", + ] + + missing = [ + key + for key in required + if monitor.get(key) is None + ] + + if missing: + raise RuntimeError( + f"Incomplete cosmic-randr data for " + f"{monitor['output']}: " + f"{', '.join(missing)}" + ) + + logical_width = round( + monitor["mode_width"] + / monitor["scale"] + ) + + logical_height = round( + monitor["mode_height"] + / monitor["scale"] + ) + + monitors.append({ + "left": monitor["left"], + "top": monitor["top"], + "width": logical_width, + "height": logical_height, + "output": monitor["output"], + "scale": monitor["scale"], + "primary": monitor["primary"], + "mode_width": monitor["mode_width"], + "mode_height": monitor["mode_height"], + }) + + for line in result.stdout.splitlines(): + output_match = output_pattern.match(line) + + if output_match: + finish_monitor(current) + + current = { + "output": output_match.group(1), + "enabled": ( + output_match.group(2) + == "true" + ), + "left": None, + "top": None, + "scale": None, + "primary": False, + "mode_width": None, + "mode_height": None, + } + + continue + + if current is None: + continue + + position_match = position_pattern.match(line) + + if position_match: + current["left"] = int( + position_match.group(1) + ) + + current["top"] = int( + position_match.group(2) + ) + + continue + + scale_match = scale_pattern.match(line) + + if scale_match: + current["scale"] = float( + scale_match.group(1) + ) + + continue + + primary_match = primary_pattern.match(line) + + if primary_match: + current["primary"] = ( + primary_match.group(1) + == "true" + ) + + continue + + mode_match = mode_pattern.match(line) + + if mode_match: + current["mode_width"] = int( + mode_match.group(1) + ) + + current["mode_height"] = int( + mode_match.group(2) + ) + + finish_monitor(current) + + if not monitors: + raise RuntimeError( + "cosmic-randr returned no enabled monitors." + ) + + # Preserve the previous monitor ordering: + # primary first, then the remaining outputs. + monitors.sort( + key=lambda monitor: ( + not monitor["primary"], + ) + ) + + for index, monitor in enumerate( + monitors, + start=1, + ): + monitor["index"] = index + + return monitors + + +# ------------------------------------------------------------ +# IMAGE CROPPING +# ------------------------------------------------------------ + +def crop_monitor(full_image, monitor): + """ + Crop one physical monitor out of the full COSMIC screenshot. + """ + + height, width = full_image.shape[:2] + + left = monitor["left"] + top = monitor["top"] + right = left + monitor["width"] + bottom = top + monitor["height"] + + if left < 0 or top < 0: + raise RuntimeError( + f"Monitor {monitor['index']} has negative coordinates: " + f"{left}, {top}" + ) + + if right > width or bottom > height: + raise RuntimeError( + "Monitor is outside captured desktop.\n" + f"Screenshot: {width} x {height}\n" + f"Monitor: {monitor['width']} x {monitor['height']}\n" + f"Position: {left}, {top}\n" + ) + + return full_image[ + top:bottom, + left:right, + ].copy() + + +# ------------------------------------------------------------ +# TEMPLATE MATCHING +# ------------------------------------------------------------ + +def match_sample(image, template): + """ + Return the best TM_SQDIFF_NORMED score and location. + """ + + if ( + template.shape[1] > image.shape[1] + or template.shape[0] > image.shape[0] + ): + return None + + result = cv2.matchTemplate( + image, + template, + cv2.TM_SQDIFF_NORMED, + ) + + min_val, _, min_loc, _ = cv2.minMaxLoc(result) + + return { + "score": float(min_val), + "x": int(min_loc[0]), + "y": int(min_loc[1]), + } + + +# ------------------------------------------------------------ +# MONITOR SCORING +# ------------------------------------------------------------ + +def score_monitor(image, samples): + """ + Match every detector sample against one monitor. + + Returns matches sorted from best to worst. + """ + + matches = [] + + for name, template in samples.items(): + match = match_sample(image, template) + + if match is None: + continue + + matches.append({ + "name": name, + **match, + }) + + matches.sort(key=lambda item: item["score"]) + + return matches + + +def monitor_confidence(matches): + """ + Calculate a simple confidence score. + + The best match dominates. + + Additional strong matches provide supporting evidence, + but are NOT required. + + Higher is better. + """ + + if not matches: + return 0.0 + + confidence = 0.0 + + weights = [ + 1.00, + 0.50, + 0.25, + 0.125, + ] + + for match, weight in zip(matches[:4], weights): + score = match["score"] + + # Convert score into a 0..1 quality value. + # + # 0.00 score -> 1.00 quality + # 0.20 score -> 0.00 quality + quality = max( + 0.0, + 1.0 - (score / REVIEW_THRESHOLD), + ) + + confidence += quality * weight + + return confidence + + +# ------------------------------------------------------------ +# DETECTION +# ------------------------------------------------------------ + +def detect_game_monitor(verbose=True): + """ + Capture the entire COSMIC desktop and determine which + physical monitor contains the game. + + Returns: + + { + "monitor": {...}, + "confidence": float, + "matches": [...] + } + + Raises RuntimeError if no sufficiently strong monitor + can be identified. + """ + + if verbose: + print("Loading detector samples...") + + samples = load_samples(ANCHOR_SAMPLES) + + if not samples: + raise RuntimeError( + "No detector samples could be loaded." + ) + + if verbose: + print(f"Loaded {len(samples)} samples:") + for name in samples: + print(f" {name}") + + if verbose: + print() + print("Capturing full COSMIC desktop...") + + full_image, screenshot_path = capture_full_desktop() + + if verbose: + print(f"Screenshot: {screenshot_path}") + print( + f"Size: {full_image.shape[1]} x " + f"{full_image.shape[0]}" + ) + + monitors = get_monitors() + + results = [] + + print() + print("=" * 60) + print("=== GAME MONITOR SCAN ===") + print("=" * 60) + + for monitor in monitors: + image = crop_monitor( + full_image, + monitor, + ) + + matches = score_monitor( + image, + samples, + ) + + confidence = monitor_confidence( + matches, + ) + + best = matches[0] if matches else None + + result = { + "monitor": monitor, + "confidence": confidence, + "matches": matches, + } + + results.append(result) + + print() + print( + f"Monitor {monitor['index']}: " + f"{monitor['width']} x {monitor['height']} " + f"at ({monitor['left']}, {monitor['top']})" + ) + + if monitor.get("output"): + print(f" Output: {monitor['output']}") + + print(f" Confidence: {confidence:.6f}") + + if best: + print( + f" BEST: {best['name']} " + f"score={best['score']:.6f} " + f"location=({best['x']},{best['y']})" + ) + + print() + print(" Best matches:") + + for match in matches[:5]: + print( + f" {match['name']:25s} " + f"score={match['score']:.6f} " + f"location=({match['x']},{match['y']})" + ) + + # -------------------------------------------------------- + # SELECT BEST MONITOR + # -------------------------------------------------------- + + results.sort( + key=lambda result: result["confidence"], + reverse=True, + ) + + best_result = results[0] + + if len(results) > 1: + second_result = results[1] + margin = ( + best_result["confidence"] + - second_result["confidence"] + ) + else: + margin = best_result["confidence"] + + best_match = ( + best_result["matches"][0] + if best_result["matches"] + else None + ) + + best_score = ( + best_match["score"] + if best_match + else 1.0 + ) + + # -------------------------------------------------------- + # CONFIDENCE DECISION + # -------------------------------------------------------- + + # + # A very strong template match is enough by itself. + # + # This is important for screens such as the shop where + # only one or two recognizable samples may be visible. + # + confident = ( + best_score <= EXCELLENT_THRESHOLD + or ( + best_score <= REVIEW_THRESHOLD + and margin >= MIN_MARGIN + ) + ) + + print() + print("=" * 60) + print("=== DETECTION RESULT ===") + print("=" * 60) + print() + + monitor = best_result["monitor"] + + print( + f"Best monitor: {monitor['index']}" + ) + + if monitor.get("output"): + print(f"Output: {monitor['output']}") + + print( + f"Position: " + f"({monitor['left']}, {monitor['top']})" + ) + + print( + f"Size: " + f"{monitor['width']} x {monitor['height']}" + ) + + print( + f"Confidence: " + f"{best_result['confidence']:.6f}" + ) + + print( + f"Best score: " + f"{best_score:.6f}" + ) + + print( + f"Monitor margin:" + f" {margin:.6f}" + ) + + if confident: + print() + print("✓ CONFIDENT GAME MONITOR FOUND") + + return { + "monitor": monitor, + "confidence": best_result["confidence"], + "best_score": best_score, + "margin": margin, + "matches": best_result["matches"], + } + + print() + print("✗ NO CONFIDENT GAME MONITOR FOUND") + print() + print( + "The detector will NOT guess a monitor." + ) + + return None + + +# ------------------------------------------------------------ +# COMMAND LINE TEST +# ------------------------------------------------------------ + +if __name__ == "__main__": + result = detect_game_monitor() + + if result: + print() + print("Detected monitor:") + print(result["monitor"]) diff --git a/development/sample_tools/__init__.py b/development/sample_tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/development/sample_tools/config.py b/development/sample_tools/config.py new file mode 100644 index 0000000..21b0657 --- /dev/null +++ b/development/sample_tools/config.py @@ -0,0 +1,56 @@ +from pathlib import Path +import cv2 + +# ------------------------------------------------------------ +# PROJECT DIRECTORIES +# ------------------------------------------------------------ + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +SAMPLES_DIR = PROJECT_ROOT / "samples" +DESKTOP_SAMPLES_DIR = PROJECT_ROOT / "samples_desktop" + +DEVELOPMENT_DIR = PROJECT_ROOT / "development" +SCREENSHOT_DIR = DEVELOPMENT_DIR / "cosmic_test" + + +# ------------------------------------------------------------ +# MONITOR +# ------------------------------------------------------------ + +MONITOR_LEFT = 2730 +MONITOR_TOP = 0 +MONITOR_WIDTH = 3840 +MONITOR_HEIGHT = 1080 + + +# ------------------------------------------------------------ +# TEMPLATE MATCHING +# ------------------------------------------------------------ + +MATCH_METHOD = cv2.TM_SQDIFF_NORMED + +# Excellent match. Automatically considered good. +AUTO_ACCEPT_THRESHOLD = 0.01 + +# Below this is potentially usable but should be shown +# to the user for confirmation. +REVIEW_THRESHOLD = 0.50 + + +# ------------------------------------------------------------ +# FILE TYPES +# ------------------------------------------------------------ + +SAMPLE_EXTENSION = ".png" + + +# ------------------------------------------------------------ +# DISPLAY +# ------------------------------------------------------------ + +REFERENCE_WINDOW_WIDTH = 900 +REFERENCE_WINDOW_HEIGHT = 700 + +SCREENSHOT_WINDOW_WIDTH = 1600 +SCREENSHOT_WINDOW_HEIGHT = 900 diff --git a/development/sample_tools/matching.py b/development/sample_tools/matching.py new file mode 100644 index 0000000..3e1c7e9 --- /dev/null +++ b/development/sample_tools/matching.py @@ -0,0 +1,106 @@ +import cv2 +import numpy as np + + +def match_template( + screenshot, + template, +): + """ + Find the best match for a template inside a screenshot. + + Uses TM_SQDIFF_NORMED, where LOWER scores are better. + + Returns: + dict containing: + score + x + y + width + height + """ + + if screenshot is None: + raise ValueError("Screenshot is None.") + + if template is None: + raise ValueError("Template is None.") + + screenshot_height, screenshot_width = screenshot.shape[:2] + template_height, template_width = template.shape[:2] + + if template_width > screenshot_width: + raise ValueError( + "Template is wider than screenshot." + ) + + if template_height > screenshot_height: + raise ValueError( + "Template is taller than screenshot." + ) + + result = cv2.matchTemplate( + screenshot, + template, + cv2.TM_SQDIFF_NORMED, + ) + + min_val, _, min_loc, _ = cv2.minMaxLoc(result) + + return { + "score": float(min_val), + "x": int(min_loc[0]), + "y": int(min_loc[1]), + "width": int(template_width), + "height": int(template_height), + } + + +def draw_match( + screenshot, + match, + color=(0, 0, 255), + thickness=3, +): + """ + Return a copy of the screenshot with the match rectangle drawn. + """ + + result = screenshot.copy() + + x = match["x"] + y = match["y"] + w = match["width"] + h = match["height"] + + cv2.rectangle( + result, + (x, y), + (x + w, y + h), + color, + thickness, + ) + + return result + + +def crop_match( + screenshot, + match, +): + """ + Crop the exact region identified by a match. + + Returns: + numpy.ndarray + """ + + x = match["x"] + y = match["y"] + w = match["width"] + h = match["height"] + + return screenshot[ + y:y + h, + x:x + w, + ].copy() diff --git a/development/sample_tools/samples.py b/development/sample_tools/samples.py new file mode 100644 index 0000000..43f96d8 --- /dev/null +++ b/development/sample_tools/samples.py @@ -0,0 +1,113 @@ +from pathlib import Path + +import cv2 + +from .config import ( + SAMPLES_DIR, + DESKTOP_SAMPLES_DIR, +) + + +def sample_path(name, desktop=True): + """ + Return the appropriate sample path. + + When desktop=True: + 1. Prefer samples_desktop/name.png + 2. Fall back to samples/name.png + + When desktop=False: + Use samples/name.png directly. + """ + + if not name.endswith(".png"): + name = f"{name}.png" + + if desktop: + desktop_path = DESKTOP_SAMPLES_DIR / name + + if desktop_path.exists(): + return desktop_path + + standard_path = SAMPLES_DIR / name + + if standard_path.exists(): + return standard_path + + raise FileNotFoundError( + f"Sample not found:\n" + f" Desktop: {DESKTOP_SAMPLES_DIR / name}\n" + f" Standard: {standard_path}" + ) + + +def get_sample(name, desktop=True): + """ + Load a sample image. + + Returns: + numpy.ndarray + """ + + path = sample_path( + name, + desktop=desktop, + ) + + image = cv2.imread( + str(path) + ) + + if image is None: + raise RuntimeError( + f"Could not read sample: {path}" + ) + + return image + + +def sample_source(name): + """ + Return where the active sample comes from. + + Returns: + 'DESKTOP' or 'STANDARD' + """ + + if not name.endswith(".png"): + name = f"{name}.png" + + desktop_path = ( + DESKTOP_SAMPLES_DIR / name + ) + + if desktop_path.exists(): + return "DESKTOP" + + standard_path = ( + SAMPLES_DIR / name + ) + + if standard_path.exists(): + return "STANDARD" + + return "MISSING" + + +def list_samples(): + """ + Return the standard sample names. + + Desktop samples are deliberately NOT used to + generate the list because the standard folder + defines what samples the application knows about. + """ + + files = sorted( + SAMPLES_DIR.glob("*.png") + ) + + return [ + path.stem + for path in files + ] diff --git a/development/sample_tools/screenshots.py b/development/sample_tools/screenshots.py new file mode 100644 index 0000000..bcd88c1 --- /dev/null +++ b/development/sample_tools/screenshots.py @@ -0,0 +1,255 @@ +from datetime import datetime +from pathlib import Path +import glob +import os +import subprocess +import time + +import cv2 + +from .config import ( + MONITOR_LEFT, + MONITOR_TOP, + MONITOR_WIDTH, + MONITOR_HEIGHT, + SCREENSHOT_DIR, +) + + +def capture_full_desktop(): + """ + Capture the full COSMIC desktop using cosmic-screenshot. + + Returns: + tuple[numpy.ndarray, Path]: + Full desktop BGR image and screenshot path. + """ + + SCREENSHOT_DIR.mkdir( + parents=True, + exist_ok=True, + ) + + before = set( + Path(p) + for p in glob.glob( + str(SCREENSHOT_DIR / "Screenshot_*.png") + ) + ) + + env = os.environ.copy() + + # Use COSMIC/Wayland rather than X11. + env.pop("DISPLAY", None) + env.pop("XAUTHORITY", None) + + command = [ + "cosmic-screenshot", + "--interactive=false", + "--modal=false", + "--notify=false", + "-s", + str(SCREENSHOT_DIR), + ] + + result = subprocess.run( + command, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError( + "cosmic-screenshot failed.\n\n" + f"stdout:\n{result.stdout}\n\n" + f"stderr:\n{result.stderr}" + ) + + # Give COSMIC a moment to finish writing the PNG. + time.sleep(0.3) + + files = [ + Path(p) + for p in glob.glob( + str(SCREENSHOT_DIR / "Screenshot_*.png") + ) + ] + + if not files: + raise RuntimeError( + "cosmic-screenshot completed, " + "but no screenshot was created." + ) + + new_files = [ + p for p in files + if p not in before + ] + + if new_files: + screenshot_path = max( + new_files, + key=lambda p: p.stat().st_mtime, + ) + else: + screenshot_path = max( + files, + key=lambda p: p.stat().st_mtime, + ) + + image = cv2.imread( + str(screenshot_path) + ) + + if image is None: + raise RuntimeError( + f"Could not read screenshot:\n" + f"{screenshot_path}" + ) + + return image, screenshot_path + + +def crop_monitor(full_image): + """ + Crop the configured DP-1 monitor from a full COSMIC desktop + screenshot. + + Returns: + numpy.ndarray: BGR image exactly + MONITOR_WIDTH x MONITOR_HEIGHT. + """ + + height, width = full_image.shape[:2] + + right = MONITOR_LEFT + MONITOR_WIDTH + bottom = MONITOR_TOP + MONITOR_HEIGHT + + if right > width or bottom > height: + raise RuntimeError( + "Configured monitor is outside screenshot.\n" + f"Screenshot: {width} x {height}\n" + f"Monitor: {MONITOR_WIDTH} x " + f"{MONITOR_HEIGHT}\n" + f"Offset: {MONITOR_LEFT}, " + f"{MONITOR_TOP}" + ) + + return full_image[ + MONITOR_TOP:bottom, + MONITOR_LEFT:right, + ].copy() + + +def capture_monitor(): + """ + Capture the configured monitor using the same screenshot + mechanism as production. + + COSMIC captures the full desktop first, then this function + crops DP-1 from that image. + + Returns: + numpy.ndarray: BGR image exactly + MONITOR_WIDTH x MONITOR_HEIGHT. + """ + + full_image, _ = capture_full_desktop() + + return crop_monitor(full_image) + + +def save_monitor_screenshot(image): + """ + Save a monitor screenshot into development/cosmic_test. + + Returns: + Path: path to saved screenshot. + """ + + SCREENSHOT_DIR.mkdir( + parents=True, + exist_ok=True, + ) + + timestamp = datetime.now().strftime( + "%Y-%m-%d_%H-%M-%S" + ) + + path = ( + SCREENSHOT_DIR + / f"Screenshot_{timestamp}.png" + ) + + if not cv2.imwrite( + str(path), + image, + ): + raise RuntimeError( + f"Could not save screenshot: {path}" + ) + + return path + + +def capture_and_save(): + """ + Capture the configured monitor and save it. + + Returns: + tuple[numpy.ndarray, Path] + """ + + image = capture_monitor() + path = save_monitor_screenshot(image) + + return image, path + + +def latest_screenshot(): + """ + Return the most recently saved COSMIC monitor screenshot. + + Returns: + Path + """ + + files = list( + SCREENSHOT_DIR.glob( + "Screenshot_*.png" + ) + ) + + if not files: + raise FileNotFoundError( + f"No screenshots found in {SCREENSHOT_DIR}" + ) + + return max( + files, + key=lambda path: path.stat().st_mtime, + ) + + +def load_latest_screenshot(): + """ + Load the most recently saved monitor screenshot. + + Returns: + tuple[numpy.ndarray, Path] + """ + + path = latest_screenshot() + + image = cv2.imread( + str(path) + ) + + if image is None: + raise RuntimeError( + f"Could not read screenshot: {path}" + ) + + return image, path diff --git a/game_monitor.py b/game_monitor.py new file mode 100644 index 0000000..968b5d5 --- /dev/null +++ b/game_monitor.py @@ -0,0 +1,1838 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import glob +import os +import re +import subprocess +import tempfile +import time +import sys + +import cv2 + + +PROJECT_ROOT = Path(__file__).resolve().parent + +SAMPLES_DIR = PROJECT_ROOT / "samples" +DESKTOP_SAMPLES_DIR = PROJECT_ROOT / "samples_desktop" + + +ANCHOR_SAMPLES = [ + "army", + "lookup", + "clan", + "city", + "watchtower", + "exit_shop", + "gifts", + "exchange", +] + +EXCELLENT_THRESHOLD = 0.01 +REVIEW_THRESHOLD = 0.20 +MIN_MARGIN = 0.05 + + +# ============================================================ +# SCREEN CAPTURE +# ============================================================ + +def capture_full_desktop_linux( + attempts=3, + retry_wait=0.50, +): + """ + Capture the full COSMIC desktop. + + Returns: + numpy.ndarray: + Full desktop BGR image. + + Production captures use a temporary directory so + screenshots do not accumulate on disk. + + COSMIC's screenshot portal can occasionally fail + transiently during long-running automation. Retry the + complete capture with a fresh temporary directory before + treating the failure as fatal. + """ + + env = os.environ.copy() + + # Force cosmic-screenshot to use the Wayland/COSMIC + # environment rather than attempting X11. + env.pop("DISPLAY", None) + env.pop("XAUTHORITY", None) + + attempts = max( + 1, + int(attempts), + ) + + last_error = None + + for attempt in range( + 1, + attempts + 1, + ): + + try: + + # Each attempt gets its own temporary directory. + # A failed attempt therefore cannot expose stale or + # partially-written screenshot files to a retry. + with tempfile.TemporaryDirectory( + prefix="tba_capture_" + ) as temp_dir: + + command = [ + "cosmic-screenshot", + "--interactive=false", + "--modal=false", + "--notify=false", + "-s", + temp_dir, + ] + + result = subprocess.run( + command, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError( + "cosmic-screenshot failed.\n\n" + f"stdout:\n{result.stdout}\n\n" + f"stderr:\n{result.stderr}" + ) + + # COSMIC writes the PNG asynchronously after the + # process returns, so allow a short moment for it. + time.sleep(0.3) + + files = glob.glob( + os.path.join( + temp_dir, + "Screenshot_*.png", + ) + ) + + if not files: + raise RuntimeError( + "cosmic-screenshot completed but " + "created no screenshot." + ) + + screenshot_path = max( + files, + key=os.path.getmtime, + ) + + image = cv2.imread( + screenshot_path + ) + + if image is None: + raise RuntimeError( + "Could not read COSMIC screenshot." + ) + + # Only report recovery when an earlier attempt + # actually failed. Normal screenshots stay quiet. + if attempt > 1: + print( + f"COSMIC screenshot recovered " + f"on attempt {attempt}/{attempts}." + ) + + return image + + except RuntimeError as exc: + + last_error = exc + + if attempt >= attempts: + break + + print( + f"COSMIC screenshot attempt " + f"{attempt}/{attempts} failed; retrying..." + ) + + # Small increasing delay: + # + # attempt 1 -> 0.50s + # attempt 2 -> 1.00s + # + # This gives the portal time to recover without + # materially slowing normal operation. + time.sleep( + float(retry_wait) + * attempt + ) + + raise RuntimeError( + f"COSMIC screenshot failed after " + f"{attempts} attempts.\n\n" + f"Last error:\n{last_error}" + ) + + +# ============================================================ +# COSMIC MONITOR GEOMETRY +# ============================================================ + +def get_monitors_linux(): + """ + Return enabled COSMIC outputs in logical desktop + coordinates using cosmic-randr. + """ + + result = subprocess.run( + [ + "cosmic-randr", + "list", + "--kdl", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError( + "cosmic-randr failed.\n\n" + f"stdout:\n{result.stdout}\n\n" + f"stderr:\n{result.stderr}" + ) + + monitors = [] + current = None + + output_pattern = re.compile( + r'^output "([^"]+)" enabled=#(true|false) \{$' + ) + + position_pattern = re.compile( + r'^\s*position (-?\d+) (-?\d+)\s*$' + ) + + scale_pattern = re.compile( + r'^\s*scale ([0-9.]+)\s*$' + ) + + primary_pattern = re.compile( + r'^\s*xwayland_primary #(true|false)\s*$' + ) + + mode_pattern = re.compile( + r'^\s*mode (\d+) (\d+) \d+.*current=#true' + ) + + def finish_monitor(monitor): + if monitor is None: + return + + if not monitor["enabled"]: + return + + required = [ + "left", + "top", + "scale", + "mode_width", + "mode_height", + ] + + missing = [ + key + for key in required + if monitor.get(key) is None + ] + + if missing: + raise RuntimeError( + f"Incomplete cosmic-randr data for " + f"{monitor['output']}: " + f"{', '.join(missing)}" + ) + + logical_width = round( + monitor["mode_width"] + / monitor["scale"] + ) + + logical_height = round( + monitor["mode_height"] + / monitor["scale"] + ) + + monitors.append({ + "left": monitor["left"], + "top": monitor["top"], + "width": logical_width, + "height": logical_height, + "output": monitor["output"], + "scale": monitor["scale"], + "primary": monitor["primary"], + "mode_width": monitor["mode_width"], + "mode_height": monitor["mode_height"], + }) + + for line in result.stdout.splitlines(): + + output_match = output_pattern.match( + line + ) + + if output_match: + finish_monitor(current) + + current = { + "output": output_match.group(1), + "enabled": ( + output_match.group(2) + == "true" + ), + "left": None, + "top": None, + "scale": None, + "primary": False, + "mode_width": None, + "mode_height": None, + } + + continue + + if current is None: + continue + + match = position_pattern.match(line) + + if match: + current["left"] = int( + match.group(1) + ) + current["top"] = int( + match.group(2) + ) + continue + + match = scale_pattern.match(line) + + if match: + current["scale"] = float( + match.group(1) + ) + continue + + match = primary_pattern.match(line) + + if match: + current["primary"] = ( + match.group(1) == "true" + ) + continue + + match = mode_pattern.match(line) + + if match: + current["mode_width"] = int( + match.group(1) + ) + current["mode_height"] = int( + match.group(2) + ) + + finish_monitor(current) + + if not monitors: + raise RuntimeError( + "cosmic-randr returned no enabled monitors." + ) + + # Keep the primary display first. + monitors.sort( + key=lambda monitor: ( + not monitor["primary"], + ) + ) + + for index, monitor in enumerate( + monitors, + start=1, + ): + monitor["index"] = index + + return monitors + + +# ============================================================ +# PLATFORM SCREEN BACKEND +# ============================================================ + +if sys.platform == "win32": + from screen_backend_windows import ( + capture_full_desktop, + find_total_battle_windows, + get_monitors, + get_virtual_desktop, + ) +else: + capture_full_desktop = capture_full_desktop_linux + get_monitors = get_monitors_linux + + +# ============================================================ +# MONITOR CROP +# ============================================================ + +def crop_monitor(full_image, monitor): + """ + Crop one logical COSMIC output from a full-desktop + screenshot. + """ + + height, width = full_image.shape[:2] + + # Linux logical desktop coordinates normally map directly + # into the captured image. Windows virtual-desktop coordinates + # may be negative, so the Windows backend supplies normalized + # capture-relative coordinates. + left = monitor.get( + "capture_left", + monitor["left"], + ) + + top = monitor.get( + "capture_top", + monitor["top"], + ) + + right = left + monitor["width"] + bottom = top + monitor["height"] + + if ( + left < 0 + or top < 0 + or right > width + or bottom > height + ): + raise RuntimeError( + "Monitor lies outside captured desktop.\n" + f"Desktop: {width} x {height}\n" + f"Monitor: {monitor}" + ) + + return full_image[ + top:bottom, + left:right, + ].copy() + + +def capture_game_monitor(monitor): + """ + Capture only the already-detected game monitor. + + cosmic-screenshot captures the full desktop and this + function crops the requested logical output. + """ + + full_image = capture_full_desktop() + + return crop_monitor( + full_image, + monitor, + ) + + +# ============================================================ +# SAMPLE LOADING +# ============================================================ + +def sample_path(name): + + if not name.endswith(".png"): + name = f"{name}.png" + + desktop_path = ( + DESKTOP_SAMPLES_DIR / name + ) + + if desktop_path.exists(): + return desktop_path + + standard_path = ( + SAMPLES_DIR / name + ) + + if standard_path.exists(): + return standard_path + + return None + + +def load_samples(names): + + samples = {} + + for name in names: + + path = sample_path(name) + + if path is None: + continue + + image = cv2.imread( + str(path) + ) + + if image is None: + continue + + samples[name] = image + + return samples + + +# ============================================================ +# TEMPLATE MATCHING +# ============================================================ + +def match_sample(image, template): + + if ( + template.shape[1] > image.shape[1] + or template.shape[0] > image.shape[0] + ): + return None + + result = cv2.matchTemplate( + image, + template, + cv2.TM_SQDIFF_NORMED, + ) + + min_val, _, min_loc, _ = ( + cv2.minMaxLoc(result) + ) + + return { + "score": float(min_val), + "x": int(min_loc[0]), + "y": int(min_loc[1]), + } + + +def score_monitor(image, samples): + + matches = [] + + for name, template in samples.items(): + + match = match_sample( + image, + template, + ) + + if match is None: + continue + + matches.append({ + "name": name, + **match, + }) + + matches.sort( + key=lambda item: item["score"] + ) + + return matches + + +def monitor_confidence(matches): + + if not matches: + return 0.0 + + confidence = 0.0 + + weights = [ + 1.00, + 0.50, + 0.25, + 0.125, + ] + + for match, weight in zip( + matches[:4], + weights, + ): + quality = max( + 0.0, + 1.0 + - ( + match["score"] + / REVIEW_THRESHOLD + ), + ) + + confidence += ( + quality * weight + ) + + return confidence + + +# ============================================================ +# WINDOWS WINDOW CROP / VERIFICATION +# ============================================================ + +def _crop_windows_desktop_rect( + full_image, + rect, +): + """ + Crop the visible portion of one absolute Windows desktop + rectangle from the full virtual-desktop capture. + + This represents the pixels CURRENTLY visible on screen. + It cannot reveal pixels belonging to an obscured window. + """ + + virtual = get_virtual_desktop() + + image_height, image_width = ( + full_image.shape[:2] + ) + + left = int( + rect["left"] + - virtual["left"] + ) + + top = int( + rect["top"] + - virtual["top"] + ) + + right = left + int( + rect["width"] + ) + + bottom = top + int( + rect["height"] + ) + + crop_left = max( + 0, + left, + ) + + crop_top = max( + 0, + top, + ) + + crop_right = min( + image_width, + right, + ) + + crop_bottom = min( + image_height, + bottom, + ) + + if ( + crop_right <= crop_left + or crop_bottom <= crop_top + ): + return None + + return full_image[ + crop_top:crop_bottom, + crop_left:crop_right, + ].copy() + + +def _resize_sample_for_scale( + template, + sample_scale, +): + """ + Resize one reference sample for a candidate UI scale. + """ + + sample_scale = float( + sample_scale + ) + + if abs( + sample_scale - 1.0 + ) < 0.000001: + return template + + return cv2.resize( + template, + (0, 0), + fx=sample_scale, + fy=sample_scale, + interpolation=( + cv2.INTER_LINEAR + if sample_scale >= 1.0 + else cv2.INTER_AREA + ), + ) + + +def _windows_candidate_game_evidence_at_scale( + image, + samples, + sample_scale, +): + """ + Test one possible Total Battle UI scale. + + A valid scale requires one strong state anchor plus at + least two static HUD anchors. + """ + + matches = {} + + for name, template in samples.items(): + + scaled = _resize_sample_for_scale( + template, + sample_scale, + ) + + if ( + scaled.shape[0] > image.shape[0] + or scaled.shape[1] > image.shape[1] + ): + continue + + match = match_sample( + image, + scaled, + ) + + if match is None: + continue + + matches[name] = match + + state = None + state_anchor = None + + # -------------------------------------------------------- + # CHOOSE THE STRONGEST VALID STATE ANCHOR + # -------------------------------------------------------- + # + # More than one state template can occasionally fall below + # the broad REVIEW_THRESHOLD on the same screen. + # + # Previously we accepted the first passing template in this + # fixed order: + # + # SHOP -> CITY -> WORLD + # + # That allowed a weak false-positive SHOP match to override + # a much stronger WORLD match. + # + # TM_SQDIFF_NORMED is lower-is-better, so collect every + # state anchor that passes the existing threshold and use + # the lowest score. + # + # No thresholds are changed here. + # -------------------------------------------------------- + + valid_states = [] + + for name, value in ( + ("shop_loaded", "shop"), + ("map", "city"), + ("city", "world"), + ): + match = matches.get( + name + ) + + if ( + match is not None + and match["score"] + <= REVIEW_THRESHOLD + ): + valid_states.append( + ( + float( + match["score"] + ), + name, + value, + ) + ) + + if valid_states: + valid_states.sort( + key=lambda item: item[0] + ) + + ( + _, + state_anchor, + state, + ) = valid_states[0] + + supporting = [] + + for name in ( + "watchtower", + "army", + "clan", + ): + match = matches.get( + name + ) + + if ( + match is not None + and match["score"] + <= REVIEW_THRESHOLD + ): + supporting.append( + name + ) + + # -------------------------------------------------------- + # STATE-SPECIFIC VERIFICATION + # -------------------------------------------------------- + # + # WORLD and CITY display the normal static HUD, so retain + # the strong existing requirement: + # + # valid state anchor + at least two HUD anchors + # + # SHOP intentionally hides those World/City HUD controls. + # Requiring Watchtower/Army/Clan while Shop is open makes a + # legitimate Shop impossible to verify. + # + # Shop therefore uses a substantially stricter state-anchor + # requirement instead. + # + # Measured examples: + # + # real SHOP shop_loaded: ~0.013 + # WORLD false-positive: ~0.143 + # + # Keep a large safety margin between those clusters. + # -------------------------------------------------------- + + SHOP_STRONG_THRESHOLD = 0.050 + + if ( + state == "shop" + and state_anchor is not None + ): + verified = ( + matches[ + state_anchor + ][ + "score" + ] + <= SHOP_STRONG_THRESHOLD + ) + + else: + verified = ( + state is not None + and len(supporting) >= 2 + ) + + # Rank verified scales by the evidence that actually matters: + # the state anchor and supporting HUD controls. + evidence_scores = [] + + if state_anchor is not None: + evidence_scores.append( + matches[state_anchor]["score"] + ) + + for name in supporting: + evidence_scores.append( + matches[name]["score"] + ) + + if evidence_scores: + score = sum( + evidence_scores + ) / len( + evidence_scores + ) + else: + score = 1.0 + + return { + "verified": verified, + "state": state, + "state_anchor": state_anchor, + "supporting": supporting, + "matches": matches, + "sample_scale": float( + sample_scale + ), + "score": float( + score + ), + } + + +def _windows_candidate_game_evidence( + image, +): + """ + Verify a visible Windows Total Battle candidate and discover + the UI/template scale actually rendered inside it. + + Window/monitor dimensions are not sufficient to determine + game UI scale. Test several plausible scales and select the + strongest verified state + HUD combination. + """ + + names = [ + "shop_loaded", + "map", + "city", + "watchtower", + "army", + "clan", + ] + + samples = load_samples( + names + ) + + # -------------------------------------------------------- + # GEOMETRY-PREDICTED SCALE FAST PATH + # -------------------------------------------------------- + # + # For a standalone Total Battle client, rendered game width + # tracks the canonical 3840-wide game surface: + # + # predicted scale = visible candidate width / 3840 + # + # Example: + # + # 5120 / 3840 = 1.333333 + # + # Geometry is ONLY a prediction. It is never sufficient by + # itself to identify the game or UI scale. + # + # The predicted scale must still pass the exact same + # production visual verification used by the exhaustive + # search: + # + # - valid high-level state anchor + # - required supporting HUD anchors + # + # In addition, the fast path uses a deliberately stricter + # aggregate evidence score than the normal fallback. + # + # If this probe is not exceptionally convincing, execution + # falls through to the complete existing scale search below. + # -------------------------------------------------------- + + if ( + image is not None + and image.size + and image.shape[1] > 0 + ): + predicted_scale = ( + float( + image.shape[1] + ) + / 3840.0 + ) + + # Keep the prediction within a sane game-UI range. + # This does not constrain the exhaustive fallback. + if ( + 0.50 + <= predicted_scale + <= 3.00 + ): + fast_result = ( + _windows_candidate_game_evidence_at_scale( + image, + samples, + predicted_scale, + ) + ) + + # The normal verifier must pass AND the result must + # be substantially stronger than merely meeting the + # ordinary review threshold. + # + # Current known-good standalone evidence is roughly + # 0.009-0.010, leaving comfortable room below 0.020. + if ( + fast_result[ + "verified" + ] + and fast_result[ + "score" + ] + <= 0.020 + ): + fast_result[ + "scale_results" + ] = [ + fast_result + ] + + fast_result[ + "scale_discovery" + ] = ( + "geometry_verified_fast_path" + ) + + fast_result[ + "predicted_scale" + ] = float( + predicted_scale + ) + + return fast_result + + # Include the scales already observed in our desktop setups, + # plus intermediate values for windowed/browser configurations. + candidate_scales = [ + 0.75, + 0.80, + 0.85, + 0.90, + 0.95, + 1.00, + 1.05, + 1.10, + 1.15, + 1.20, + 1.25, + 4.0 / 3.0, + 1.40, + 1.50, + 1.60, + 1.75, + 2.00, + 2.25, + 2.50, + ] + + results = [] + + for sample_scale in candidate_scales: + + result = ( + _windows_candidate_game_evidence_at_scale( + image, + samples, + sample_scale, + ) + ) + + results.append( + result + ) + + verified_results = [ + result + for result in results + if result["verified"] + ] + + if not verified_results: + return { + "verified": False, + "state": None, + "state_anchor": None, + "supporting": [], + "matches": {}, + "sample_scale": None, + "score": 1.0, + "scale_results": results, + } + + verified_results.sort( + key=lambda result: + ( + result["score"], + -len(result["supporting"]), + ) + ) + + best = verified_results[0] + + best["scale_results"] = results + + return best + + + +# ============================================================ +# TOTAL BATTLE REFERENCE VIEWPORT GEOMETRY +# ============================================================ +# +# These values were calibrated from the standalone Total Battle +# client after normalizing its detected 1.333333 UI scale back +# to reference scale 1.0. +# +# Reference standalone client: +# +# rendered: 5120 x 1417 +# UI scale: 4/3 +# normalized: 3840 x 1062.75 +# +# The bottom HUD anchors are stable across World/City and allow +# an embedded browser game viewport to be solved without making +# assumptions about browser tabs/toolbars/chrome. +# ============================================================ + +REFERENCE_GAME_WIDTH = 3840.0 +REFERENCE_GAME_HEIGHT = 1062.75 + +REFERENCE_BOTTOM_HUD = { + "city": { + "x_from_left": 1624.50, + "y_from_bottom": 50.625, + }, + "watchtower": { + "x_from_left": 1648.875, + "y_from_bottom": 100.875, + }, + "army": { + "x_from_left": 1899.375, + "y_from_bottom": 80.625, + }, + "clan": { + "x_from_left": 1975.875, + "y_from_bottom": 76.875, + }, +} + + +def _match_center( + match, + template, +): + """ + Return template-match center in the searched image. + """ + + height, width = ( + template.shape[:2] + ) + + return ( + float(match["x"]) + + float(width) / 2.0, + float(match["y"]) + + float(height) / 2.0, + ) + + +def _median(values): + """ + Small dependency-free median helper. + """ + + values = sorted( + float(value) + for value in values + ) + + if not values: + return None + + middle = len(values) // 2 + + if len(values) % 2: + return values[ + middle + ] + + return ( + values[middle - 1] + + values[middle] + ) / 2.0 + + +def _solve_browser_game_viewport( + window, + evidence, +): + """ + Solve the actual Total Battle viewport inside a verified + browser client. + + The evidence match coordinates are relative to the browser + client crop. + + Each recognized bottom-HUD anchor independently predicts: + + game viewport left + game viewport bottom + + We use the median so one poor match cannot dominate the + geometry. + + Returns absolute Windows desktop geometry, or None. + """ + + if ( + window is None + or evidence is None + ): + return None + + sample_scale = evidence.get( + "sample_scale" + ) + + if sample_scale is None: + return None + + sample_scale = float( + sample_scale + ) + + matches = evidence.get( + "matches", + {}, + ) + + samples = load_samples( + list( + REFERENCE_BOTTOM_HUD.keys() + ) + ) + + left_estimates = [] + bottom_estimates = [] + used = [] + + for name, calibration in ( + REFERENCE_BOTTOM_HUD.items() + ): + + match = matches.get( + name + ) + + base_template = samples.get( + name + ) + + if ( + match is None + or base_template is None + ): + continue + + # Do not allow a merely mediocre false match to become + # geometric calibration evidence. + if ( + float(match["score"]) + > REVIEW_THRESHOLD + ): + continue + + template = ( + _resize_sample_for_scale( + base_template, + sample_scale, + ) + ) + + center_x, center_y = ( + _match_center( + match, + template, + ) + ) + + predicted_left = ( + center_x + - ( + float( + calibration[ + "x_from_left" + ] + ) + * sample_scale + ) + ) + + predicted_bottom = ( + center_y + + ( + float( + calibration[ + "y_from_bottom" + ] + ) + * sample_scale + ) + ) + + left_estimates.append( + predicted_left + ) + + bottom_estimates.append( + predicted_bottom + ) + + used.append({ + "name": name, + "score": float( + match["score"] + ), + "center": ( + center_x, + center_y, + ), + "left_estimate": + predicted_left, + "bottom_estimate": + predicted_bottom, + }) + + # Three agreeing HUD controls is enough to solve a viewport. + # + # World normally gives us: + # city + watchtower + army + clan + # + # City normally gives us: + # watchtower + army + clan + # + if len(used) < 3: + return None + + viewport_left = _median( + left_estimates + ) + + viewport_bottom = _median( + bottom_estimates + ) + + if ( + viewport_left is None + or viewport_bottom is None + ): + return None + + viewport_width = ( + REFERENCE_GAME_WIDTH + * sample_scale + ) + + viewport_height = ( + REFERENCE_GAME_HEIGHT + * sample_scale + ) + + viewport_top = ( + viewport_bottom + - viewport_height + ) + + # -------------------------------------------------------- + # CONSISTENCY CHECK + # -------------------------------------------------------- + # + # All contributing anchors should independently predict + # nearly the same viewport edges. Express tolerance in + # rendered pixels so it naturally follows UI scale. + # -------------------------------------------------------- + + tolerance = max( + 8.0, + 6.0 * sample_scale, + ) + + max_left_error = max( + abs( + item["left_estimate"] + - viewport_left + ) + for item in used + ) + + max_bottom_error = max( + abs( + item["bottom_estimate"] + - viewport_bottom + ) + for item in used + ) + + if ( + max_left_error > tolerance + or max_bottom_error > tolerance + ): + return None + + client = window[ + "client" + ] + + # Coordinates above are relative to the browser CLIENT crop. + # Convert solved viewport to absolute Windows desktop coords. + desktop_left = ( + float(client["left"]) + + viewport_left + ) + + desktop_top = ( + float(client["top"]) + + viewport_top + ) + + desktop_right = ( + desktop_left + + viewport_width + ) + + desktop_bottom = ( + desktop_top + + viewport_height + ) + + # -------------------------------------------------------- + # VISIBLE-CLIENT SANITY CHECK + # -------------------------------------------------------- + + client_left = float( + client["left"] + ) + + client_top = float( + client["top"] + ) + + client_right = float( + client["right"] + ) + + client_bottom = float( + client["bottom"] + ) + + boundary_tolerance = max( + 4.0, + 4.0 * sample_scale, + ) + + if ( + desktop_left + < client_left + - boundary_tolerance + or desktop_top + < client_top + - boundary_tolerance + or desktop_right + > client_right + + boundary_tolerance + or desktop_bottom + > client_bottom + + boundary_tolerance + ): + return None + + return { + "left": desktop_left, + "top": desktop_top, + "right": desktop_right, + "bottom": desktop_bottom, + "width": viewport_width, + "height": viewport_height, + "sample_scale": sample_scale, + "anchor_count": len( + used + ), + "anchors": used, + "max_left_error": + max_left_error, + "max_bottom_error": + max_bottom_error, + } + + +# ============================================================ +# GAME MONITOR DETECTION +# ============================================================ + +def detect_game_monitor(verbose=True): + + samples = load_samples( + ANCHOR_SAMPLES + ) + + if not samples: + raise RuntimeError( + "No detector samples could be loaded." + ) + + if verbose: + print() + print( + "Detecting game monitor..." + ) + + full_image = ( + capture_full_desktop() + ) + + monitors = get_monitors() + + # -------------------------------------------------------- + # WINDOWS NATIVE-WINDOW FAST PATH + # -------------------------------------------------------- + # + # A standalone Total Battle window gives Windows-native + # client geometry and therefore identifies its monitor much + # more reliably than unrestricted template matching. + # + # Browser-hosted/title-containing candidates are + # intentionally left for a verified fallback path. + # -------------------------------------------------------- + + if sys.platform == "win32": + + candidates = find_total_battle_windows( + monitors=monitors, + ) + + if candidates: + + # EnumWindows order is top -> bottom. Only inspect + # the first candidate here. A desktop screenshot + # cannot reveal the contents of a lower candidate + # that is obscured by this one. + window = candidates[0] + + visible = ( + _crop_windows_desktop_rect( + full_image, + window["client"], + ) + ) + + evidence = None + + if visible is not None: + evidence = ( + _windows_candidate_game_evidence( + visible + ) + ) + + verified = ( + evidence is not None + and evidence["verified"] + ) + + if verified: + + monitor = window[ + "monitor" + ] + + state = evidence[ + "state" + ] + + if verbose: + print( + f"Visible Total Battle window: " + f"{window['title']!r}" + ) + + print( + f"Windows candidate type: " + f"{'standalone' if window['exact_title'] else 'browser'}" + ) + + print( + f"Verified game state: " + f"{state.upper()}" + ) + + print( + f"Supporting HUD: " + f"{', '.join(evidence['supporting'])}" + ) + + print( + f"Detected sample scale: " + f"{evidence['sample_scale']:.6f}" + ) + + print( + f"Scale evidence score: " + f"{evidence['score']:.6f}" + ) + + print( + f"Game monitor from visible " + f"Total Battle window: " + f"{monitor['output']} " + f"({monitor['left']}, " + f"{monitor['top']}) " + f"{monitor['width']} x " + f"{monitor['height']}" + ) + + result = { + "monitor": monitor, + "confidence": 1.0, + "best_score": 0.0, + "margin": 1.0, + "matches": list( + evidence[ + "matches" + ].values() + ), + "source": ( + "windows_total_battle_window" + ), + "window": window, + "state": state, + "sample_scale": evidence[ + "sample_scale" + ], + } + + # Only the standalone application's client is + # itself the Total Battle game surface. + # + # A browser client also contains browser chrome, + # so its client rectangle identifies the window + # and monitor but NOT yet the game viewport. + if window["exact_title"]: + + # Standalone Total Battle client is already + # the authoritative game rendering surface. + client = window[ + "client" + ] + + result[ + "game_viewport" + ] = { + "left": float( + client["left"] + ), + "top": float( + client["top"] + ), + "right": float( + client["right"] + ), + "bottom": float( + client["bottom"] + ), + "width": float( + client["width"] + ), + "height": float( + client["height"] + ), + } + + result[ + "window_geometry_authoritative" + ] = True + + else: + + # Browser client includes browser chrome. + # Solve the embedded Total Battle viewport + # from its verified bottom HUD anchors. + viewport = ( + _solve_browser_game_viewport( + window, + evidence, + ) + ) + + if viewport is not None: + + result[ + "game_viewport" + ] = viewport + + result[ + "window_geometry_authoritative" + ] = True + + if verbose: + print( + "Browser Total Battle viewport " + "solved:" + ) + + print( + f" desktop: " + f"({viewport['left']:.1f}, " + f"{viewport['top']:.1f}) " + f"{viewport['width']:.1f} x " + f"{viewport['height']:.1f}" + ) + + print( + f" anchors: " + f"{viewport['anchor_count']}" + ) + + print( + f" max left error: " + f"{viewport['max_left_error']:.2f}px" + ) + + print( + f" max bottom error: " + f"{viewport['max_bottom_error']:.2f}px" + ) + + else: + + result[ + "window_geometry_authoritative" + ] = False + + if verbose: + print( + "Browser game content verified, " + "but viewport geometry could not " + "be solved safely." + ) + + return result + + if verbose: + print( + f"Topmost Total Battle-titled window " + f"did not verify as visible game content: " + f"{window['title']!r}" + ) + + print( + "Falling back to visual monitor detection." + ) + + results = [] + + for monitor in monitors: + + image = crop_monitor( + full_image, + monitor, + ) + + matches = score_monitor( + image, + samples, + ) + + confidence = ( + monitor_confidence(matches) + ) + + results.append({ + "monitor": monitor, + "confidence": confidence, + "matches": matches, + }) + + results.sort( + key=lambda result: + result["confidence"], + reverse=True, + ) + + best_result = results[0] + + if len(results) > 1: + margin = ( + best_result["confidence"] + - results[1]["confidence"] + ) + else: + margin = ( + best_result["confidence"] + ) + + best_match = ( + best_result["matches"][0] + if best_result["matches"] + else None + ) + + best_score = ( + best_match["score"] + if best_match + else 1.0 + ) + + confident = ( + best_score + <= EXCELLENT_THRESHOLD + or ( + best_score + <= REVIEW_THRESHOLD + and margin >= MIN_MARGIN + ) + ) + + if not confident: + return None + + monitor = best_result["monitor"] + + if verbose: + print( + f"Game monitor: " + f"{monitor['output']} " + f"({monitor['left']}, " + f"{monitor['top']}) " + f"{monitor['width']} x " + f"{monitor['height']}" + ) + + print( + f"Best score: {best_score:.6f}" + ) + + print( + f"Margin: {margin:.6f}" + ) + + return { + "monitor": monitor, + "confidence": + best_result["confidence"], + "best_score": best_score, + "margin": margin, + "matches": + best_result["matches"], + } + + + diff --git a/input_control.py b/input_control.py new file mode 100644 index 0000000..285e2a2 --- /dev/null +++ b/input_control.py @@ -0,0 +1,6 @@ +import sys + +if sys.platform == "win32": + from input_control_windows import InputController +else: + from input_control_linux import InputController diff --git a/input_control_linux.py b/input_control_linux.py new file mode 100644 index 0000000..f199d67 --- /dev/null +++ b/input_control_linux.py @@ -0,0 +1,841 @@ +#!/usr/bin/env python3 + +import os +import re +import subprocess +import time +import ctypes +import ctypes.util + +from evdev import UInput, ecodes as e + + +COSMIC_CONFIG = os.path.expanduser( + "~/.config/cosmic/com.system76.CosmicComp/v1/input_default" +) + +COSMIC_OBJECT = ( + "/com/system76/CosmicSettingsDaemon/Config/" + "com/system76/CosmicComp/V1" +) + +COSMIC_INTERFACE = "com.system76.CosmicSettingsDaemon.Config" + +DEFAULT_CHUNK = 500 + + +class InputController: + def __init__(self, chunk_size=DEFAULT_CHUNK): + self.chunk_size = chunk_size + self.original_config = None + + # Last monitor-relative position that we commanded. + # + # Once we have anchored the pointer successfully, later + # movements on the same monitor can use relative deltas + # instead of navigating the multi-monitor boundaries again. + self.current_position = None + self.current_monitor = None + + # ----------------------------------------------------- + # VIRTUAL MOUSE + # ----------------------------------------------------- + # + # Keep this device strictly mouse-only. + # + # This exact device classification was proven to work + # with Total Battle for both movement and clicking. + # + + mouse_capabilities = { + e.EV_KEY: [ + e.BTN_LEFT, + e.BTN_RIGHT, + e.BTN_MIDDLE, + ], + e.EV_REL: [ + e.REL_X, + e.REL_Y, + e.REL_WHEEL, + ], + } + + self.ui = UInput( + mouse_capabilities, + name="TBA Virtual Mouse", + bustype=e.BUS_USB, + vendor=0x046d, + product=0xc077, + version=1, + input_props=[ + e.INPUT_PROP_POINTER, + ], + ) + + # ----------------------------------------------------- + # VIRTUAL KEYBOARD + # ----------------------------------------------------- + + keyboard_capabilities = { + e.EV_KEY: [ + e.KEY_ESC, + e.KEY_TAB, + e.KEY_F5, + e.KEY_LEFTSHIFT, + + e.KEY_A, e.KEY_B, e.KEY_C, e.KEY_D, + e.KEY_E, e.KEY_F, e.KEY_G, e.KEY_H, + e.KEY_I, e.KEY_J, e.KEY_K, e.KEY_L, + e.KEY_M, e.KEY_N, e.KEY_O, e.KEY_P, + e.KEY_Q, e.KEY_R, e.KEY_S, e.KEY_T, + e.KEY_U, e.KEY_V, e.KEY_W, e.KEY_X, + e.KEY_Y, e.KEY_Z, + + e.KEY_0, e.KEY_1, e.KEY_2, e.KEY_3, + e.KEY_4, e.KEY_5, e.KEY_6, e.KEY_7, + e.KEY_8, e.KEY_9, + + e.KEY_SPACE, + e.KEY_MINUS, + e.KEY_EQUAL, + e.KEY_LEFTBRACE, + e.KEY_RIGHTBRACE, + e.KEY_BACKSLASH, + e.KEY_SEMICOLON, + e.KEY_APOSTROPHE, + e.KEY_GRAVE, + e.KEY_COMMA, + e.KEY_DOT, + e.KEY_SLASH, + ], + } + + self.keyboard = UInput( + keyboard_capabilities, + name="TBA Virtual Keyboard", + bustype=e.BUS_USB, + vendor=0x046d, + product=0xc31c, + version=1, + ) + + time.sleep(0.25) + + # XWayland accurately reports the actual compositor + # pointer position. We use it as feedback instead of + # assuming relative movement landed where requested. + lib_x11 = ctypes.util.find_library("X11") + + if not lib_x11: + raise RuntimeError( + "Could not find libX11." + ) + + self.x11 = ctypes.cdll.LoadLibrary(lib_x11) + + self.x11.XOpenDisplay.argtypes = [ + ctypes.c_char_p, + ] + self.x11.XOpenDisplay.restype = ctypes.c_void_p + + self.x11.XDefaultRootWindow.argtypes = [ + ctypes.c_void_p, + ] + self.x11.XDefaultRootWindow.restype = ctypes.c_ulong + + self.x11.XQueryPointer.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.POINTER(ctypes.c_ulong), + ctypes.POINTER(ctypes.c_ulong), + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_uint), + ] + self.x11.XQueryPointer.restype = ctypes.c_int + + self.x11.XCloseDisplay.argtypes = [ + ctypes.c_void_p, + ] + + self.x11.XSetInputFocus.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.c_int, + ctypes.c_ulong, + ] + self.x11.XSetInputFocus.restype = ctypes.c_int + + self.x11.XRaiseWindow.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ] + self.x11.XRaiseWindow.restype = ctypes.c_int + + self.x11.XFlush.argtypes = [ + ctypes.c_void_p, + ] + self.x11.XFlush.restype = ctypes.c_int + + self.x_display = self.x11.XOpenDisplay(None) + + if not self.x_display: + raise RuntimeError( + "Could not connect to XWayland display." + ) + + self.x_root = self.x11.XDefaultRootWindow( + self.x_display + ) + + # --------------------------------------------------------- + # COSMIC mouse acceleration + # --------------------------------------------------------- + + def _notify_cosmic(self): + subprocess.run( + [ + "busctl", + "--user", + "emit", + COSMIC_OBJECT, + COSMIC_INTERFACE, + "Changed", + "ss", + "com.system76.CosmicComp", + "input_default", + ], + check=True, + ) + + def set_flat_input(self): + with open( + COSMIC_CONFIG, + "r", + encoding="utf-8", + ) as f: + current = f.read() + + if self.original_config is None: + self.original_config = current + + updated = re.sub( + r"profile:\s*Some\(\s*(Adaptive|Flat)\s*\)", + "profile: Some(Flat)", + current, + ) + + if ( + updated == current + and "profile: Some(Flat)" not in current + ): + raise RuntimeError( + "Could not locate COSMIC acceleration profile " + "in input_default." + ) + + if updated != current: + with open( + COSMIC_CONFIG, + "w", + encoding="utf-8", + ) as f: + f.write(updated) + + self._notify_cosmic() + time.sleep(0.1) + + def restore_input_profile(self): + if self.original_config is None: + return + + with open( + COSMIC_CONFIG, + "w", + encoding="utf-8", + ) as f: + f.write(self.original_config) + + self._notify_cosmic() + time.sleep(0.1) + + self.original_config = None + + # --------------------------------------------------------- + # Relative movement + # --------------------------------------------------------- + + def _move_axis(self, axis, amount): + amount = int(round(amount)) + + if amount == 0: + return + + direction = 1 if amount > 0 else -1 + remaining = abs(amount) + + while remaining: + movement = min( + self.chunk_size, + remaining, + ) + + self.ui.write( + e.EV_REL, + axis, + direction * movement, + ) + self.ui.syn() + + remaining -= movement + + def move_relative(self, dx=0, dy=0): + self._move_axis( + e.REL_X, + dx, + ) + + self._move_axis( + e.REL_Y, + dy, + ) + + # --------------------------------------------------------- + # Mouse buttons + # --------------------------------------------------------- + + def scroll(self, amount): + """ + Send vertical mouse-wheel movement through uinput. + + Positive values scroll up. + Negative values scroll down. + """ + + amount = int( + round(amount) + ) + + if amount == 0: + return + + direction = ( + 1 + if amount > 0 + else -1 + ) + + for _ in range( + abs(amount) + ): + self.ui.write( + e.EV_REL, + e.REL_WHEEL, + direction, + ) + + self.ui.syn() + + time.sleep( + 0.02 + ) + + + def click(self, button=e.BTN_LEFT): + self.ui.write( + e.EV_KEY, + button, + 1, + ) + self.ui.syn() + + time.sleep(0.05) + + self.ui.write( + e.EV_KEY, + button, + 0, + ) + self.ui.syn() + + time.sleep(0.05) + + # --------------------------------------------------------- + # Game window focus + # --------------------------------------------------------- + + def find_game_window(self): + """ + Find the XWayland Total Battle window dynamically. + + Returns: + integer X11 window ID + """ + + result = subprocess.run( + [ + "xwininfo", + "-root", + "-tree", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + + for line in result.stdout.splitlines(): + if '"Total Battle"' not in line: + continue + + parts = line.strip().split() + + if not parts: + continue + + window_text = parts[0] + + try: + return int( + window_text, + 16, + ) + except ValueError: + continue + + raise RuntimeError( + "Could not find XWayland window titled " + "'Total Battle'." + ) + + def get_game_window_geometry( + self, + ): + """ + Return the actual XWayland Total Battle window geometry. + + Coordinates returned here are absolute desktop + coordinates: + + { + "window": X11 window id, + "left": desktop X, + "top": desktop Y, + "width": width, + "height": height, + } + """ + + window = self.find_game_window() + + result = subprocess.run( + [ + "xwininfo", + "-id", + hex(window), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + + values = {} + + for raw_line in result.stdout.splitlines(): + + line = raw_line.strip() + + if line.startswith( + "Absolute upper-left X:" + ): + values["left"] = int( + line.split( + ":", + 1, + )[1].strip() + ) + + elif line.startswith( + "Absolute upper-left Y:" + ): + values["top"] = int( + line.split( + ":", + 1, + )[1].strip() + ) + + elif line.startswith( + "Width:" + ): + values["width"] = int( + line.split( + ":", + 1, + )[1].strip() + ) + + elif line.startswith( + "Height:" + ): + values["height"] = int( + line.split( + ":", + 1, + )[1].strip() + ) + + required = { + "left", + "top", + "width", + "height", + } + + missing = ( + required + - set(values) + ) + + if missing: + raise RuntimeError( + "Could not determine Total Battle " + "window geometry. Missing: " + + ", ".join( + sorted(missing) + ) + ) + + values[ + "window" + ] = window + + return values + + + def focus_game_window(self): + """ + Give keyboard focus to the Total Battle XWayland window. + """ + + window = self.find_game_window() + + # RevertToParent = 2 + # CurrentTime = 0 + self.x11.XRaiseWindow( + self.x_display, + window, + ) + + self.x11.XSetInputFocus( + self.x_display, + window, + 2, + 0, + ) + + self.x11.XFlush( + self.x_display + ) + + # Give COSMIC/XWayland a moment to apply focus. + time.sleep(0.10) + + return window + + # --------------------------------------------------------- + # Keyboard + # --------------------------------------------------------- + + def key_down(self, key): + self.keyboard.write( + e.EV_KEY, + key, + 1, + ) + self.keyboard.syn() + + def key_up(self, key): + self.keyboard.write( + e.EV_KEY, + key, + 0, + ) + self.keyboard.syn() + + def press_key(self, key, hold=0.05): + self.focus_game_window() + + self.key_down(key) + time.sleep(hold) + self.key_up(key) + time.sleep(0.05) + + def type_text(self, text, delay=0.005): + """ + Type printable US-keyboard text through uinput. + """ + + self.focus_game_window() + + key_map = { + "a": (e.KEY_A, False), + "b": (e.KEY_B, False), + "c": (e.KEY_C, False), + "d": (e.KEY_D, False), + "e": (e.KEY_E, False), + "f": (e.KEY_F, False), + "g": (e.KEY_G, False), + "h": (e.KEY_H, False), + "i": (e.KEY_I, False), + "j": (e.KEY_J, False), + "k": (e.KEY_K, False), + "l": (e.KEY_L, False), + "m": (e.KEY_M, False), + "n": (e.KEY_N, False), + "o": (e.KEY_O, False), + "p": (e.KEY_P, False), + "q": (e.KEY_Q, False), + "r": (e.KEY_R, False), + "s": (e.KEY_S, False), + "t": (e.KEY_T, False), + "u": (e.KEY_U, False), + "v": (e.KEY_V, False), + "w": (e.KEY_W, False), + "x": (e.KEY_X, False), + "y": (e.KEY_Y, False), + "z": (e.KEY_Z, False), + + "0": (e.KEY_0, False), + "1": (e.KEY_1, False), + "2": (e.KEY_2, False), + "3": (e.KEY_3, False), + "4": (e.KEY_4, False), + "5": (e.KEY_5, False), + "6": (e.KEY_6, False), + "7": (e.KEY_7, False), + "8": (e.KEY_8, False), + "9": (e.KEY_9, False), + + " ": (e.KEY_SPACE, False), + "-": (e.KEY_MINUS, False), + "=": (e.KEY_EQUAL, False), + "[": (e.KEY_LEFTBRACE, False), + "]": (e.KEY_RIGHTBRACE, False), + "\\": (e.KEY_BACKSLASH, False), + ";": (e.KEY_SEMICOLON, False), + "'": (e.KEY_APOSTROPHE, False), + "`": (e.KEY_GRAVE, False), + ",": (e.KEY_COMMA, False), + ".": (e.KEY_DOT, False), + "/": (e.KEY_SLASH, False), + + "!": (e.KEY_1, True), + "@": (e.KEY_2, True), + "#": (e.KEY_3, True), + "$": (e.KEY_4, True), + "%": (e.KEY_5, True), + "^": (e.KEY_6, True), + "&": (e.KEY_7, True), + "*": (e.KEY_8, True), + "(": (e.KEY_9, True), + ")": (e.KEY_0, True), + "_": (e.KEY_MINUS, True), + "+": (e.KEY_EQUAL, True), + "{": (e.KEY_LEFTBRACE, True), + "}": (e.KEY_RIGHTBRACE, True), + "|": (e.KEY_BACKSLASH, True), + ":": (e.KEY_SEMICOLON, True), + '"': (e.KEY_APOSTROPHE, True), + "~": (e.KEY_GRAVE, True), + "<": (e.KEY_COMMA, True), + ">": (e.KEY_DOT, True), + "?": (e.KEY_SLASH, True), + } + + for char in str(text): + if "A" <= char <= "Z": + key = getattr( + e, + f"KEY_{char}", + ) + shifted = True + else: + entry = key_map.get(char) + + if entry is None: + raise ValueError( + f"Unsupported character for typing: {char!r}" + ) + + key, shifted = entry + + if shifted: + self.key_down( + e.KEY_LEFTSHIFT + ) + + self.key_down(key) + self.key_up(key) + + if shifted: + self.key_up( + e.KEY_LEFTSHIFT + ) + + if delay: + time.sleep(delay) + + def press_escape(self): + self.press_key(e.KEY_ESC) + + def press_tab(self): + self.press_key(e.KEY_TAB) + + def press_f5(self): + self.press_key(e.KEY_F5) + + # --------------------------------------------------------- + # Actual pointer position + # --------------------------------------------------------- + + def get_pointer_position(self): + root_return = ctypes.c_ulong() + child_return = ctypes.c_ulong() + + root_x = ctypes.c_int() + root_y = ctypes.c_int() + + win_x = ctypes.c_int() + win_y = ctypes.c_int() + + mask = ctypes.c_uint() + + result = self.x11.XQueryPointer( + self.x_display, + self.x_root, + ctypes.byref(root_return), + ctypes.byref(child_return), + ctypes.byref(root_x), + ctypes.byref(root_y), + ctypes.byref(win_x), + ctypes.byref(win_y), + ctypes.byref(mask), + ) + + if not result: + raise RuntimeError( + "XQueryPointer failed." + ) + + return ( + root_x.value, + root_y.value, + ) + + # --------------------------------------------------------- + # Monitor-relative positioning + # --------------------------------------------------------- + + def move_to_monitor(self, x, y, monitor): + """ + Move to an exact coordinate relative to the target monitor. + + We establish a deterministic global anchor first: + + 1. Move hard upward. + 2. Move hard left. + + This reaches the top-left corner of the COSMIC desktop. + + From there we can use the monitor's detected global + position plus the requested monitor-relative coordinate. + """ + + left = int(monitor["left"]) + top = int(monitor["top"]) + width = int(monitor["width"]) + height = int(monitor["height"]) + + x = int(round(x)) + y = int(round(y)) + + if not 0 <= x < width: + raise ValueError( + f"x={x} outside monitor width {width}" + ) + + if not 0 <= y < height: + raise ValueError( + f"y={y} outside monitor height {height}" + ) + + # ----------------------------------------------------- + # GLOBAL TOP-LEFT ANCHOR + # ----------------------------------------------------- + + self.move_relative( + dy=-10000 + ) + + self.move_relative( + dx=-10000 + ) + + # ----------------------------------------------------- + # GLOBAL ANCHOR -> TARGET + # ----------------------------------------------------- + + target_x = left + x + target_y = top + y + + self.move_relative( + dx=target_x, + dy=target_y, + ) + + self.current_monitor = monitor.get( + "output" + ) + + self.current_position = ( + x, + y, + ) + + # --------------------------------------------------------- + # Cleanup + # --------------------------------------------------------- + + def close(self): + try: + self.restore_input_profile() + finally: + try: + self.ui.close() + self.keyboard.close() + finally: + if getattr( + self, + "x_display", + None, + ): + self.x11.XCloseDisplay( + self.x_display + ) + self.x_display = None + + def __enter__(self): + self.set_flat_input() + return self + + def __exit__( + self, + exc_type, + exc_value, + traceback, + ): + self.close() diff --git a/input_control_windows.py b/input_control_windows.py new file mode 100644 index 0000000..02a1f7f --- /dev/null +++ b/input_control_windows.py @@ -0,0 +1,279 @@ +import ctypes +import time + +import pyautogui + + +class POINT(ctypes.Structure): + _fields_ = [ + ("x", ctypes.c_long), + ("y", ctypes.c_long), + ] + + +class RECT(ctypes.Structure): + _fields_ = [ + ("left", ctypes.c_long), + ("top", ctypes.c_long), + ("right", ctypes.c_long), + ("bottom", ctypes.c_long), + ] + + +class InputController: + """Windows input/window backend for Total Battle automation.""" + + def __init__(self, chunk_size=500): + self.chunk_size = chunk_size + self.original_config = None + self.current_position = None + self.current_monitor = None + + self.user32 = ctypes.windll.user32 + + # Keep PyAutoGUI from adding its own pause to every call. + pyautogui.PAUSE = 0 + + # Use physical desktop coordinates where possible. + try: + self.user32.SetProcessDPIAware() + except Exception: + pass + + # --------------------------------------------------------- + # Platform input profile + # --------------------------------------------------------- + + def set_flat_input(self): + # COSMIC mouse acceleration handling is Linux-only. + return + + def restore_input_profile(self): + return + + # --------------------------------------------------------- + # Pointer movement + # --------------------------------------------------------- + + def get_pointer_position(self): + point = POINT() + + if not self.user32.GetCursorPos(ctypes.byref(point)): + raise RuntimeError("GetCursorPos failed.") + + return (point.x, point.y) + + def move_relative(self, dx=0, dy=0): + x, y = self.get_pointer_position() + + target_x = int(round(x + dx)) + target_y = int(round(y + dy)) + + if not self.user32.SetCursorPos(target_x, target_y): + raise RuntimeError("SetCursorPos failed.") + + def move_to_monitor(self, x, y, monitor): + left = int(monitor["left"]) + top = int(monitor["top"]) + width = int(monitor["width"]) + height = int(monitor["height"]) + + x = int(round(x)) + y = int(round(y)) + + if not 0 <= x < width: + raise ValueError(f"x={x} outside monitor width {width}") + + if not 0 <= y < height: + raise ValueError(f"y={y} outside monitor height {height}") + + target_x = left + x + target_y = top + y + + if not self.user32.SetCursorPos(target_x, target_y): + raise RuntimeError("SetCursorPos failed.") + + self.current_monitor = monitor.get("output") + self.current_position = (x, y) + + # --------------------------------------------------------- + # Mouse + # --------------------------------------------------------- + + def scroll(self, amount): + pyautogui.scroll(int(round(amount))) + + def click(self, button="left"): + pyautogui.click(button=button) + time.sleep(0.05) + + # --------------------------------------------------------- + # Total Battle window + # --------------------------------------------------------- + + def find_game_window(self): + # Prefer the exact HWND selected by game_monitor.py. + # This prevents browser-hosted and standalone Total + # Battle windows from being confused when both exist. + cached = getattr( + self, + "game_window_hwnd", + None, + ) + + if ( + cached is not None + and self.user32.IsWindow( + cached + ) + ): + return cached + + matches = [] + + callback_type = ctypes.WINFUNCTYPE( + ctypes.c_bool, + ctypes.c_void_p, + ctypes.c_void_p, + ) + + def callback(hwnd, _): + if not self.user32.IsWindowVisible(hwnd): + return True + + length = self.user32.GetWindowTextLengthW(hwnd) + + if length <= 0: + return True + + buffer = ctypes.create_unicode_buffer(length + 1) + self.user32.GetWindowTextW(hwnd, buffer, length + 1) + + title = buffer.value.strip() + + if "Total Battle" in title: + matches.append((int(hwnd), title)) + + return True + + callback_ref = callback_type(callback) + + self.user32.EnumWindows(callback_ref, 0) + + if not matches: + raise RuntimeError( + "Could not find window containing title 'Total Battle'." + ) + + return matches[0][0] + + def get_game_window_geometry(self): + """ + Return the Total Battle client-area geometry in absolute + Windows desktop coordinates. + + The client area excludes the Windows window frame/title + decoration and corresponds to the usable game surface. + """ + + hwnd = self.find_game_window() + + rect = RECT() + + if not self.user32.GetClientRect( + hwnd, + ctypes.byref(rect), + ): + raise RuntimeError("GetClientRect failed.") + + point = POINT( + 0, + 0, + ) + + if not self.user32.ClientToScreen( + hwnd, + ctypes.byref(point), + ): + raise RuntimeError("ClientToScreen failed.") + + return { + "window": hwnd, + "left": point.x, + "top": point.y, + "width": rect.right - rect.left, + "height": rect.bottom - rect.top, + } + def focus_game_window(self): + hwnd = self.find_game_window() + + # SW_RESTORE must only be used for a minimized window. + # + # Calling it unconditionally restores a maximized Brave + # window to normal/windowed size, which changes the + # embedded Total Battle viewport after automation has + # already calibrated its geometry. + if self.user32.IsIconic( + hwnd + ): + SW_RESTORE = 9 + + self.user32.ShowWindow( + hwnd, + SW_RESTORE, + ) + + time.sleep(0.10) + + self.user32.SetForegroundWindow( + hwnd + ) + + time.sleep(0.10) + + return hwnd + + # --------------------------------------------------------- + # Keyboard + # --------------------------------------------------------- + + def key_down(self, key): + pyautogui.keyDown(key) + + def key_up(self, key): + pyautogui.keyUp(key) + + def press_key(self, key, hold=0.05): + self.focus_game_window() + self.key_down(key) + time.sleep(hold) + self.key_up(key) + time.sleep(0.05) + + def type_text(self, text, delay=0.005): + self.focus_game_window() + pyautogui.write(str(text), interval=delay) + + def press_escape(self): + self.press_key("esc") + + def press_tab(self): + self.press_key("tab") + + def press_f5(self): + self.press_key("f5") + + # --------------------------------------------------------- + # Cleanup + # --------------------------------------------------------- + + def close(self): + self.restore_input_profile() + + def __enter__(self): + self.set_flat_input() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + diff --git a/install_total_battle_helper_linux.py b/install_total_battle_helper_linux.py new file mode 100755 index 0000000..f6f7d5d --- /dev/null +++ b/install_total_battle_helper_linux.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 + +import shutil +import subprocess +from pathlib import Path + + +APP_NAME = "TotalBattleHelper" +PROJECT_DIR = Path(__file__).resolve().parent +VENV_PYTHON = PROJECT_DIR / ".venv" / "bin" / "python" +HELPER = PROJECT_DIR / "total_battle_helper.py" +APPLICATION_DIR = Path.home() / ".local" / "share" / "applications" +DESKTOP_FILE = APPLICATION_DIR / "total-battle-helper.desktop" + + +def main(): + if not VENV_PYTHON.exists(): + raise SystemExit( + f"Project virtual environment Python not found: {VENV_PYTHON}" + ) + + if not HELPER.exists(): + raise SystemExit( + f"TotalBattleHelper source not found: {HELPER}" + ) + + APPLICATION_DIR.mkdir( + parents=True, + exist_ok=True, + ) + + desktop_text = f"""[Desktop Entry] +Version=1.0 +Type=Application +Name={APP_NAME} +Comment=Total Battle automation and configuration helper +Exec={VENV_PYTHON} {HELPER} +Path={PROJECT_DIR} +Terminal=false +Categories=Game;Utility; +StartupNotify=true +StartupWMClass={APP_NAME} +""" + + DESKTOP_FILE.write_text( + desktop_text, + encoding="utf-8", + ) + + DESKTOP_FILE.chmod(0o755) + + update_database = shutil.which( + "update-desktop-database" + ) + + if update_database: + subprocess.run( + [ + update_database, + str(APPLICATION_DIR), + ], + check=False, + ) + + print() + print("=" * 60) + print("=== TOTALBATTLEHELPER LINUX LAUNCHER INSTALLED") + print("=" * 60) + print() + print(f"Desktop entry: {DESKTOP_FILE}") + print() + print( + "Open your Applications menu, search for " + "TotalBattleHelper, launch it once, then pin that " + "application instance to the dock." + ) + + +if __name__ == "__main__": + main() diff --git a/march.py b/march.py new file mode 100644 index 0000000..8af0f3b --- /dev/null +++ b/march.py @@ -0,0 +1,626 @@ +#!/usr/bin/env python3 + +import time +import clan + + +def find_active_march( + a, + update=True, +): + """ + Detect a generic active march banner. + + This intentionally does not identify a specific captain. + It only proves that an active march banner exists. + + Returns monitor-relative location on success, otherwise None. + """ + + if update: + a.update() + + return a.find_in_region( + "generic_march", + a.get_ui_region( + "top_center" + ), + threshold=float( + a.matching[ + "generic_march_threshold" + ] + ), + set_loc=False, + ) + + +def wait_for_active_march( + a, + timeout=3.0, + poll_wait=0.20, +): + """ + Wait briefly for a generic active march banner to appear. + + Returns its location on success, otherwise None. + """ + + deadline = ( + time.monotonic() + + float(timeout) + ) + + while time.monotonic() < deadline: + + march = find_active_march( + a, + update=True, + ) + + if march is not None: + return march + + time.sleep( + poll_wait + ) + + return None + + +def open_march( + a, + march_location, + vertical_tolerance=60, +): + """ + Open the speedup panel belonging to a specific active march. + + There may be several active marches visible simultaneously, + and therefore several march speedup buttons. + + Pair the requested march banner with the speedup button on + the closest horizontal row. + + `march_location` is the monitor-relative location returned + by generic_march detection. + """ + + if march_location is None: + return False + + # -------------------------------------------------------- + # ONE FRESH SCREENSHOT + # -------------------------------------------------------- + + a.update() + + region = a.get_ui_region( + "top_center" + ) + + # -------------------------------------------------------- + # FIND ALL VISIBLE MARCH SPEEDUP BUTTONS + # -------------------------------------------------------- + + buttons = a.find_multi_in_region( + "march_speedup_button", + region, + ) + + if not buttons: + print( + "FAIL: no march speedup buttons detected." + ) + + return False + + march_x = float( + march_location[0] + ) + + march_y = float( + march_location[1] + ) + + a.debug() + a.debug( + f"Target march: " + f"({march_x:.1f}, {march_y:.1f})" + ) + + a.debug( + f"March speedup buttons detected: " + f"{len(buttons)}" + ) + + # -------------------------------------------------------- + # PAIR BY NEAREST HORIZONTAL PLANE + # -------------------------------------------------------- + + candidates = [] + + for button in buttons: + + button_x = float( + button[0] + ) + + button_y = float( + button[1] + ) + + y_delta = abs( + button_y + - march_y + ) + + a.debug( + f" button=" + f"({button_x:.1f}, {button_y:.1f}) " + f"delta={y_delta:.1f}" + ) + + if y_delta > vertical_tolerance: + continue + + candidates.append( + ( + y_delta, + abs( + button_x + - march_x + ), + button, + ) + ) + + if not candidates: + print( + "FAIL: no march speedup button " + "was aligned with the target march." + ) + + return False + + # Closest Y wins. + # + # Horizontal distance is only a deterministic tie-breaker; + # rows are fundamentally paired vertically. + ( + y_delta, + x_delta, + selected, + ) = min( + candidates, + key=lambda item: ( + item[0], + item[1], + ), + ) + + a.debug( + f"Selected march speedup button: " + f"({float(selected[0]):.1f}, " + f"{float(selected[1]):.1f}) " + f"delta={y_delta:.1f}" + ) + + # -------------------------------------------------------- + # OPEN THIS MARCH'S SPEEDUP PANEL + # -------------------------------------------------------- + + # This control occasionally ignores a correctly positioned + # click on Windows. Use a small targeted jiggle and positively + # confirm the speedup panel from its 50% row. + button_x = float( + selected[0] + ) + + button_y = float( + selected[1] + ) + + for attempt in range( + 1, + 4, + ): + a.input.current_position = None + a.input.current_monitor = None + + a.loc = ( + button_x, + button_y, + ) + a.move() + + time.sleep( + 0.08 + ) + + a.loc = ( + button_x + 8.0, + button_y, + ) + a.move() + + time.sleep( + 0.05 + ) + + a.loc = ( + button_x, + button_y, + ) + a.move() + + time.sleep( + 0.10 + ) + + a.click() + + time.sleep( + a.timing[ + "march_panel_wait" + ] + ) + + # The 50% speedup sample exists only in the opened + # speedup panel, so use it as positive confirmation. + a.update() + + speedup_50 = a.find( + "speedup", + set_loc=False, + ) + + if speedup_50 is not None: + a.debug( + f"Speedup panel confirmed open " + f"(attempt {attempt}/3)." + ) + + return True + + a.debug( + f"Speedup panel not confirmed " + f"(attempt {attempt}/3)." + ) + + print( + "FAIL: march speedup panel did not open " + "after 3 attempts." + ) + + return False + + +def find_max_speedup( + a, + update=True, +): + """ + Find the MAX button corresponding to the 50% speedup row. + + Returns: + + { + "speedup_50": (x, y), + "max_speedup": (x, y), + } + + or None if the speedup panel could not be classified. + """ + + if update: + a.update() + + # -------------------------------------------------------- + # FIND 50% ROW + # -------------------------------------------------------- + + speedup_50 = a.find( + "speedup", + set_loc=False, + ) + + # -------------------------------------------------------- + # FIND ALL MAX BUTTONS + # -------------------------------------------------------- + + max_buttons = a.find_multi( + "max_speedup", + ) + + if ( + speedup_50 is None + or not max_buttons + ): + return None + + sx, sy = speedup_50 + + # The MAX button belonging to the 50% row should be to the + # right of the row marker and approximately horizontally + # aligned with it. + candidates = [ + location + for location in max_buttons + if ( + location[0] > sx + and abs( + float(location[1]) + - float(sy) + ) <= 50 + ) + ] + + if not candidates: + return None + + selected = min( + candidates, + key=lambda location: ( + abs( + float(location[1]) + - float(sy) + ), + float(location[0]) + - float(sx), + ), + ) + + return { + "speedup_50": speedup_50, + "max_speedup": selected, + } + + +def apply_max_speedup( + a, +): + """ + Apply MAX speedup in an already-open march speedup panel. + + The MAX control is static within the speedup panel, so once + discovered its location is cached as: + + march_max_speedup + + Returns True only when MAX was clicked. + """ + + # -------------------------------------------------------- + # FAST PATH: REUSE STATIC MAX LOCATION + # -------------------------------------------------------- + + cached_max = a.use_ui_location( + "march_max_speedup" + ) + + if cached_max is not None: + + # open_march() has already positively confirmed the + # speedup panel from the 50% row, so the cached MAX + # location is safe to use immediately. + a.debug( + f"Using cached march MAX speedup: " + f"({cached_max[0]:.1f}, " + f"{cached_max[1]:.1f})" + ) + + a.loc = cached_max + a.click() + + time.sleep( + a.timing[ + "speedup_click_wait" + ] + ) + + return True + + # -------------------------------------------------------- + # FIRST-TIME DISCOVERY + # -------------------------------------------------------- + + result = None + + # The speedup panel can animate/render asynchronously after + # the march speedup button is clicked. Poll for a short + # bounded window rather than trusting only one or two frames. + for attempt in range(6): + + result = find_max_speedup( + a, + update=True, + ) + + if result is not None: + break + + if attempt < 5: + time.sleep( + a.timing[ + "march_panel_retry_wait" + ] + ) + + if result is None: + + print( + "FAIL: could not locate 50% row " + "and aligned MAX speedup after retries." + ) + + return False + + speedup_50 = result[ + "speedup_50" + ] + + selected = result[ + "max_speedup" + ] + + a.debug( + f"50% speedup: " + f"({float(speedup_50[0]):.1f}, " + f"{float(speedup_50[1]):.1f})" + ) + + a.debug( + f"MAX speedup: " + f"({float(selected[0]):.1f}, " + f"{float(selected[1]):.1f})" + ) + + # Static within the speedup panel. + a.cache_ui_location( + "march_max_speedup", + selected, + ) + + a.loc = selected + a.click() + + time.sleep( + a.timing[ + "speedup_click_wait" + ] + ) + + return True + + +def open_and_max_speedup( + a, + march_location, +): + """ + Generic active-march speedup flow: + + active march + -> open march + -> speedup panel + -> 50% row + -> aligned MAX + -> click MAX + + Does not care which captain owns the march. + """ + + if not open_march( + a, + march_location, + ): + return False + + if not apply_max_speedup( + a + ): + return False + + a.debug( + "MARCH MAX SPEEDUP APPLIED." + ) + + return True + + +def wait_for_no_active_march( + a, + poll_wait=0.50, + consecutive_misses=2, + initial_grace=0.0, + while_active=None, +): + """ + Wait until no generic active march banner remains. + + This does NOT identify march ownership. + + Appropriate when the caller knows there should be only one + relevant active march, such as the current Citadel flow where + all available captains are sent together. + + A march is considered finished only after multiple + consecutive missing screenshots so one bad frame does not + produce a false completion. + + Returns True when no active march is confirmed. + """ + + if initial_grace > 0: + time.sleep( + initial_grace + ) + + misses = 0 + + while True: + + # ONE fresh frame owns this entire poll: + # + # march state + # Clan Help availability + # + # Do not take a second screenshot just to check Help. + a.update() + + march_location = find_active_march( + a, + update=False, + ) + + if march_location is not None: + + misses = 0 + + clan.click_help_if_available( + a, + update=False, + ) + + # The caller may perform additional work while the + # march is positively known to be active. + # + # The callback receives the SAME fresh screenshot + # used for march + Help detection. If it performs + # UI navigation, the next loop iteration starts with + # a fresh screenshot before evaluating march state. + if while_active is not None: + if not while_active( + a, + march_location, + ): + return False + + a.debug( + "Citadel march still active; " + "checking again..." + ) + + time.sleep( + poll_wait + ) + + continue + + misses += 1 + + if misses >= consecutive_misses: + + a.debug( + "No active Citadel march confirmed." + ) + + return True + + # Never trust a single missing screenshot. + time.sleep( + min( + poll_wait, + 0.25, + ) + ) diff --git a/requirements-linux.txt b/requirements-linux.txt new file mode 100644 index 0000000..32f8a5f --- /dev/null +++ b/requirements-linux.txt @@ -0,0 +1,2 @@ +-r requirements.txt +evdev diff --git a/requirements-windows.txt b/requirements-windows.txt new file mode 100644 index 0000000..1cbbce2 --- /dev/null +++ b/requirements-windows.txt @@ -0,0 +1 @@ +-r requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0e901de --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +numpy +opencv-python +Pillow +imutils +pytesseract +pyautogui diff --git a/samples/city.png b/samples/city.png new file mode 100644 index 0000000..55806ef Binary files /dev/null and b/samples/city.png differ diff --git a/samples/selected.png b/samples/selected.png index a5f6b8d..cda179f 100644 Binary files a/samples/selected.png and b/samples/selected.png differ diff --git a/samples_desktop/--help.png b/samples_desktop/--help.png new file mode 100644 index 0000000..b3e4e25 Binary files /dev/null and b/samples_desktop/--help.png differ diff --git a/samples_desktop/.mapping.json b/samples_desktop/.mapping.json new file mode 100644 index 0000000..cf24b30 --- /dev/null +++ b/samples_desktop/.mapping.json @@ -0,0 +1,739 @@ +{ + "_progress": 1, + "army": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "attack": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "aydae": { + "score": 6.038695801180438e-07, + "source": "desktop", + "status": "existing" + }, + "aydae_brunhild_march": { + "status": "skipped" + }, + "battle": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "brunhild": { + "score": 6.805473731219536e-07, + "source": "desktop", + "status": "existing" + }, + "carter_march": { + "score": 0.005285997875034809, + "source": "desktop", + "status": "existing" + }, + "carter_selected": { + "score": 0.0014145898167043924, + "source": "desktop", + "status": "existing" + }, + "city": { + "score": 0.0005851531168445945, + "source": "desktop", + "status": "existing" + }, + "clan": { + "score": 2.0076611235708697e-06, + "source": "desktop", + "status": "existing" + }, + "crypts/cathedral": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "crypts/cave": { + "score": 0.00020903736003674567, + "source": "desktop", + "status": "existing" + }, + "crypts/cog": { + "score": 1.1750449630199e-05, + "source": "desktop", + "status": "existing" + }, + "crypts/crystal": { + "score": 0.0002802751841954887, + "source": "desktop", + "status": "existing" + }, + "crypts/darktree": { + "score": 1.7916058823175263e-06, + "source": "desktop", + "status": "existing" + }, + "crypts/dune": { + "score": 4.0409375401395664e-07, + "source": "desktop", + "status": "existing" + }, + "crypts/epicpyramid": { + "score": 5.782932817055553e-07, + "source": "desktop", + "status": "existing" + }, + "crypts/greek": { + "score": 0.0001363481133012101, + "source": "desktop", + "status": "existing" + }, + "crypts/grotto": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "crypts/haunted": { + "score": 7.087742659450669e-08, + "source": "desktop", + "status": "existing" + }, + "crypts/ice": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "crypts/labyrinth": { + "score": 0.0004177118244115263, + "source": "desktop", + "status": "existing" + }, + "crypts/lava": { + "score": 1.2817527306197007e-07, + "source": "desktop", + "status": "existing" + }, + "crypts/left": { + "score": 0.0007795006968080997, + "source": "desktop", + "status": "existing" + }, + "crypts/lighttree": { + "score": 0.0007526398403570056, + "source": "desktop", + "status": "existing" + }, + "crypts/palace": { + "score": 0.0012957408325746655, + "source": "desktop", + "status": "existing" + }, + "crypts/pit": { + "score": 0.00094228470697999, + "source": "desktop", + "status": "existing" + }, + "crypts/pyramid": { + "score": 0.0004267658805474639, + "source": "desktop", + "status": "existing" + }, + "crypts/redtree": { + "score": 1.3560287698055618e-05, + "source": "desktop", + "status": "existing" + }, + "crypts/right": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "crypts/ring": { + "score": 1.4914509449681645e-07, + "source": "desktop", + "status": "existing" + }, + "crypts/rock": { + "score": 4.011997953057289e-05, + "source": "desktop", + "status": "existing" + }, + "crypts/rune": { + "score": 5.044595673098229e-05, + "source": "desktop", + "status": "existing" + }, + "crypts/scorpion": { + "score": 1.3181751690183319e-08, + "source": "desktop", + "status": "existing" + }, + "crypts/skull": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "crypts/snake": { + "score": 5.766317485722539e-07, + "source": "desktop", + "status": "existing" + }, + "crypts/spike": { + "score": 1.313427873128603e-07, + "source": "desktop", + "status": "existing" + }, + "crypts/staired": { + "score": 2.2514267072892835e-07, + "source": "desktop", + "status": "existing" + }, + "crypts/steppyramid": { + "score": 0.003089962527155876, + "source": "desktop", + "status": "existing" + }, + "crypts/temple": { + "score": 0.005476740188896656, + "source": "desktop", + "status": "existing" + }, + "crypts/tomb": { + "score": 0.001692689023911953, + "source": "desktop", + "status": "existing" + }, + "crypts_selected": { + "score": 4.748039472701748e-08, + "source": "desktop", + "status": "existing" + }, + "crypts_unselected": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "cursed_citadel_20": { + "score": 0.012599696405231953, + "source": "desktop", + "status": "existing" + }, + "cursed_citadel_20_select": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "cursed_citadel_20_wt": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "cursed_citadel_25": { + "score": 0.00013923631922807544, + "source": "desktop", + "status": "existing" + }, + "cursed_citadel_25_select": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "cursed_citadel_25_wt": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "elven_citadel_20": { + "score": 0.002331620315089822, + "source": "desktop", + "status": "existing" + }, + "elven_citadel_20_select": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "elven_citadel_20_wt": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "elven_citadel_25": { + "score": 0.0033388882875442505, + "source": "desktop", + "status": "existing" + }, + "elven_citadel_25_select": { + "score": 1.471812112185944e-07, + "source": "desktop", + "status": "existing" + }, + "elven_citadel_25_wt": { + "score": 4.537861997278014e-08, + "source": "desktop", + "status": "existing" + }, + "email": { + "status": "skipped" + }, + "exchange": { + "status": "skipped" + }, + "exit_shop": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "explore": { + "score": 2.9246061217236274e-07, + "source": "desktop", + "status": "existing" + }, + "full_march": { + "score": 0.00012113496632082388, + "source": "desktop", + "status": "existing" + }, + "gifts": { + "score": 9.925504684815678e-08, + "source": "desktop", + "status": "existing" + }, + "gifts_selected": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "go": { + "score": 1.0855388836716884e-06, + "source": "desktop", + "status": "existing" + }, + "go_watchtower": { + "score": 2.9338000331335934e-06, + "source": "desktop", + "status": "existing" + }, + "hand": { + "score": 0.001065651886165142, + "source": "desktop", + "status": "existing" + }, + "ingrid": { + "score": 1.1557052630450926e-06, + "source": "desktop", + "status": "existing" + }, + "login_finish": { + "status": "skipped" + }, + "logo": { + "status": "skipped" + }, + "lookup": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "map": { + "score": 0.0003355202206876129, + "source": "desktop", + "status": "existing" + }, + "march": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "monsters_selected": { + "score": 2.487013830432261e-07, + "source": "desktop", + "status": "existing" + }, + "monsters_unselected": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "next": { + "status": "skipped" + }, + "no_speedups": { + "score": 0.11716637760400772, + "source": "desktop", + "status": "existing" + }, + "open": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "rare_open": { + "score": 1.5477691306386987e-07, + "source": "desktop", + "status": "existing" + }, + "selected": { + "score": 0.00032892299350351095, + "source": "desktop", + "status": "existing" + }, + "shop_loaded": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "speedup": { + "score": 0.0, + "source": "desktop", + "status": "existing" + }, + "target": { + "status": "skipped" + }, + "tengel": { + "score": 0.00018990365788340569, + "source": "desktop", + "status": "existing" + }, + "tengel_wu_march": { + "status": "skipped" + }, + "units/anteater9": { + "status": "skipped" + }, + "units/ariel2": { + "status": "skipped" + }, + "units/ballistae6": { + "score": 0.006448430009186268, + "source": "desktop", + "status": "existing" + }, + "units/black": { + "score": 0.0006288046133704484, + "source": "desktop", + "status": "existing" + }, + "units/cannoneer": { + "score": 0.0043763634748756886, + "source": "desktop", + "status": "existing" + }, + "units/catapult5": { + "score": 0.0048806872218847275, + "source": "desktop", + "status": "existing" + }, + "units/colossus": { + "score": 0.002293727593496442, + "source": "desktop", + "status": "existing" + }, + "units/corax1": { + "status": "skipped" + }, + "units/corax2": { + "status": "skipped" + }, + "units/crystal": { + "score": 0.0009140402544289827, + "source": "desktop", + "status": "existing" + }, + "units/deadshot6": { + "status": "skipped" + }, + "units/deadshot7": { + "score": 0.0003247589629609138, + "source": "desktop", + "status": "existing" + }, + "units/defender9": { + "status": "skipped" + }, + "units/devastator1": { + "score": 0.007357090245932341, + "source": "desktop", + "status": "existing" + }, + "units/devastator2": { + "score": 0.0003045027842745185, + "source": "desktop", + "status": "existing" + }, + "units/duelist1": { + "score": 0.0017152727814391255, + "source": "desktop", + "status": "existing" + }, + "units/duelist2": { + "status": "skipped" + }, + "units/fire-phoenix1": { + "score": 0.005956209730356932, + "source": "desktop", + "status": "existing" + }, + "units/fire-phoenix2": { + "score": 0.007370104547590017, + "source": "desktop", + "status": "existing" + }, + "units/galloper2": { + "score": 0.00042602463508956134, + "source": "desktop", + "status": "existing" + }, + "units/griffin5": { + "score": 0.00042699254117906094, + "source": "desktop", + "status": "existing" + }, + "units/griffin6": { + "score": 0.00046346901217475533, + "source": "desktop", + "status": "existing" + }, + "units/griffin7": { + "score": 0.003581954864785075, + "source": "desktop", + "status": "existing" + }, + "units/heavy-arbalester6": { + "score": 0.020127415657043457, + "source": "desktop", + "status": "existing" + }, + "units/heavy-arbalester7": { + "score": 0.0036609265953302383, + "source": "desktop", + "status": "existing" + }, + "units/heavy-halberdier6": { + "score": 0.003911010921001434, + "source": "desktop", + "status": "existing" + }, + "units/heavy-halberdier7": { + "score": 0.0016891079721972346, + "source": "desktop", + "status": "existing" + }, + "units/heavy-knight6": { + "score": 0.003805936314165592, + "source": "desktop", + "status": "existing" + }, + "units/heavy-knight7": { + "score": 0.012140224687755108, + "source": "desktop", + "status": "existing" + }, + "units/highlander2": { + "score": 0.00429085036739707, + "source": "desktop", + "status": "existing" + }, + "units/jago2": { + "score": 0.00022989787976257503, + "source": "desktop", + "status": "existing" + }, + "units/josephine2": { + "status": "skipped" + }, + "units/jungle-destroyer": { + "score": 0.00015606153465341777, + "source": "desktop", + "status": "existing" + }, + "units/kraken1": { + "score": 0.003336937865242362, + "source": "desktop", + "status": "existing" + }, + "units/kraken2": { + "score": 0.0057405526749789715, + "source": "desktop", + "status": "existing" + }, + "units/legitimist1": { + "score": 0.02286403253674507, + "source": "desktop", + "status": "existing" + }, + "units/legitimist2": { + "status": "skipped" + }, + "units/lion-rider6": { + "score": 0.001019490766339004, + "source": "desktop", + "status": "existing" + }, + "units/lion-rider7": { + "score": 2.3479893570765853e-05, + "source": "desktop", + "status": "existing" + }, + "units/manticore": { + "score": 0.004596727900207043, + "source": "desktop", + "status": "existing" + }, + "units/mounted-knight6": { + "score": 0.013069191947579384, + "source": "desktop", + "status": "existing" + }, + "units/mounted-knight7": { + "score": 0.00039250828558579087, + "source": "desktop", + "status": "existing" + }, + "units/pounder2": { + "score": 0.0013576023047789931, + "source": "desktop", + "status": "existing" + }, + "units/punisher1": { + "status": "skipped" + }, + "units/punisher2": { + "status": "skipped" + }, + "units/purifier1": { + "status": "skipped" + }, + "units/purifier2": { + "status": "skipped" + }, + "units/quicksand2": { + "score": 0.001074916566722095, + "source": "desktop", + "status": "existing" + }, + "units/royal-lion1": { + "score": 0.007026767823845148, + "source": "desktop", + "status": "existing" + }, + "units/royal-lion2": { + "status": "skipped" + }, + "units/ruby": { + "score": 3.264907718403265e-05, + "source": "desktop", + "status": "existing" + }, + "units/salamander": { + "score": 0.0012390910414978862, + "source": "desktop", + "status": "existing" + }, + "units/scarface2": { + "score": 0.002234273124486208, + "source": "desktop", + "status": "existing" + }, + "units/slavic2": { + "score": 0.0021102011669427156, + "source": "desktop", + "status": "existing" + }, + "units/smiter1": { + "status": "skipped" + }, + "units/smiter2": { + "status": "skipped" + }, + "units/stalker9": { + "status": "skipped" + }, + "units/superior-hunter2": { + "score": 0.002617922145873308, + "source": "desktop", + "status": "existing" + }, + "units/terror": { + "score": 0.0015193395083770156, + "source": "desktop", + "status": "existing" + }, + "units/trickster1": { + "score": 0.013821613974869251, + "source": "desktop", + "status": "existing" + }, + "units/trickster2": { + "score": 0.022793825715780258, + "source": "desktop", + "status": "existing" + }, + "units/troll": { + "score": 0.0020551395136862993, + "source": "desktop", + "status": "existing" + }, + "units/vulture6": { + "score": 0.00424960907548666, + "source": "desktop", + "status": "existing" + }, + "units/vulture7": { + "score": 0.017469970509409904, + "source": "desktop", + "status": "existing" + }, + "units/warden": { + "score": 0.0008957363897934556, + "source": "desktop", + "status": "existing" + }, + "units/warregal2": { + "score": 0.0009511340176686645, + "source": "desktop", + "status": "existing" + }, + "units/wasp-man9": { + "status": "skipped" + }, + "units/whitemane1": { + "score": 0.00665618060156703, + "source": "desktop", + "status": "existing" + }, + "units/whitemane2": { + "status": "skipped" + }, + "units/wind": { + "score": 0.010632743127644062, + "source": "desktop", + "status": "existing" + }, + "units/wyvern": { + "score": 0.0012686954578384757, + "source": "desktop", + "status": "existing" + }, + "watchtower": { + "score": 0.005386506672948599, + "source": "desktop", + "status": "existing" + }, + "wu": { + "score": 4.719614298664965e-05, + "source": "desktop", + "status": "existing" + }, + "wu_march": { + "score": 0.09185593575239182, + "source": "desktop", + "status": "existing" + }, + "zoom_out": { + "score": 0.020536672323942184, + "source": "desktop", + "status": "existing" + } +} diff --git a/samples_desktop/army.png b/samples_desktop/army.png new file mode 100644 index 0000000..dab12be Binary files /dev/null and b/samples_desktop/army.png differ diff --git a/samples_desktop/attack.png b/samples_desktop/attack.png new file mode 100644 index 0000000..806f57e Binary files /dev/null and b/samples_desktop/attack.png differ diff --git a/samples_desktop/aydae.png b/samples_desktop/aydae.png new file mode 100644 index 0000000..4e60855 Binary files /dev/null and b/samples_desktop/aydae.png differ diff --git a/samples_desktop/aydae_brunhild_march.png b/samples_desktop/aydae_brunhild_march.png new file mode 100644 index 0000000..e8c1c14 Binary files /dev/null and b/samples_desktop/aydae_brunhild_march.png differ diff --git a/samples_desktop/battle.png b/samples_desktop/battle.png new file mode 100644 index 0000000..4656bb8 Binary files /dev/null and b/samples_desktop/battle.png differ diff --git a/samples_desktop/brunhild.png b/samples_desktop/brunhild.png new file mode 100644 index 0000000..1b846a6 Binary files /dev/null and b/samples_desktop/brunhild.png differ diff --git a/samples_desktop/carter_march.png b/samples_desktop/carter_march.png new file mode 100644 index 0000000..24a0ece Binary files /dev/null and b/samples_desktop/carter_march.png differ diff --git a/samples_desktop/carter_on_march.png b/samples_desktop/carter_on_march.png new file mode 100644 index 0000000..d4a7f5a Binary files /dev/null and b/samples_desktop/carter_on_march.png differ diff --git a/samples_desktop/carter_selected.png b/samples_desktop/carter_selected.png new file mode 100644 index 0000000..43efa4c Binary files /dev/null and b/samples_desktop/carter_selected.png differ diff --git a/samples_desktop/citadel_captain_on_march.png b/samples_desktop/citadel_captain_on_march.png new file mode 100644 index 0000000..4476aab Binary files /dev/null and b/samples_desktop/citadel_captain_on_march.png differ diff --git a/samples_desktop/citadel_key_20.png b/samples_desktop/citadel_key_20.png new file mode 100644 index 0000000..81e2291 Binary files /dev/null and b/samples_desktop/citadel_key_20.png differ diff --git a/samples_desktop/citadel_key_25.png b/samples_desktop/citadel_key_25.png new file mode 100644 index 0000000..691ff91 Binary files /dev/null and b/samples_desktop/citadel_key_25.png differ diff --git a/samples_desktop/citadel_key_30.png b/samples_desktop/citadel_key_30.png new file mode 100644 index 0000000..d9c2265 Binary files /dev/null and b/samples_desktop/citadel_key_30.png differ diff --git a/samples_desktop/city.png b/samples_desktop/city.png new file mode 100644 index 0000000..ab1dc24 Binary files /dev/null and b/samples_desktop/city.png differ diff --git a/samples_desktop/city_test.png b/samples_desktop/city_test.png new file mode 100644 index 0000000..2e60090 Binary files /dev/null and b/samples_desktop/city_test.png differ diff --git a/samples_desktop/clan.png b/samples_desktop/clan.png new file mode 100644 index 0000000..7ef1d78 Binary files /dev/null and b/samples_desktop/clan.png differ diff --git a/samples_desktop/common_selected.png b/samples_desktop/common_selected.png new file mode 100644 index 0000000..8a4755f Binary files /dev/null and b/samples_desktop/common_selected.png differ diff --git a/samples_desktop/common_unselected.png b/samples_desktop/common_unselected.png new file mode 100644 index 0000000..ae31140 Binary files /dev/null and b/samples_desktop/common_unselected.png differ diff --git a/samples_desktop/confirm_permanent_removal.png b/samples_desktop/confirm_permanent_removal.png new file mode 100644 index 0000000..b958d33 Binary files /dev/null and b/samples_desktop/confirm_permanent_removal.png differ diff --git a/samples_desktop/crypts/cathedral.png b/samples_desktop/crypts/cathedral.png new file mode 100644 index 0000000..6580f20 Binary files /dev/null and b/samples_desktop/crypts/cathedral.png differ diff --git a/samples_desktop/crypts/cave.png b/samples_desktop/crypts/cave.png new file mode 100644 index 0000000..3a42895 Binary files /dev/null and b/samples_desktop/crypts/cave.png differ diff --git a/samples_desktop/crypts/cog.png b/samples_desktop/crypts/cog.png new file mode 100644 index 0000000..16b21c7 Binary files /dev/null and b/samples_desktop/crypts/cog.png differ diff --git a/samples_desktop/crypts/crystal.png b/samples_desktop/crypts/crystal.png new file mode 100644 index 0000000..d03b221 Binary files /dev/null and b/samples_desktop/crypts/crystal.png differ diff --git a/samples_desktop/crypts/darktree.png b/samples_desktop/crypts/darktree.png new file mode 100644 index 0000000..c3b0aa6 Binary files /dev/null and b/samples_desktop/crypts/darktree.png differ diff --git a/samples_desktop/crypts/dune.png b/samples_desktop/crypts/dune.png new file mode 100644 index 0000000..6756a8c Binary files /dev/null and b/samples_desktop/crypts/dune.png differ diff --git a/samples_desktop/crypts/epicpyramid.png b/samples_desktop/crypts/epicpyramid.png new file mode 100644 index 0000000..d81e019 Binary files /dev/null and b/samples_desktop/crypts/epicpyramid.png differ diff --git a/samples_desktop/crypts/greek.png b/samples_desktop/crypts/greek.png new file mode 100644 index 0000000..b941d4e Binary files /dev/null and b/samples_desktop/crypts/greek.png differ diff --git a/samples_desktop/crypts/grotto.png b/samples_desktop/crypts/grotto.png new file mode 100644 index 0000000..c64d3e1 Binary files /dev/null and b/samples_desktop/crypts/grotto.png differ diff --git a/samples_desktop/crypts/haunted.png b/samples_desktop/crypts/haunted.png new file mode 100644 index 0000000..adc1e8f Binary files /dev/null and b/samples_desktop/crypts/haunted.png differ diff --git a/samples_desktop/crypts/ice.png b/samples_desktop/crypts/ice.png new file mode 100644 index 0000000..fee030a Binary files /dev/null and b/samples_desktop/crypts/ice.png differ diff --git a/samples_desktop/crypts/labyrinth.png b/samples_desktop/crypts/labyrinth.png new file mode 100644 index 0000000..83d6f13 Binary files /dev/null and b/samples_desktop/crypts/labyrinth.png differ diff --git a/samples_desktop/crypts/lava.png b/samples_desktop/crypts/lava.png new file mode 100644 index 0000000..b5630eb Binary files /dev/null and b/samples_desktop/crypts/lava.png differ diff --git a/samples_desktop/crypts/left.png b/samples_desktop/crypts/left.png new file mode 100644 index 0000000..8733228 Binary files /dev/null and b/samples_desktop/crypts/left.png differ diff --git a/samples_desktop/crypts/lighttree.png b/samples_desktop/crypts/lighttree.png new file mode 100644 index 0000000..3f19890 Binary files /dev/null and b/samples_desktop/crypts/lighttree.png differ diff --git a/samples_desktop/crypts/palace.png b/samples_desktop/crypts/palace.png new file mode 100644 index 0000000..a6594ab Binary files /dev/null and b/samples_desktop/crypts/palace.png differ diff --git a/samples_desktop/crypts/pit.png b/samples_desktop/crypts/pit.png new file mode 100644 index 0000000..06ddc87 Binary files /dev/null and b/samples_desktop/crypts/pit.png differ diff --git a/samples_desktop/crypts/pyramid.png b/samples_desktop/crypts/pyramid.png new file mode 100644 index 0000000..94f4af1 Binary files /dev/null and b/samples_desktop/crypts/pyramid.png differ diff --git a/samples_desktop/crypts/redtree.png b/samples_desktop/crypts/redtree.png new file mode 100644 index 0000000..fafb5e0 Binary files /dev/null and b/samples_desktop/crypts/redtree.png differ diff --git a/samples_desktop/crypts/right.png b/samples_desktop/crypts/right.png new file mode 100644 index 0000000..4d79550 Binary files /dev/null and b/samples_desktop/crypts/right.png differ diff --git a/samples_desktop/crypts/ring.png b/samples_desktop/crypts/ring.png new file mode 100644 index 0000000..0d55ece Binary files /dev/null and b/samples_desktop/crypts/ring.png differ diff --git a/samples_desktop/crypts/rock.png b/samples_desktop/crypts/rock.png new file mode 100644 index 0000000..6ea9b99 Binary files /dev/null and b/samples_desktop/crypts/rock.png differ diff --git a/samples_desktop/crypts/rune.png b/samples_desktop/crypts/rune.png new file mode 100644 index 0000000..ea6913a Binary files /dev/null and b/samples_desktop/crypts/rune.png differ diff --git a/samples_desktop/crypts/scorpion.png b/samples_desktop/crypts/scorpion.png new file mode 100644 index 0000000..ed268f4 Binary files /dev/null and b/samples_desktop/crypts/scorpion.png differ diff --git a/samples_desktop/crypts/skull.png b/samples_desktop/crypts/skull.png new file mode 100644 index 0000000..91eeb7c Binary files /dev/null and b/samples_desktop/crypts/skull.png differ diff --git a/samples_desktop/crypts/snake.png b/samples_desktop/crypts/snake.png new file mode 100644 index 0000000..57f18f8 Binary files /dev/null and b/samples_desktop/crypts/snake.png differ diff --git a/samples_desktop/crypts/spike.png b/samples_desktop/crypts/spike.png new file mode 100644 index 0000000..29a0cd8 Binary files /dev/null and b/samples_desktop/crypts/spike.png differ diff --git a/samples_desktop/crypts/staired.png b/samples_desktop/crypts/staired.png new file mode 100644 index 0000000..343f3cb Binary files /dev/null and b/samples_desktop/crypts/staired.png differ diff --git a/samples_desktop/crypts/steppyramid.png b/samples_desktop/crypts/steppyramid.png new file mode 100644 index 0000000..4976e0b Binary files /dev/null and b/samples_desktop/crypts/steppyramid.png differ diff --git a/samples_desktop/crypts/temple.png b/samples_desktop/crypts/temple.png new file mode 100644 index 0000000..7db5039 Binary files /dev/null and b/samples_desktop/crypts/temple.png differ diff --git a/samples_desktop/crypts/tomb.png b/samples_desktop/crypts/tomb.png new file mode 100644 index 0000000..d1ebc49 Binary files /dev/null and b/samples_desktop/crypts/tomb.png differ diff --git a/samples_desktop/crypts_selected.png b/samples_desktop/crypts_selected.png new file mode 100644 index 0000000..7afb627 Binary files /dev/null and b/samples_desktop/crypts_selected.png differ diff --git a/samples_desktop/crypts_unselected.png b/samples_desktop/crypts_unselected.png new file mode 100644 index 0000000..5670bd4 Binary files /dev/null and b/samples_desktop/crypts_unselected.png differ diff --git a/samples_desktop/cursed_citadel_20.png b/samples_desktop/cursed_citadel_20.png new file mode 100644 index 0000000..1310d86 Binary files /dev/null and b/samples_desktop/cursed_citadel_20.png differ diff --git a/samples_desktop/cursed_citadel_20_select.png b/samples_desktop/cursed_citadel_20_select.png new file mode 100644 index 0000000..76c66a7 Binary files /dev/null and b/samples_desktop/cursed_citadel_20_select.png differ diff --git a/samples_desktop/cursed_citadel_20_wt.png b/samples_desktop/cursed_citadel_20_wt.png new file mode 100644 index 0000000..b017348 Binary files /dev/null and b/samples_desktop/cursed_citadel_20_wt.png differ diff --git a/samples_desktop/cursed_citadel_25.png b/samples_desktop/cursed_citadel_25.png new file mode 100644 index 0000000..d7ed6c0 Binary files /dev/null and b/samples_desktop/cursed_citadel_25.png differ diff --git a/samples_desktop/cursed_citadel_25_select.png b/samples_desktop/cursed_citadel_25_select.png new file mode 100644 index 0000000..26acb28 Binary files /dev/null and b/samples_desktop/cursed_citadel_25_select.png differ diff --git a/samples_desktop/cursed_citadel_25_wt.png b/samples_desktop/cursed_citadel_25_wt.png new file mode 100644 index 0000000..fd03f21 Binary files /dev/null and b/samples_desktop/cursed_citadel_25_wt.png differ diff --git a/samples_desktop/elven_citadel_20.png b/samples_desktop/elven_citadel_20.png new file mode 100644 index 0000000..64b29a8 Binary files /dev/null and b/samples_desktop/elven_citadel_20.png differ diff --git a/samples_desktop/elven_citadel_20_select.png b/samples_desktop/elven_citadel_20_select.png new file mode 100644 index 0000000..1f9153a Binary files /dev/null and b/samples_desktop/elven_citadel_20_select.png differ diff --git a/samples_desktop/elven_citadel_20_wt.png b/samples_desktop/elven_citadel_20_wt.png new file mode 100644 index 0000000..2bbe48c Binary files /dev/null and b/samples_desktop/elven_citadel_20_wt.png differ diff --git a/samples_desktop/elven_citadel_25.png b/samples_desktop/elven_citadel_25.png new file mode 100644 index 0000000..fab3cb9 Binary files /dev/null and b/samples_desktop/elven_citadel_25.png differ diff --git a/samples_desktop/elven_citadel_25_select.png b/samples_desktop/elven_citadel_25_select.png new file mode 100644 index 0000000..41eccec Binary files /dev/null and b/samples_desktop/elven_citadel_25_select.png differ diff --git a/samples_desktop/elven_citadel_25_wt.png b/samples_desktop/elven_citadel_25_wt.png new file mode 100644 index 0000000..5dea2d0 Binary files /dev/null and b/samples_desktop/elven_citadel_25_wt.png differ diff --git a/samples_desktop/elven_citadel_30_select.png b/samples_desktop/elven_citadel_30_select.png new file mode 100644 index 0000000..953548b Binary files /dev/null and b/samples_desktop/elven_citadel_30_select.png differ diff --git a/samples_desktop/elven_citadel_30_wt.png b/samples_desktop/elven_citadel_30_wt.png new file mode 100644 index 0000000..e1ed4ba Binary files /dev/null and b/samples_desktop/elven_citadel_30_wt.png differ diff --git a/samples_desktop/epic_selected.png b/samples_desktop/epic_selected.png new file mode 100644 index 0000000..31f4a68 Binary files /dev/null and b/samples_desktop/epic_selected.png differ diff --git a/samples_desktop/epic_unselected.png b/samples_desktop/epic_unselected.png new file mode 100644 index 0000000..10e299e Binary files /dev/null and b/samples_desktop/epic_unselected.png differ diff --git a/samples_desktop/exchange.png b/samples_desktop/exchange.png new file mode 100644 index 0000000..262eb5c Binary files /dev/null and b/samples_desktop/exchange.png differ diff --git a/samples_desktop/exit_shop.png b/samples_desktop/exit_shop.png new file mode 100644 index 0000000..5d1174e Binary files /dev/null and b/samples_desktop/exit_shop.png differ diff --git a/samples_desktop/explore.png b/samples_desktop/explore.png new file mode 100644 index 0000000..8175af0 Binary files /dev/null and b/samples_desktop/explore.png differ diff --git a/samples_desktop/full_march.png b/samples_desktop/full_march.png new file mode 100644 index 0000000..dea0f9c Binary files /dev/null and b/samples_desktop/full_march.png differ diff --git a/samples_desktop/generic_march.png b/samples_desktop/generic_march.png new file mode 100644 index 0000000..51f39e1 Binary files /dev/null and b/samples_desktop/generic_march.png differ diff --git a/samples_desktop/gifts.png b/samples_desktop/gifts.png new file mode 100644 index 0000000..554d3d0 Binary files /dev/null and b/samples_desktop/gifts.png differ diff --git a/samples_desktop/gifts_selected.png b/samples_desktop/gifts_selected.png new file mode 100644 index 0000000..1a857c1 Binary files /dev/null and b/samples_desktop/gifts_selected.png differ diff --git a/samples_desktop/go.png b/samples_desktop/go.png new file mode 100644 index 0000000..f8f21fb Binary files /dev/null and b/samples_desktop/go.png differ diff --git a/samples_desktop/go_watchtower.png b/samples_desktop/go_watchtower.png new file mode 100644 index 0000000..1a2e5ed Binary files /dev/null and b/samples_desktop/go_watchtower.png differ diff --git a/samples_desktop/hand.png b/samples_desktop/hand.png new file mode 100644 index 0000000..d4f2b67 Binary files /dev/null and b/samples_desktop/hand.png differ diff --git a/samples_desktop/ingrid.png b/samples_desktop/ingrid.png new file mode 100644 index 0000000..28e2e69 Binary files /dev/null and b/samples_desktop/ingrid.png differ diff --git a/samples_desktop/lookup.png b/samples_desktop/lookup.png new file mode 100644 index 0000000..9bb2696 Binary files /dev/null and b/samples_desktop/lookup.png differ diff --git a/samples_desktop/losses.png b/samples_desktop/losses.png new file mode 100644 index 0000000..0607c08 Binary files /dev/null and b/samples_desktop/losses.png differ diff --git a/samples_desktop/map.png b/samples_desktop/map.png new file mode 100644 index 0000000..36d0645 Binary files /dev/null and b/samples_desktop/map.png differ diff --git a/samples_desktop/march.png b/samples_desktop/march.png new file mode 100644 index 0000000..aed1018 Binary files /dev/null and b/samples_desktop/march.png differ diff --git a/samples_desktop/march_speedup_button.png b/samples_desktop/march_speedup_button.png new file mode 100644 index 0000000..d486095 Binary files /dev/null and b/samples_desktop/march_speedup_button.png differ diff --git a/samples_desktop/max_speedup.png b/samples_desktop/max_speedup.png new file mode 100644 index 0000000..1552da2 Binary files /dev/null and b/samples_desktop/max_speedup.png differ diff --git a/samples_desktop/monsters_selected.png b/samples_desktop/monsters_selected.png new file mode 100644 index 0000000..c500d69 Binary files /dev/null and b/samples_desktop/monsters_selected.png differ diff --git a/samples_desktop/monsters_unselected.png b/samples_desktop/monsters_unselected.png new file mode 100644 index 0000000..7c08a01 Binary files /dev/null and b/samples_desktop/monsters_unselected.png differ diff --git a/samples_desktop/no_speedups.png b/samples_desktop/no_speedups.png new file mode 100644 index 0000000..9ae7208 Binary files /dev/null and b/samples_desktop/no_speedups.png differ diff --git a/samples_desktop/open.png b/samples_desktop/open.png new file mode 100644 index 0000000..6ee5ef8 Binary files /dev/null and b/samples_desktop/open.png differ diff --git a/samples_desktop/permanent_removal.png b/samples_desktop/permanent_removal.png new file mode 100644 index 0000000..e2b4940 Binary files /dev/null and b/samples_desktop/permanent_removal.png differ diff --git a/samples_desktop/rare_open.png b/samples_desktop/rare_open.png new file mode 100644 index 0000000..eef1df9 Binary files /dev/null and b/samples_desktop/rare_open.png differ diff --git a/samples_desktop/rare_selected.png b/samples_desktop/rare_selected.png new file mode 100644 index 0000000..2dc6de8 Binary files /dev/null and b/samples_desktop/rare_selected.png differ diff --git a/samples_desktop/rare_unselected.png b/samples_desktop/rare_unselected.png new file mode 100644 index 0000000..295d154 Binary files /dev/null and b/samples_desktop/rare_unselected.png differ diff --git a/samples_desktop/revive_all.png b/samples_desktop/revive_all.png new file mode 100644 index 0000000..4d1e2e6 Binary files /dev/null and b/samples_desktop/revive_all.png differ diff --git a/samples_desktop/revive_gold.png b/samples_desktop/revive_gold.png new file mode 100644 index 0000000..df7bf62 Binary files /dev/null and b/samples_desktop/revive_gold.png differ diff --git a/samples_desktop/same_page_test.png b/samples_desktop/same_page_test.png new file mode 100644 index 0000000..d9cc909 Binary files /dev/null and b/samples_desktop/same_page_test.png differ diff --git a/samples_desktop/selected.png b/samples_desktop/selected.png new file mode 100644 index 0000000..b3ee8dd Binary files /dev/null and b/samples_desktop/selected.png differ diff --git a/samples_desktop/shop_loaded.png b/samples_desktop/shop_loaded.png new file mode 100644 index 0000000..3f5d6df Binary files /dev/null and b/samples_desktop/shop_loaded.png differ diff --git a/samples_desktop/speedup.png b/samples_desktop/speedup.png new file mode 100644 index 0000000..d31de2d Binary files /dev/null and b/samples_desktop/speedup.png differ diff --git a/samples_desktop/tengel.png b/samples_desktop/tengel.png new file mode 100644 index 0000000..7c6ff1b Binary files /dev/null and b/samples_desktop/tengel.png differ diff --git a/samples_desktop/tengel_wu_march.png b/samples_desktop/tengel_wu_march.png new file mode 100644 index 0000000..bb959bd Binary files /dev/null and b/samples_desktop/tengel_wu_march.png differ diff --git a/samples_desktop/units/ballistae6.png b/samples_desktop/units/ballistae6.png new file mode 100644 index 0000000..1e975fe Binary files /dev/null and b/samples_desktop/units/ballistae6.png differ diff --git a/samples_desktop/units/ballistae7.png b/samples_desktop/units/ballistae7.png new file mode 100644 index 0000000..7522d17 Binary files /dev/null and b/samples_desktop/units/ballistae7.png differ diff --git a/samples_desktop/units/black.png b/samples_desktop/units/black.png new file mode 100644 index 0000000..645b99c Binary files /dev/null and b/samples_desktop/units/black.png differ diff --git a/samples_desktop/units/cannoneer.png b/samples_desktop/units/cannoneer.png new file mode 100644 index 0000000..a5f82af Binary files /dev/null and b/samples_desktop/units/cannoneer.png differ diff --git a/samples_desktop/units/catapult5.png b/samples_desktop/units/catapult5.png new file mode 100644 index 0000000..2b32cd6 Binary files /dev/null and b/samples_desktop/units/catapult5.png differ diff --git a/samples_desktop/units/catapult_4.png b/samples_desktop/units/catapult_4.png new file mode 100644 index 0000000..542f4ed Binary files /dev/null and b/samples_desktop/units/catapult_4.png differ diff --git a/samples_desktop/units/colossus.png b/samples_desktop/units/colossus.png new file mode 100644 index 0000000..6b66372 Binary files /dev/null and b/samples_desktop/units/colossus.png differ diff --git a/samples_desktop/units/corax1.png b/samples_desktop/units/corax1.png new file mode 100644 index 0000000..7c23743 Binary files /dev/null and b/samples_desktop/units/corax1.png differ diff --git a/samples_desktop/units/crystal.png b/samples_desktop/units/crystal.png new file mode 100644 index 0000000..8b17fdb Binary files /dev/null and b/samples_desktop/units/crystal.png differ diff --git a/samples_desktop/units/deadshot6.png b/samples_desktop/units/deadshot6.png new file mode 100644 index 0000000..524fdc6 Binary files /dev/null and b/samples_desktop/units/deadshot6.png differ diff --git a/samples_desktop/units/deadshot7.png b/samples_desktop/units/deadshot7.png new file mode 100644 index 0000000..145500d Binary files /dev/null and b/samples_desktop/units/deadshot7.png differ diff --git a/samples_desktop/units/devastator1.png b/samples_desktop/units/devastator1.png new file mode 100644 index 0000000..62a6d64 Binary files /dev/null and b/samples_desktop/units/devastator1.png differ diff --git a/samples_desktop/units/devastator2.png b/samples_desktop/units/devastator2.png new file mode 100644 index 0000000..2c8ed38 Binary files /dev/null and b/samples_desktop/units/devastator2.png differ diff --git a/samples_desktop/units/duelist1.png b/samples_desktop/units/duelist1.png new file mode 100644 index 0000000..d9774e1 Binary files /dev/null and b/samples_desktop/units/duelist1.png differ diff --git a/samples_desktop/units/fire-phoenix1.png b/samples_desktop/units/fire-phoenix1.png new file mode 100644 index 0000000..ca0d786 Binary files /dev/null and b/samples_desktop/units/fire-phoenix1.png differ diff --git a/samples_desktop/units/fire-phoenix2.png b/samples_desktop/units/fire-phoenix2.png new file mode 100644 index 0000000..a415332 Binary files /dev/null and b/samples_desktop/units/fire-phoenix2.png differ diff --git a/samples_desktop/units/galloper2.png b/samples_desktop/units/galloper2.png new file mode 100644 index 0000000..4ae6b70 Binary files /dev/null and b/samples_desktop/units/galloper2.png differ diff --git a/samples_desktop/units/griffin5.png b/samples_desktop/units/griffin5.png new file mode 100644 index 0000000..c24dc87 Binary files /dev/null and b/samples_desktop/units/griffin5.png differ diff --git a/samples_desktop/units/griffin6.png b/samples_desktop/units/griffin6.png new file mode 100644 index 0000000..b3f5521 Binary files /dev/null and b/samples_desktop/units/griffin6.png differ diff --git a/samples_desktop/units/griffin7.png b/samples_desktop/units/griffin7.png new file mode 100644 index 0000000..56b92cc Binary files /dev/null and b/samples_desktop/units/griffin7.png differ diff --git a/samples_desktop/units/heavy-arbalester6.png b/samples_desktop/units/heavy-arbalester6.png new file mode 100644 index 0000000..c1175f6 Binary files /dev/null and b/samples_desktop/units/heavy-arbalester6.png differ diff --git a/samples_desktop/units/heavy-arbalester7.png b/samples_desktop/units/heavy-arbalester7.png new file mode 100644 index 0000000..e9f1bcb Binary files /dev/null and b/samples_desktop/units/heavy-arbalester7.png differ diff --git a/samples_desktop/units/heavy-halberdier6.png b/samples_desktop/units/heavy-halberdier6.png new file mode 100644 index 0000000..b329baa Binary files /dev/null and b/samples_desktop/units/heavy-halberdier6.png differ diff --git a/samples_desktop/units/heavy-halberdier7.png b/samples_desktop/units/heavy-halberdier7.png new file mode 100644 index 0000000..ec9d589 Binary files /dev/null and b/samples_desktop/units/heavy-halberdier7.png differ diff --git a/samples_desktop/units/heavy-knight6.png b/samples_desktop/units/heavy-knight6.png new file mode 100644 index 0000000..f74073c Binary files /dev/null and b/samples_desktop/units/heavy-knight6.png differ diff --git a/samples_desktop/units/heavy-knight7.png b/samples_desktop/units/heavy-knight7.png new file mode 100644 index 0000000..b1e8c46 Binary files /dev/null and b/samples_desktop/units/heavy-knight7.png differ diff --git a/samples_desktop/units/highlander2.png b/samples_desktop/units/highlander2.png new file mode 100644 index 0000000..aef9ddd Binary files /dev/null and b/samples_desktop/units/highlander2.png differ diff --git a/samples_desktop/units/jago2.png b/samples_desktop/units/jago2.png new file mode 100644 index 0000000..a25fccb Binary files /dev/null and b/samples_desktop/units/jago2.png differ diff --git a/samples_desktop/units/josephine1.png b/samples_desktop/units/josephine1.png new file mode 100644 index 0000000..d44cfa8 Binary files /dev/null and b/samples_desktop/units/josephine1.png differ diff --git a/samples_desktop/units/jungle-destroyer.png b/samples_desktop/units/jungle-destroyer.png new file mode 100644 index 0000000..d8fb218 Binary files /dev/null and b/samples_desktop/units/jungle-destroyer.png differ diff --git a/samples_desktop/units/kraken1.png b/samples_desktop/units/kraken1.png new file mode 100644 index 0000000..47ead75 Binary files /dev/null and b/samples_desktop/units/kraken1.png differ diff --git a/samples_desktop/units/kraken2.png b/samples_desktop/units/kraken2.png new file mode 100644 index 0000000..96dbd5c Binary files /dev/null and b/samples_desktop/units/kraken2.png differ diff --git a/samples_desktop/units/legitimist1.png b/samples_desktop/units/legitimist1.png new file mode 100644 index 0000000..3ea6790 Binary files /dev/null and b/samples_desktop/units/legitimist1.png differ diff --git a/samples_desktop/units/lion-rider6.png b/samples_desktop/units/lion-rider6.png new file mode 100644 index 0000000..ff69406 Binary files /dev/null and b/samples_desktop/units/lion-rider6.png differ diff --git a/samples_desktop/units/lion-rider7.png b/samples_desktop/units/lion-rider7.png new file mode 100644 index 0000000..bc10219 Binary files /dev/null and b/samples_desktop/units/lion-rider7.png differ diff --git a/samples_desktop/units/manticore.png b/samples_desktop/units/manticore.png new file mode 100644 index 0000000..0d3f7bc Binary files /dev/null and b/samples_desktop/units/manticore.png differ diff --git a/samples_desktop/units/mounted-knight6.png b/samples_desktop/units/mounted-knight6.png new file mode 100644 index 0000000..287d197 Binary files /dev/null and b/samples_desktop/units/mounted-knight6.png differ diff --git a/samples_desktop/units/mounted-knight7.png b/samples_desktop/units/mounted-knight7.png new file mode 100644 index 0000000..d0f8ec1 Binary files /dev/null and b/samples_desktop/units/mounted-knight7.png differ diff --git a/samples_desktop/units/pounder2.png b/samples_desktop/units/pounder2.png new file mode 100644 index 0000000..e073492 Binary files /dev/null and b/samples_desktop/units/pounder2.png differ diff --git a/samples_desktop/units/punisher1.png b/samples_desktop/units/punisher1.png new file mode 100644 index 0000000..831bef0 Binary files /dev/null and b/samples_desktop/units/punisher1.png differ diff --git a/samples_desktop/units/purifier1.png b/samples_desktop/units/purifier1.png new file mode 100644 index 0000000..609d595 Binary files /dev/null and b/samples_desktop/units/purifier1.png differ diff --git a/samples_desktop/units/quicksand2.png b/samples_desktop/units/quicksand2.png new file mode 100644 index 0000000..40157d0 Binary files /dev/null and b/samples_desktop/units/quicksand2.png differ diff --git a/samples_desktop/units/royal-lion1.png b/samples_desktop/units/royal-lion1.png new file mode 100644 index 0000000..beb39df Binary files /dev/null and b/samples_desktop/units/royal-lion1.png differ diff --git a/samples_desktop/units/ruby.png b/samples_desktop/units/ruby.png new file mode 100644 index 0000000..4c584af Binary files /dev/null and b/samples_desktop/units/ruby.png differ diff --git a/samples_desktop/units/salamander.png b/samples_desktop/units/salamander.png new file mode 100644 index 0000000..17633a8 Binary files /dev/null and b/samples_desktop/units/salamander.png differ diff --git a/samples_desktop/units/scarface2.png b/samples_desktop/units/scarface2.png new file mode 100644 index 0000000..e450558 Binary files /dev/null and b/samples_desktop/units/scarface2.png differ diff --git a/samples_desktop/units/slavic2.png b/samples_desktop/units/slavic2.png new file mode 100644 index 0000000..bb773d4 Binary files /dev/null and b/samples_desktop/units/slavic2.png differ diff --git a/samples_desktop/units/smiter1.png b/samples_desktop/units/smiter1.png new file mode 100644 index 0000000..f0a0ebc Binary files /dev/null and b/samples_desktop/units/smiter1.png differ diff --git a/samples_desktop/units/superior-hunter2.png b/samples_desktop/units/superior-hunter2.png new file mode 100644 index 0000000..cd18f07 Binary files /dev/null and b/samples_desktop/units/superior-hunter2.png differ diff --git a/samples_desktop/units/terror.png b/samples_desktop/units/terror.png new file mode 100644 index 0000000..2c12d26 Binary files /dev/null and b/samples_desktop/units/terror.png differ diff --git a/samples_desktop/units/trickster1.png b/samples_desktop/units/trickster1.png new file mode 100644 index 0000000..0c5af90 Binary files /dev/null and b/samples_desktop/units/trickster1.png differ diff --git a/samples_desktop/units/trickster2.png b/samples_desktop/units/trickster2.png new file mode 100644 index 0000000..c4f7d97 Binary files /dev/null and b/samples_desktop/units/trickster2.png differ diff --git a/samples_desktop/units/troll.png b/samples_desktop/units/troll.png new file mode 100644 index 0000000..7764f06 Binary files /dev/null and b/samples_desktop/units/troll.png differ diff --git a/samples_desktop/units/vulture6.png b/samples_desktop/units/vulture6.png new file mode 100644 index 0000000..bcdd138 Binary files /dev/null and b/samples_desktop/units/vulture6.png differ diff --git a/samples_desktop/units/vulture7.png b/samples_desktop/units/vulture7.png new file mode 100644 index 0000000..b3eeb89 Binary files /dev/null and b/samples_desktop/units/vulture7.png differ diff --git a/samples_desktop/units/warden.png b/samples_desktop/units/warden.png new file mode 100644 index 0000000..ae9b4ce Binary files /dev/null and b/samples_desktop/units/warden.png differ diff --git a/samples_desktop/units/warregal2.png b/samples_desktop/units/warregal2.png new file mode 100644 index 0000000..5292d0c Binary files /dev/null and b/samples_desktop/units/warregal2.png differ diff --git a/samples_desktop/units/whitemane1.png b/samples_desktop/units/whitemane1.png new file mode 100644 index 0000000..2df94f6 Binary files /dev/null and b/samples_desktop/units/whitemane1.png differ diff --git a/samples_desktop/units/wind.png b/samples_desktop/units/wind.png new file mode 100644 index 0000000..85b0828 Binary files /dev/null and b/samples_desktop/units/wind.png differ diff --git a/samples_desktop/units/wyvern.png b/samples_desktop/units/wyvern.png new file mode 100644 index 0000000..cf85fed Binary files /dev/null and b/samples_desktop/units/wyvern.png differ diff --git a/samples_desktop/watchtower.png b/samples_desktop/watchtower.png new file mode 100644 index 0000000..5d6c33f Binary files /dev/null and b/samples_desktop/watchtower.png differ diff --git a/samples_desktop/wu.png b/samples_desktop/wu.png new file mode 100644 index 0000000..e283b87 Binary files /dev/null and b/samples_desktop/wu.png differ diff --git a/samples_desktop/wu_march.png b/samples_desktop/wu_march.png new file mode 100644 index 0000000..855dc62 Binary files /dev/null and b/samples_desktop/wu_march.png differ diff --git a/samples_desktop/zoom_out.png b/samples_desktop/zoom_out.png new file mode 100644 index 0000000..a044506 Binary files /dev/null and b/samples_desktop/zoom_out.png differ diff --git a/screen_backend_windows.py b/screen_backend_windows.py new file mode 100644 index 0000000..51c2b41 --- /dev/null +++ b/screen_backend_windows.py @@ -0,0 +1,479 @@ +import ctypes +import time +from ctypes import wintypes + +import numpy as np +from PIL import ImageGrab + + +def get_virtual_desktop(): + """Return the Windows virtual-desktop rectangle.""" + user32 = ctypes.windll.user32 + + try: + user32.SetProcessDPIAware() + except Exception: + pass + + return { + "left": user32.GetSystemMetrics(76), + "top": user32.GetSystemMetrics(77), + "width": user32.GetSystemMetrics(78), + "height": user32.GetSystemMetrics(79), + } + + +def capture_full_desktop( + attempts=3, + retry_wait=0.20, +): + """ + Capture all Windows monitors as one BGR OpenCV image. + + Pillow can occasionally return a transient partial/stale + virtual-desktop capture after Windows display topology + changes, monitor wake/sleep, or display re-enumeration. + + Validate the captured dimensions against the Windows + virtual-desktop rectangle and retry before exposing a bad + image to monitor detection/cropping. + """ + + attempts = max( + 1, + int(attempts), + ) + + last_error = None + + for attempt in range( + 1, + attempts + 1, + ): + # Read geometry immediately before capture. + before = get_virtual_desktop() + + pil_image = ImageGrab.grab( + all_screens=True + ) + + # Read geometry again afterward. If Windows changed the + # topology during capture, discard this frame and retry. + after = get_virtual_desktop() + + captured_width, captured_height = ( + pil_image.size + ) + + topology_stable = ( + before == after + ) + + dimensions_valid = ( + captured_width == after["width"] + and captured_height == after["height"] + ) + + if ( + topology_stable + and dimensions_valid + ): + if attempt > 1: + print( + f"Windows desktop capture recovered " + f"on attempt {attempt}/{attempts}." + ) + + image = np.array( + pil_image + ) + + # Pillow supplies RGB; the existing OpenCV code + # expects BGR. + return image[ + :, :, ::-1 + ].copy() + + last_error = ( + "Windows desktop capture geometry mismatch.\n" + f"Before: {before}\n" + f"After: {after}\n" + f"Captured: " + f"{captured_width} x {captured_height}" + ) + + if attempt < attempts: + time.sleep( + retry_wait + ) + + raise RuntimeError( + last_error + or "Windows desktop capture failed." + ) + + +def _window_client_rect(hwnd): + """ + Return one HWND's client rectangle in virtual-desktop + coordinates. + + Returns None when the client rectangle cannot be resolved. + """ + + user32 = ctypes.windll.user32 + + class POINT(ctypes.Structure): + _fields_ = [ + ("x", ctypes.c_long), + ("y", ctypes.c_long), + ] + + rect = wintypes.RECT() + + if not user32.GetClientRect( + hwnd, + ctypes.byref(rect), + ): + return None + + top_left = POINT( + rect.left, + rect.top, + ) + + bottom_right = POINT( + rect.right, + rect.bottom, + ) + + if not user32.ClientToScreen( + hwnd, + ctypes.byref(top_left), + ): + return None + + if not user32.ClientToScreen( + hwnd, + ctypes.byref(bottom_right), + ): + return None + + width = ( + bottom_right.x + - top_left.x + ) + + height = ( + bottom_right.y + - top_left.y + ) + + if ( + width <= 0 + or height <= 0 + ): + return None + + return { + "left": int(top_left.x), + "top": int(top_left.y), + "right": int(bottom_right.x), + "bottom": int(bottom_right.y), + "width": int(width), + "height": int(height), + } + + +def _rect_intersection_area( + rect, + monitor, +): + """ + Return visible intersection area between a desktop rectangle + and one monitor. + """ + + left = max( + rect["left"], + monitor["left"], + ) + + top = max( + rect["top"], + monitor["top"], + ) + + right = min( + rect["right"], + ( + monitor["left"] + + monitor["width"] + ), + ) + + bottom = min( + rect["bottom"], + ( + monitor["top"] + + monitor["height"] + ), + ) + + width = max( + 0, + right - left, + ) + + height = max( + 0, + bottom - top, + ) + + return ( + width + * height + ) + + +def find_total_battle_windows( + monitors=None, +): + """ + Find visible Windows top-level windows whose title contains + 'Total Battle'. + + This supports both: + + Total Battle + + and browser-hosted titles such as: + + Total Battle - Brave + + Each result includes the client rectangle and the monitor + containing the largest visible portion of that client area. + + Exact-title standalone windows sort before title-containing + browser windows. + """ + + user32 = ctypes.windll.user32 + + if monitors is None: + monitors = get_monitors() + + candidates = [] + + callback_type = ctypes.WINFUNCTYPE( + ctypes.c_bool, + wintypes.HWND, + wintypes.LPARAM, + ) + + def callback( + hwnd, + lparam, + ): + if not user32.IsWindowVisible( + hwnd + ): + return True + + length = user32.GetWindowTextLengthW( + hwnd + ) + + if length <= 0: + return True + + title_buffer = ( + ctypes.create_unicode_buffer( + length + 1 + ) + ) + + user32.GetWindowTextW( + hwnd, + title_buffer, + length + 1, + ) + + title = ( + title_buffer.value.strip() + ) + + if ( + "total battle" + not in title.casefold() + ): + return True + + client = _window_client_rect( + hwnd + ) + + if client is None: + return True + + # Ignore tiny utility/popup windows. + if ( + client["width"] < 300 + or client["height"] < 200 + ): + return True + + best_monitor = None + best_area = 0 + + for monitor in monitors: + + area = _rect_intersection_area( + client, + monitor, + ) + + if area > best_area: + best_area = area + best_monitor = monitor + + if ( + best_monitor is None + or best_area <= 0 + ): + return True + + exact_title = ( + title.casefold() + == "total battle" + ) + + client_area = ( + client["width"] + * client["height"] + ) + + visible_fraction = ( + best_area + / client_area + if client_area > 0 + else 0.0 + ) + + candidates.append({ + "hwnd": int(hwnd), + "title": title, + "exact_title": exact_title, + + # EnumWindows walks top-level windows in Z order. + # Preserve that order so an obscured standalone + # Total Battle window cannot outrank the actually + # visible browser-hosted game merely because its + # title is exact. + "z_order": len(candidates), + + "client": client, + "monitor": best_monitor, + "intersection_area": best_area, + "visible_fraction": ( + visible_fraction + ), + }) + + return True + + callback_ref = callback_type( + callback + ) + + if not user32.EnumWindows( + callback_ref, + 0, + ): + raise RuntimeError( + "EnumWindows failed." + ) + + # Preserve EnumWindows Z order. + # + # The first matching Total Battle window is the highest + # visible candidate. Do NOT force an exact-title standalone + # client ahead of a browser-hosted Total Battle window: + # the standalone client may be completely obscured behind + # the browser while still reporting IsWindowVisible=True. + # + # Title type remains metadata for later verification. + + return candidates + + +def find_total_battle_window( + monitors=None, +): + """ + Return the best visible Total Battle titled window, or None. + """ + + candidates = find_total_battle_windows( + monitors=monitors, + ) + + if not candidates: + return None + + return candidates[0] + + +def get_monitors(): + """Enumerate monitors and provide desktop and capture coordinates.""" + user32 = ctypes.windll.user32 + + try: + user32.SetProcessDPIAware() + except Exception: + pass + + virtual = get_virtual_desktop() + monitors = [] + + callback_type = ctypes.WINFUNCTYPE( + ctypes.c_int, + wintypes.HMONITOR, + wintypes.HDC, + ctypes.POINTER(wintypes.RECT), + wintypes.LPARAM, + ) + + def callback(hmonitor, hdc, rect_ptr, data): + rect = rect_ptr.contents + + left = rect.left + top = rect.top + width = rect.right - rect.left + height = rect.bottom - rect.top + + monitors.append({ + "output": f"DISPLAY{len(monitors) + 1}", + "left": left, + "top": top, + "width": width, + "height": height, + "capture_left": left - virtual["left"], + "capture_top": top - virtual["top"], + }) + + return 1 + + callback_ref = callback_type(callback) + + if not user32.EnumDisplayMonitors(0, 0, callback_ref, 0): + raise RuntimeError("EnumDisplayMonitors failed.") + + for index, monitor in enumerate( + monitors, + start=1, + ): + monitor["index"] = index + + return monitors + diff --git a/scroll_control.py b/scroll_control.py new file mode 100644 index 0000000..8e0e492 --- /dev/null +++ b/scroll_control.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +import os +import time + + +# COSMIC / XWayland does not provide ~/.Xauthority. +# This MUST be established before importing pyautogui. +os.environ.setdefault( + "XAUTHORITY", + "/dev/null", +) + +import pyautogui + +pyautogui.PAUSE = 0 + + +def production_scroll( + a, + amount=-4, + anchor=None, + focus_wait=0.15, + settle_wait=0.15, +): + """ + Scroll a Total Battle list using the production-proven + PyAutoGUI path. + + The InputController pointer cache is invalidated before and + after the scroll because PyAutoGUI and InputController use + different input paths. + + `anchor` is monitor-relative. If omitted, the Battle troop + list anchor is used. + + Returns the anchor used. + """ + + # Never trust the stored InputController pointer position + # after another input backend has been involved. + a.input.current_position = None + a.input.current_monitor = None + + if anchor is None: + anchor = ( + a.get_battle_scroll_anchor() + ) + + a.loc = anchor + + a.debug( + f"Moving to scroll anchor: " + f"{a.loc}" + ) + + a.move() + + # Windows ignores wheel input when Total Battle is not the + # foreground window. Bring it forward immediately before + # assigning scroll focus. Linux has no user32 attribute, so + # its existing behavior remains unchanged. + if hasattr(a.input, "user32"): + hwnd = a.input.find_game_window() + + if hwnd: + a.input.user32.SetForegroundWindow( + hwnd + ) + + # Give Total Battle time to assign scroll focus to the + # panel beneath the pointer. + time.sleep( + focus_wait + ) + + # Total Battle may drop rapid wheel input on Windows. + # Send one wheel notch at a time with a short delay so the + # game processes the full requested scroll distance. + scroll_direction = ( + 1 + if amount > 0 + else -1 + ) + + for _ in range( + abs(int(round(amount))) + ): + pyautogui.scroll( + scroll_direction + ) + + time.sleep( + 0.04 + ) + + # PyAutoGUI may leave InputController's internal pointer + # tracking inconsistent with the compositor. + a.input.current_position = None + a.input.current_monitor = None + + time.sleep( + settle_wait + ) + + return anchor diff --git a/show_ui_regions.py b/show_ui_regions.py new file mode 100755 index 0000000..4d50a2a --- /dev/null +++ b/show_ui_regions.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import json + +import cv2 + +from game_monitor import ( + detect_game_monitor, + capture_game_monitor, +) + + +# ============================================================ +# LOAD CONFIG +# ============================================================ + +config = json.loads( + Path( + "automation_config.json" + ).read_text() +) + +regions = config[ + "ui_regions" +] + + +# ============================================================ +# DETECT + CAPTURE GAME MONITOR +# ============================================================ + +print() +print("Detecting game monitor...") + +detection = detect_game_monitor() + +print() +print("detect_game_monitor() returned:") +print(detection) +print() + +monitor = detection[ + "monitor" +] + +print( + f"Using monitor: " + f"{monitor['output']} " + f"({monitor['left']}, {monitor['top']}) " + f"{monitor['width']} x {monitor['height']}" +) + +image = capture_game_monitor( + monitor +) + +height, width = image.shape[:2] + +print( + f"Captured game-monitor image: " + f"{width} x {height}" +) + + +# ============================================================ +# DRAW CONFIGURED REGIONS +# ============================================================ + +# OpenCV uses BGR. +region_colors = { + "top_center": ( + 255, + 100, + 100, + ), + "middle_center": ( + 100, + 255, + 100, + ), + "bottom_center": ( + 100, + 100, + 255, + ), +} + + +for name, settings in regions.items(): + + region_width = int( + width + * settings[ + "width_ratio" + ] + ) + + region_height = int( + height + * settings[ + "height_ratio" + ] + ) + + center_x = int( + width + * settings[ + "center_x_ratio" + ] + ) + + center_y = int( + height + * settings[ + "center_y_ratio" + ] + ) + + x1 = int( + center_x + - region_width / 2 + ) + + y1 = int( + center_y + - region_height / 2 + ) + + x2 = ( + x1 + + region_width + ) + + y2 = ( + y1 + + region_height + ) + + # Clamp to captured image. + x1 = max( + 0, + x1, + ) + + y1 = max( + 0, + y1, + ) + + x2 = min( + width - 1, + x2, + ) + + y2 = min( + height - 1, + y2, + ) + + color = region_colors.get( + name, + ( + 255, + 255, + 255, + ), + ) + + cv2.rectangle( + image, + ( + x1, + y1, + ), + ( + x2, + y2, + ), + color, + 5, + ) + + cv2.drawMarker( + image, + ( + center_x, + center_y, + ), + color, + cv2.MARKER_CROSS, + 40, + 4, + ) + + label = ( + f"{name.upper()} " + f"{x2 - x1}x" + f"{y2 - y1}" + ) + + label_y = max( + 40, + y1 + 45, + ) + + cv2.putText( + image, + label, + ( + x1 + 15, + label_y, + ), + cv2.FONT_HERSHEY_SIMPLEX, + 1.1, + color, + 3, + cv2.LINE_AA, + ) + + print( + f"{name:15s} " + f"x={x1:4d} " + f"y={y1:4d} " + f"w={x2 - x1:4d} " + f"h={y2 - y1:4d}" + ) + + +# ============================================================ +# SAVE +# ============================================================ + +output = Path( + "ui_regions_preview.png" +) + +if not cv2.imwrite( + str(output), + image, +): + raise RuntimeError( + "Could not save preview image." + ) + +print() +print( + f"Saved: {output.resolve()}" +) diff --git a/total_battle_helper.py b/total_battle_helper.py new file mode 100644 index 0000000..91042fc --- /dev/null +++ b/total_battle_helper.py @@ -0,0 +1,1778 @@ +#!/usr/bin/env python3 + +import queue +import signal +import subprocess +import sys +import threading +import tkinter as tk +from pathlib import Path +from tkinter import messagebox +from tkinter import ttk + + +APP_NAME = "TotalBattleHelper" +DISPLAY_NAME = "Total Battle Helper" + +FROZEN = bool( + getattr( + sys, + "frozen", + False, + ) +) + +if FROZEN: + # The packaged EXE is installed in the project root. + PROJECT_DIR = Path( + sys.executable + ).resolve().parent + + if sys.platform.startswith( + "win" + ): + PYTHON_EXECUTABLE = ( + PROJECT_DIR + / ".venv" + / "Scripts" + / "python.exe" + ) + else: + PYTHON_EXECUTABLE = ( + PROJECT_DIR + / ".venv" + / "bin" + / "python" + ) + +else: + PROJECT_DIR = Path( + __file__ + ).resolve().parent + + PYTHON_EXECUTABLE = Path( + sys.executable + ) + + +class TotalBattleHelper: + def __init__(self, root): + self.root = root + + self.root.title(DISPLAY_NAME) + self.root.geometry("900x960") + self.root.minsize(760, 760) + + self.process = None + self.output_queue = queue.Queue() + + self.stop_requested = False + self.close_after_stop = False + + self.status_var = tk.StringVar( + value="Ready" + ) + + self.count_var = tk.StringVar( + value="" + ) + + self.loss_var = tk.StringVar( + value="Use Citadel Setting" + ) + + self.speedup_var = tk.BooleanVar( + value=True + ) + + self.verbose_var = tk.BooleanVar( + value=False + ) + + self.crypt_count_var = tk.StringVar( + value="" + ) + + self.crypt_type_var = tk.StringVar( + value="Leave As Is" + ) + + self.crypt_speedup_var = tk.BooleanVar( + value=True + ) + + self.crypt_verbose_var = tk.BooleanVar( + value=False + ) + + self.exchange_sound_var = tk.BooleanVar( + value=True + ) + + self.exchange_save_screenshot_var = tk.BooleanVar( + value=True + ) + + self.exchange_open_screenshot_var = tk.BooleanVar( + value=False + ) + + self.exchange_screenshot_status_var = tk.StringVar( + value="No Exchange screenshot captured yet." + ) + + self.current_view = None + + self.build_ui() + + self.root.protocol( + "WM_DELETE_WINDOW", + self.close_window, + ) + + self.root.after( + 100, + self.process_output_queue, + ) + + def build_ui(self): + self.main = ttk.Frame( + self.root, + padding=24, + ) + self.main.pack( + fill="both", + expand=True, + ) + + title = ttk.Label( + self.main, + text=DISPLAY_NAME, + font=( + "TkDefaultFont", + 22, + "bold", + ), + ) + title.pack( + pady=(0, 20), + ) + + navigation = ttk.Frame( + self.main + ) + navigation.pack( + fill="x", + pady=(0, 18), + ) + + self.citadel_nav_button = ttk.Button( + navigation, + text="Citadels", + command=self.show_citadels, + ) + self.citadel_nav_button.pack( + side="left", + fill="x", + expand=True, + padx=(0, 6), + ipady=8, + ) + + self.crypt_nav_button = ttk.Button( + navigation, + text="Crypts", + command=self.show_crypts, + ) + self.crypt_nav_button.pack( + side="left", + fill="x", + expand=True, + padx=6, + ipady=8, + ) + + self.stack_nav_button = ttk.Button( + navigation, + text="Citadel Stacks", + command=self.open_citadel_stacks, + ) + self.stack_nav_button.pack( + side="left", + fill="x", + expand=True, + padx=6, + ipady=8, + ) + + self.troop_nav_button = ttk.Button( + navigation, + text="Troop Catalog", + command=self.open_troop_catalog, + ) + self.troop_nav_button.pack( + side="left", + fill="x", + expand=True, + padx=(6, 0), + ipady=8, + ) + + developer_tools = ttk.LabelFrame( + self.main, + text="Under Development", + padding=10, + ) + developer_tools.pack( + fill="x", + pady=(0, 14), + ) + + self.watch_exchange_button = ttk.Button( + developer_tools, + text="Merc Exchange", + command=self.show_exchange, + ) + self.watch_exchange_button.pack( + side="left", + ) + + self.content = ttk.Frame( + self.main + ) + self.content.pack( + fill="x", + pady=(0, 18), + ) + + self.citadel_frame = self.build_citadel_panel() + self.crypt_frame = self.build_crypt_panel() + self.exchange_frame = self.build_exchange_panel() + + activity_header = ttk.Frame( + self.main + ) + activity_header.pack( + fill="x", + pady=(0, 6), + ) + + ttk.Label( + activity_header, + text="Activity", + font=( + "TkDefaultFont", + 10, + "bold", + ), + ).pack( + side="left", + ) + + ttk.Button( + activity_header, + text="Clear Activity", + command=self.clear_log, + ).pack( + side="right", + ) + + activity_box = ttk.Frame( + self.main, + padding=12, + ) + activity_box.pack( + fill="both", + expand=True, + ) + + self.log_text = tk.Text( + activity_box, + wrap="word", + height=14, + state="disabled", + ) + self.log_text.pack( + side="left", + fill="both", + expand=True, + ) + + scrollbar = ttk.Scrollbar( + activity_box, + orient="vertical", + command=self.log_text.yview, + ) + scrollbar.pack( + side="right", + fill="y", + ) + + self.log_text.configure( + yscrollcommand=scrollbar.set + ) + + self.activity_normal_background = ( + self.log_text.cget( + "background" + ) + ) + + status_frame = ttk.Frame( + self.main + ) + status_frame.pack( + fill="x", + pady=(12, 0), + ) + + ttk.Label( + status_frame, + text="Status:", + ).pack( + side="left", + ) + + ttk.Label( + status_frame, + textvariable=self.status_var, + ).pack( + side="left", + padx=(6, 0), + ) + + self.show_citadels() + + def show_view( + self, + name, + ): + self.current_view = name + + self.citadel_frame.pack_forget() + self.crypt_frame.pack_forget() + self.exchange_frame.pack_forget() + + if name == "citadels": + self.citadel_frame.pack( + fill="x", + ) + + elif name == "crypts": + self.crypt_frame.pack( + fill="x", + ) + + elif name == "exchange": + self.exchange_frame.pack( + fill="x", + ) + + self.update_navigation_state() + self.update_automation_buttons() + + def update_navigation_state(self): + buttons = { + "citadels": self.citadel_nav_button, + "crypts": self.crypt_nav_button, + } + + for name, button in buttons.items(): + if name == self.current_view: + button.state( + ["disabled"] + ) + else: + button.state( + ["!disabled"] + ) + + def show_citadels(self): + self.show_view( + "citadels" + ) + + def show_crypts(self): + self.show_view( + "crypts" + ) + + def show_exchange(self): + self.show_view( + "exchange" + ) + + def build_citadel_panel(self): + box = ttk.LabelFrame( + self.content, + text="Citadel Automation", + padding=18, + ) + + settings = ttk.Frame( + box + ) + settings.pack( + fill="x", + ) + + ttk.Label( + settings, + text="Successful launches:", + ).grid( + row=0, + column=0, + sticky="w", + padx=(0, 12), + pady=6, + ) + + count_entry = ttk.Entry( + settings, + textvariable=self.count_var, + width=12, + ) + count_entry.grid( + row=0, + column=1, + sticky="w", + pady=6, + ) + + ttk.Label( + settings, + text="Leave blank to run continuously", + ).grid( + row=0, + column=2, + sticky="w", + padx=(12, 0), + pady=6, + ) + + ttk.Label( + settings, + text="Loss handling:", + ).grid( + row=1, + column=0, + sticky="w", + padx=(0, 12), + pady=6, + ) + + loss_combo = ttk.Combobox( + settings, + textvariable=self.loss_var, + state="readonly", + values=( + "Use Citadel Setting", + "Stop", + "Revive", + "Delete All", + "Delete Silver", + ), + width=22, + ) + loss_combo.grid( + row=1, + column=1, + columnspan=2, + sticky="w", + pady=6, + ) + + ttk.Label( + settings, + text=( + "Delete All permanently removes all losses. " + "Delete Silver removes Silver Troops and " + "revives protected losses." + ), + wraplength=650, + justify="left", + ).grid( + row=2, + column=0, + columnspan=3, + sticky="w", + pady=(2, 8), + ) + + speedup_check = ttk.Checkbutton( + settings, + text="Apply MAX march speedup", + variable=self.speedup_var, + ) + speedup_check.grid( + row=3, + column=0, + columnspan=3, + sticky="w", + pady=6, + ) + + verbose_check = ttk.Checkbutton( + settings, + text="Verbose diagnostic output", + variable=self.verbose_var, + ) + verbose_check.grid( + row=4, + column=0, + columnspan=3, + sticky="w", + pady=6, + ) + + buttons = ttk.Frame( + box + ) + buttons.pack( + fill="x", + pady=(16, 0), + ) + + self.citadel_start_button = ttk.Button( + buttons, + text="Start Citadels", + command=self.start_citadels, + ) + self.citadel_start_button.pack( + side="left", + fill="x", + expand=True, + padx=(0, 8), + ipady=10, + ) + + self.citadel_stop_button = ttk.Button( + buttons, + text="Stop", + command=self.stop_automation, + ) + self.citadel_stop_button.pack( + side="left", + fill="x", + expand=True, + padx=(8, 0), + ipady=10, + ) + + self.citadel_setting_widgets = [ + count_entry, + loss_combo, + speedup_check, + verbose_check, + ] + + return box + + def build_crypt_panel(self): + box = ttk.LabelFrame( + self.content, + text="Crypt Automation", + padding=18, + ) + + settings = ttk.Frame( + box + ) + settings.pack( + fill="x", + ) + + ttk.Label( + settings, + text="Successful launches:", + ).grid( + row=0, + column=0, + sticky="w", + padx=(0, 12), + pady=6, + ) + + count_entry = ttk.Entry( + settings, + textvariable=self.crypt_count_var, + width=12, + ) + count_entry.grid( + row=0, + column=1, + sticky="w", + pady=6, + ) + + ttk.Label( + settings, + text="Leave blank to run continuously", + ).grid( + row=0, + column=2, + sticky="w", + padx=(12, 0), + pady=6, + ) + + ttk.Label( + settings, + text="Crypt type:", + ).grid( + row=1, + column=0, + sticky="w", + padx=(0, 12), + pady=6, + ) + + crypt_type_combo = ttk.Combobox( + settings, + textvariable=self.crypt_type_var, + state="readonly", + values=( + "Leave As Is", + "Common", + "Rare", + "Epic", + ), + width=22, + ) + crypt_type_combo.grid( + row=1, + column=1, + columnspan=2, + sticky="w", + pady=6, + ) + + ttk.Label( + settings, + text=( + "Leave As Is keeps the current Watchtower " + "Crypt filters. Choosing Common, Rare, or Epic " + "sets the Watchtower to that Crypt type." + ), + wraplength=650, + justify="left", + ).grid( + row=2, + column=0, + columnspan=3, + sticky="w", + pady=(2, 8), + ) + + speedup_check = ttk.Checkbutton( + settings, + text="Apply MAX march speedup", + variable=self.crypt_speedup_var, + ) + speedup_check.grid( + row=3, + column=0, + columnspan=3, + sticky="w", + pady=6, + ) + + verbose_check = ttk.Checkbutton( + settings, + text="Verbose diagnostic output", + variable=self.crypt_verbose_var, + ) + verbose_check.grid( + row=4, + column=0, + columnspan=3, + sticky="w", + pady=6, + ) + + buttons = ttk.Frame( + box + ) + buttons.pack( + fill="x", + pady=(16, 0), + ) + + self.crypt_start_button = ttk.Button( + buttons, + text="Start Crypts", + command=self.start_crypts, + ) + self.crypt_start_button.pack( + side="left", + fill="x", + expand=True, + padx=(0, 8), + ipady=10, + ) + + self.crypt_stop_button = ttk.Button( + buttons, + text="Stop", + command=self.stop_automation, + ) + self.crypt_stop_button.pack( + side="left", + fill="x", + expand=True, + padx=(8, 0), + ipady=10, + ) + + self.crypt_setting_widgets = [ + count_entry, + crypt_type_combo, + speedup_check, + verbose_check, + ] + + return box + + def build_exchange_panel(self): + box = ttk.LabelFrame( + self.content, + text="Merc Exchange", + padding=18, + ) + + settings = ttk.Frame( + box + ) + settings.pack( + fill="x", + ) + + sound_check = ttk.Checkbutton( + settings, + text="Play sound when Exchange is found", + variable=self.exchange_sound_var, + ) + sound_check.grid( + row=0, + column=0, + columnspan=3, + sticky="w", + pady=6, + ) + + save_check = ttk.Checkbutton( + settings, + text="Save Exchange screenshot", + variable=self.exchange_save_screenshot_var, + ) + save_check.grid( + row=1, + column=0, + columnspan=3, + sticky="w", + pady=6, + ) + + open_check = ttk.Checkbutton( + settings, + text="Automatically open screenshot", + variable=self.exchange_open_screenshot_var, + ) + open_check.grid( + row=2, + column=0, + columnspan=3, + sticky="w", + pady=6, + ) + + screenshot_box = ttk.LabelFrame( + box, + text="Last Exchange Screenshot", + padding=12, + ) + screenshot_box.pack( + fill="x", + pady=(16, 0), + ) + + self.exchange_open_last_button = ttk.Button( + screenshot_box, + text="Open Last Screenshot", + command=self.open_last_exchange_screenshot, + ) + self.exchange_open_last_button.pack( + side="left", + padx=(0, 16), + ) + screenshot_path = ( + PROJECT_DIR + / "exchange_found.png" + ) + + if screenshot_path.is_file(): + self.exchange_screenshot_status_var.set( + screenshot_path.name + ) + self.exchange_open_last_button.configure( + state="normal" + ) + else: + self.exchange_open_last_button.configure( + state="disabled" + ) + + ttk.Label( + screenshot_box, + textvariable=self.exchange_screenshot_status_var, + ).pack( + side="left", + ) + + buttons = ttk.Frame( + box + ) + buttons.pack( + fill="x", + pady=(16, 0), + ) + + self.exchange_start_button = ttk.Button( + buttons, + text="Start Watching", + command=self.open_watch_exchange, + ) + self.exchange_start_button.pack( + side="left", + fill="x", + expand=True, + padx=(0, 8), + ipady=10, + ) + + self.exchange_stop_button = ttk.Button( + buttons, + text="Stop", + command=self.stop_automation, + ) + self.exchange_stop_button.pack( + side="left", + fill="x", + expand=True, + padx=(8, 0), + ipady=10, + ) + + self.exchange_setting_widgets = [ + sound_check, + save_check, + open_check, + ] + + return box + + def open_last_exchange_screenshot(self): + screenshot_path = ( + PROJECT_DIR + / "exchange_found.png" + ) + + if not screenshot_path.is_file(): + self.exchange_screenshot_status_var.set( + "No Exchange screenshot captured yet." + ) + self.exchange_open_last_button.configure( + state="disabled" + ) + return + + try: + if sys.platform == "win32": + import os + os.startfile( + str(screenshot_path) + ) + elif sys.platform == "darwin": + subprocess.Popen( + [ + "open", + str(screenshot_path), + ] + ) + else: + subprocess.Popen( + [ + "xdg-open", + str(screenshot_path), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + except Exception as exc: + messagebox.showerror( + DISPLAY_NAME, + ( + "Could not open Exchange screenshot:" + f"\n\n{exc}" + ), + ) + + def launch_tool( + self, + filename, + display_name, + ): + script_path = ( + PROJECT_DIR / filename + ) + + if not script_path.is_file(): + messagebox.showerror( + APP_NAME, + ( + f"Could not find " + f"{script_path}" + ), + ) + return + + try: + subprocess.Popen( + [ + str(PYTHON_EXECUTABLE), + str(script_path), + ], + cwd=str(PROJECT_DIR), + ) + + except Exception as exc: + messagebox.showerror( + APP_NAME, + ( + f"Could not open " + f"{display_name}:\n\n" + f"{exc}" + ), + ) + return + + self.status_var.set( + f"Opened {display_name}" + ) + + def open_citadel_stacks(self): + self.launch_tool( + "citadel_stack_gui.py", + "Citadel Stacks", + ) + + def open_troop_catalog(self): + self.launch_tool( + "troop_catalog_gui.py", + "Troop Catalog", + ) + + def open_watch_exchange(self): + if self.automation_is_running(): + self.status_var.set( + "Automation is already running" + ) + return + + script_path = ( + PROJECT_DIR + / "dev_watch_exchange.py" + ) + + if not script_path.is_file(): + messagebox.showerror( + APP_NAME, + ( + "Could not find " + f"{script_path}" + ), + ) + return + + command = [ + str(PYTHON_EXECUTABLE), + "-u", + str(script_path), + ] + + if not self.exchange_sound_var.get(): + command.append( + "--no-sound" + ) + + if not self.exchange_save_screenshot_var.get(): + command.append( + "--no-save-screenshot" + ) + + if self.exchange_open_screenshot_var.get(): + command.append( + "--open-screenshot" + ) + + self.stop_requested = False + self.close_after_stop = False + + self.clear_log() + + self.append_log( + "Starting Exchange watcher...\n\n" + ) + + self.append_log( + "Command: " + + " ".join(command) + + "\n\n" + ) + + try: + self.process = subprocess.Popen( + command, + cwd=str(PROJECT_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + except Exception as exc: + self.process = None + + messagebox.showerror( + APP_NAME, + ( + "Could not start Exchange " + f"watcher:\n\n{exc}" + ), + ) + return + + self.status_var.set( + "Exchange watcher running" + ) + + self.update_automation_buttons() + + reader = threading.Thread( + target=self.read_process_output, + args=(self.process,), + daemon=True, + ) + reader.start() + + def append_log( + self, + text, + ): + self.log_text.configure( + state="normal" + ) + + self.log_text.insert( + "end", + text, + ) + + self.log_text.see( + "end" + ) + + self.log_text.configure( + state="disabled" + ) + + def clear_log(self): + self.log_text.configure( + state="normal" + ) + + self.log_text.delete( + "1.0", + "end", + ) + + self.log_text.configure( + state="disabled" + ) + + def set_exchange_alert_active( + self, + active, + ): + if active: + background = "#d9f2d9" + else: + background = ( + self.activity_normal_background + ) + + self.log_text.configure( + background=background + ) + + def build_citadel_command(self): + command = [ + str(PYTHON_EXECUTABLE), + "-u", + str( + PROJECT_DIR + / "citadel.py" + ), + ] + + count_text = ( + self.count_var.get().strip() + ) + + if count_text: + try: + count = int( + count_text + ) + except ValueError: + raise ValueError( + "Successful launches must be a whole number." + ) + + if count < 1: + raise ValueError( + "Successful launches must be at least 1." + ) + + command.extend( + [ + "--count", + str(count), + ] + ) + + loss_value = ( + self.loss_var.get() + ) + + loss_map = { + "Use Citadel Setting": None, + "Stop": "stop", + "Revive": "revive", + "Delete All": "delete", + "Delete Silver": "silver", + } + + loss_option = loss_map[ + loss_value + ] + + if loss_option is not None: + command.extend( + [ + "--loss", + loss_option, + ] + ) + + if not self.speedup_var.get(): + command.append( + "--no-speedup" + ) + + if self.verbose_var.get(): + command.append( + "--verbose" + ) + + return command + + def build_crypt_command(self): + command = [ + str(PYTHON_EXECUTABLE), + "-u", + str( + PROJECT_DIR + / "crypt.py" + ), + ] + + count_text = ( + self.crypt_count_var.get().strip() + ) + + if count_text: + try: + count = int( + count_text + ) + except ValueError: + raise ValueError( + "Successful launches must be a whole number." + ) + + if count < 1: + raise ValueError( + "Successful launches must be at least 1." + ) + + command.extend( + [ + "--count", + str(count), + ] + ) + + crypt_type = ( + self.crypt_type_var.get() + ) + + type_map = { + "Leave As Is": None, + "Common": "common", + "Rare": "rare", + "Epic": "epic", + } + + if crypt_type not in type_map: + raise ValueError( + "Invalid Crypt type selection." + ) + + type_option = type_map[ + crypt_type + ] + + if type_option is not None: + command.extend( + [ + "--type", + type_option, + ] + ) + + if not self.crypt_speedup_var.get(): + command.append( + "--no-speedup" + ) + + if self.crypt_verbose_var.get(): + command.append( + "--verbose" + ) + + return command + + def automation_is_running(self): + return ( + self.process is not None + and self.process.poll() is None + ) + + def update_automation_buttons(self): + running = ( + self.automation_is_running() + ) + + if hasattr( + self, + "citadel_start_button", + ): + if running: + self.citadel_start_button.state( + ["disabled"] + ) + else: + self.citadel_start_button.state( + ["!disabled"] + ) + + if hasattr( + self, + "crypt_start_button", + ): + if running: + self.crypt_start_button.state( + ["disabled"] + ) + else: + self.crypt_start_button.state( + ["!disabled"] + ) + + if hasattr( + self, + "exchange_start_button", + ): + if running: + self.exchange_start_button.state( + ["disabled"] + ) + else: + self.exchange_start_button.state( + ["!disabled"] + ) + + for button_name in ( + "citadel_stop_button", + "crypt_stop_button", + "exchange_stop_button", + ): + button = getattr( + self, + button_name, + None, + ) + + if button is None: + continue + + if running: + button.state( + ["!disabled"] + ) + else: + button.state( + ["disabled"] + ) + + for widget_name in ( + "citadel_setting_widgets", + "crypt_setting_widgets", + "exchange_setting_widgets", + ): + widgets = getattr( + self, + widget_name, + [], + ) + + for widget in widgets: + if running: + widget.state( + ["disabled"] + ) + else: + widget.state( + ["!disabled"] + ) + + self.update_navigation_state() + + def start_citadels(self): + if self.automation_is_running(): + return + + try: + command = ( + self.build_citadel_command() + ) + + except ValueError as exc: + messagebox.showerror( + APP_NAME, + str(exc), + ) + return + + self.stop_requested = False + self.close_after_stop = False + + self.clear_log() + + self.append_log( + "Starting Citadel automation...\n\n" + ) + + self.append_log( + "Command: " + + " ".join(command) + + "\n\n" + ) + + try: + self.process = subprocess.Popen( + command, + cwd=str(PROJECT_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + except Exception as exc: + self.process = None + + messagebox.showerror( + APP_NAME, + ( + "Could not start Citadel " + f"automation:\n\n{exc}" + ), + ) + return + + self.status_var.set( + "Citadel automation running" + ) + + self.update_automation_buttons() + + reader = threading.Thread( + target=self.read_process_output, + args=(self.process,), + daemon=True, + ) + reader.start() + + def start_crypts(self): + if self.automation_is_running(): + return + + try: + command = ( + self.build_crypt_command() + ) + + except ValueError as exc: + messagebox.showerror( + APP_NAME, + str(exc), + ) + return + + self.stop_requested = False + self.close_after_stop = False + + self.clear_log() + + self.append_log( + "Starting Crypt automation...\n\n" + ) + + self.append_log( + "Command: " + + " ".join(command) + + "\n\n" + ) + + try: + self.process = subprocess.Popen( + command, + cwd=str(PROJECT_DIR), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + + except Exception as exc: + self.process = None + + messagebox.showerror( + APP_NAME, + ( + "Could not start Crypt " + f"automation:\n\n{exc}" + ), + ) + return + + self.status_var.set( + "Crypt automation running" + ) + + self.update_automation_buttons() + + reader = threading.Thread( + target=self.read_process_output, + args=(self.process,), + daemon=True, + ) + reader.start() + + def read_process_output( + self, + process, + ): + try: + if process.stdout is not None: + for line in process.stdout: + self.output_queue.put( + ( + "output", + line, + ) + ) + + return_code = ( + process.wait() + ) + + self.output_queue.put( + ( + "finished", + return_code, + ) + ) + + except Exception as exc: + self.output_queue.put( + ( + "error", + str(exc), + ) + ) + + def process_output_queue(self): + try: + while True: + message_type, value = ( + self.output_queue.get_nowait() + ) + + if message_type == "output": + self.append_log( + value + ) + + if value.startswith( + "Exchange found:" + ): + self.set_exchange_alert_active( + True + ) + + self.status_var.set( + "Merc Exchange found" + ) + + elif value.startswith( + "Exchange disappeared;" + ): + self.set_exchange_alert_active( + False + ) + + self.status_var.set( + "Exchange watcher running" + ) + + screenshot_prefix = ( + "Exchange screenshot saved: " + ) + + if value.startswith( + screenshot_prefix + ): + screenshot_path = Path( + value[ + len(screenshot_prefix): + ].strip() + ) + + if screenshot_path.is_file(): + self.exchange_screenshot_status_var.set( + screenshot_path.name + ) + + self.exchange_open_last_button.configure( + state="normal" + ) + + elif message_type == "finished": + self.process = None + + self.set_exchange_alert_active( + False + ) + + if self.stop_requested: + self.append_log( + "\nAutomation stopped.\n" + ) + + self.status_var.set( + "Stopped" + ) + + elif value == 0: + self.append_log( + "\nAutomation finished successfully.\n" + ) + + self.status_var.set( + "Ready" + ) + + else: + self.append_log( + "\nAutomation process finished " + f"with exit code {value}.\n" + ) + + self.status_var.set( + f"Exited with code {value}" + ) + + self.update_automation_buttons() + + if self.close_after_stop: + self.root.destroy() + return + + self.stop_requested = False + + elif message_type == "error": + self.append_log( + "\nOutput reader error: " + f"{value}\n" + ) + + self.status_var.set( + "Error" + ) + + except queue.Empty: + pass + + self.root.after( + 100, + self.process_output_queue, + ) + + def request_automation_stop(self): + """ + Request a graceful stop of the active automation. + + On POSIX systems, SIGINT behaves like Ctrl+C. This lets + citadel.py / crypt.py unwind normally and execute their + finally blocks, including a.input.close(). + + The Windows fallback remains terminate() for now. Proper + Windows console-control handling can be added when the + packaged Windows launcher is implemented. + """ + + if ( + self.process is None + or self.process.poll() is not None + ): + return False + + if sys.platform == "win32": + self.process.terminate() + else: + self.process.send_signal( + signal.SIGINT + ) + + return True + + def stop_automation(self): + if not self.automation_is_running(): + self.status_var.set( + "No automation is running" + ) + return + + self.stop_requested = True + + self.status_var.set( + "Stopping automation..." + ) + + self.append_log( + "\nStop requested by user. " + "Waiting for automation cleanup...\n" + ) + + try: + self.request_automation_stop() + + except Exception as exc: + messagebox.showerror( + APP_NAME, + ( + "Could not stop automation:" + f"\n\n{exc}" + ), + ) + + def close_window(self): + if ( + self.process is not None + and self.process.poll() is None + ): + answer = messagebox.askyesno( + APP_NAME, + ( + "Automation is currently running.\n\n" + "Stop it and close TotalBattleHelper?" + ), + ) + + if not answer: + return + + try: + self.stop_requested = True + self.close_after_stop = True + + self.status_var.set( + "Stopping automation..." + ) + + self.append_log( + "\nClose requested. Waiting for " + "automation cleanup...\n" + ) + + if self.request_automation_stop(): + # Do not destroy the GUI yet. The output + # reader will report process completion, and + # process_output_queue() will close the GUI + # only after the child has actually exited. + return + + except Exception as exc: + messagebox.showerror( + APP_NAME, + ( + "Could not stop automation " + f"before closing:\n\n{exc}" + ), + ) + + self.close_after_stop = False + self.stop_requested = False + return + + self.root.destroy() + + +def main(): + root = tk.Tk(className="TotalBattleHelper") + + try: + TotalBattleHelper( + root + ) + + except Exception as exc: + root.withdraw() + + messagebox.showerror( + APP_NAME, + str(exc), + ) + + root.destroy() + raise + + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/troop_catalog.json b/troop_catalog.json new file mode 100644 index 0000000..e0840ed --- /dev/null +++ b/troop_catalog.json @@ -0,0 +1,446 @@ +{ + "wyvern": { + "enabled": true, + "display_name": "Wyvern", + "group": "Mercenary", + "order": 10 + }, + "warden": { + "enabled": true, + "display_name": "Warden", + "group": "Mercenary", + "order": 20 + }, + "cannoneer": { + "enabled": true, + "display_name": "Cannoneer", + "group": "Mercenary", + "order": 30 + }, + "salamander": { + "enabled": true, + "display_name": "Salamander", + "group": "Mercenary", + "order": 40 + }, + "jago2": { + "enabled": true, + "display_name": "Jago 2", + "group": "Mercenary", + "order": 50 + }, + "warregal2": { + "enabled": true, + "display_name": "Warregal 2", + "group": "Mercenary", + "order": 60 + }, + "superior-hunter2": { + "enabled": true, + "display_name": "Superior Hunter 2", + "group": "Mercenary", + "order": 70 + }, + "ariel2": { + "enabled": true, + "display_name": "Ariel 2", + "group": "Mercenary", + "order": 80 + }, + "galloper2": { + "enabled": true, + "display_name": "Galloper 2", + "group": "Mercenary", + "order": 90 + }, + "quicksand2": { + "enabled": true, + "display_name": "Quicksand 2", + "group": "Mercenary", + "order": 100 + }, + "scarface2": { + "enabled": true, + "display_name": "Scarface 2", + "group": "Mercenary", + "order": 110 + }, + "pounder2": { + "enabled": true, + "display_name": "Pounder 2", + "group": "Mercenary", + "order": 120 + }, + "slavic2": { + "enabled": true, + "display_name": "Slavic 2", + "group": "Mercenary", + "order": 130 + }, + "highlander2": { + "enabled": true, + "display_name": "Highlander 2", + "group": "Mercenary", + "order": 140 + }, + "kraken2": { + "enabled": true, + "display_name": "Kraken 2", + "group": "Monster", + "order": 150 + }, + "fire-phoenix2": { + "enabled": true, + "display_name": "Fire Phoenix 2", + "group": "Monster", + "order": 160 + }, + "devastator2": { + "enabled": true, + "display_name": "Devastator 2", + "group": "Monster", + "order": 170 + }, + "trickster2": { + "enabled": true, + "display_name": "Trickster 2", + "group": "Monster", + "order": 180 + }, + "kraken1": { + "enabled": true, + "display_name": "Kraken 1", + "group": "Monster", + "order": 190 + }, + "fire-phoenix1": { + "enabled": true, + "display_name": "Fire Phoenix 1", + "group": "Monster", + "order": 200 + }, + "devastator1": { + "enabled": true, + "display_name": "Devastator 1", + "group": "Monster", + "order": 210 + }, + "trickster1": { + "enabled": true, + "display_name": "Trickster 1", + "group": "Monster", + "order": 220 + }, + "colossus": { + "enabled": false, + "display_name": "Colossus", + "group": "Monster", + "order": 230 + }, + "wind": { + "enabled": false, + "display_name": "Wind", + "group": "Monster", + "order": 240 + }, + "black": { + "enabled": false, + "display_name": "Black", + "group": "Monster", + "order": 250 + }, + "terror": { + "enabled": false, + "display_name": "Terror", + "group": "Monster", + "order": 260 + }, + "ruby": { + "enabled": false, + "display_name": "Ruby", + "group": "Monster", + "order": 270 + }, + "troll": { + "enabled": false, + "display_name": "Troll", + "group": "Monster", + "order": 280 + }, + "crystal": { + "enabled": false, + "display_name": "Crystal", + "group": "Monster", + "order": 290 + }, + "jungle-destroyer": { + "enabled": false, + "display_name": "Jungle Destroyer", + "group": "Monster", + "order": 300 + }, + "manticore": { + "enabled": false, + "display_name": "Manticore", + "group": "Monster", + "order": 310 + }, + "corax2": { + "enabled": false, + "display_name": "Corax 2", + "group": "Silver Troops", + "order": 320 + }, + "corax1": { + "enabled": true, + "display_name": "Corax 1", + "group": "Silver Troops", + "order": 330 + }, + "royal-lion2": { + "enabled": false, + "display_name": "Royal Lion 2", + "group": "Silver Troops", + "order": 340 + }, + "smiter2": { + "enabled": false, + "display_name": "Smiter 2", + "group": "Silver Troops", + "order": 350 + }, + "whitemane2": { + "enabled": false, + "display_name": "Whitemane 2", + "group": "Silver Troops", + "order": 360 + }, + "duelist2": { + "enabled": false, + "display_name": "Duelist 2", + "group": "Silver Troops", + "order": 370 + }, + "punisher2": { + "enabled": false, + "display_name": "Punisher 2", + "group": "Silver Troops", + "order": 380 + }, + "purifier2": { + "enabled": false, + "display_name": "Purifier 2", + "group": "Silver Troops", + "order": 390 + }, + "legitimist2": { + "enabled": false, + "display_name": "Legitimist 2", + "group": "Silver Troops", + "order": 400 + }, + "josephine2": { + "enabled": false, + "display_name": "Josephine 2", + "group": "Silver Troops", + "order": 410 + }, + "royal-lion1": { + "enabled": true, + "display_name": "Royal Lion 1", + "group": "Silver Troops", + "order": 420 + }, + "smiter1": { + "enabled": true, + "display_name": "Smiter 1", + "group": "Silver Troops", + "order": 430 + }, + "purifier1": { + "enabled": true, + "display_name": "Purifier 1", + "group": "Silver Troops", + "order": 440 + }, + "griffin7": { + "enabled": true, + "display_name": "Griffin 7", + "group": "Silver Troops", + "order": 450 + }, + "griffin6": { + "enabled": true, + "display_name": "Griffin 6", + "group": "Silver Troops", + "order": 460 + }, + "josephine1": { + "enabled": true, + "display_name": "Josephine 1", + "group": "Silver Troops", + "order": 470 + }, + "griffin5": { + "enabled": true, + "display_name": "Griffin 5", + "group": "Silver Troops", + "order": 480 + }, + "ballistae7": { + "enabled": true, + "display_name": "Ballistae 7", + "group": "Silver Troops", + "order": 490 + }, + "whitemane1": { + "enabled": true, + "display_name": "Whitemane 1", + "group": "Silver Troops", + "order": 500 + }, + "ballistae6": { + "enabled": true, + "display_name": "Ballistae 6", + "group": "Silver Troops", + "order": 510 + }, + "lion-rider7": { + "enabled": true, + "display_name": "Lion Rider 7", + "group": "Silver Troops", + "order": 520 + }, + "mounted-knight7": { + "enabled": true, + "display_name": "Mounted Knight 7", + "group": "Silver Troops", + "order": 530 + }, + "duelist1": { + "enabled": true, + "display_name": "Duelist 1", + "group": "Silver Troops", + "order": 540 + }, + "punisher1": { + "enabled": true, + "display_name": "Punisher 1", + "group": "Silver Troops", + "order": 550 + }, + "legitimist1": { + "enabled": true, + "display_name": "Legitimist 1", + "group": "Silver Troops", + "order": 560 + }, + "catapult5": { + "enabled": true, + "display_name": "Catapult 5", + "group": "Silver Troops", + "order": 570 + }, + "mounted-knight6": { + "enabled": true, + "display_name": "Mounted Knight 6", + "group": "Silver Troops", + "order": 580 + }, + "lion-rider6": { + "enabled": true, + "display_name": "Lion Rider 6", + "group": "Silver Troops", + "order": 590 + }, + "heavy-arbalester7": { + "enabled": true, + "display_name": "Heavy Arbalester 7", + "group": "Silver Troops", + "order": 600 + }, + "deadshot7": { + "enabled": true, + "display_name": "Deadshot 7", + "group": "Silver Troops", + "order": 610 + }, + "heavy-knight7": { + "enabled": true, + "display_name": "Heavy Knight 7", + "group": "Silver Troops", + "order": 620 + }, + "vulture7": { + "enabled": true, + "display_name": "Vulture 7", + "group": "Silver Troops", + "order": 630 + }, + "heavy-halberdier7": { + "enabled": true, + "display_name": "Heavy Halberdier 7", + "group": "Silver Troops", + "order": 640 + }, + "heavy-arbalester6": { + "enabled": true, + "display_name": "Heavy Arbalester 6", + "group": "Silver Troops", + "order": 650 + }, + "vulture6": { + "enabled": true, + "display_name": "Vulture 6", + "group": "Silver Troops", + "order": 660 + }, + "deadshot6": { + "enabled": true, + "display_name": "Deadshot 6", + "group": "Silver Troops", + "order": 670 + }, + "heavy-halberdier6": { + "enabled": true, + "display_name": "Heavy Halberdier 6", + "group": "Silver Troops", + "order": 680 + }, + "heavy-knight6": { + "enabled": true, + "display_name": "Heavy Knight 6", + "group": "Silver Troops", + "order": 690 + }, + "wasp-man9": { + "enabled": false, + "display_name": "Wasp Man 9", + "group": "Mercenary", + "order": 700 + }, + "defender9": { + "enabled": false, + "display_name": "Defender 9", + "group": "Mercenary", + "order": 710 + }, + "anteater9": { + "enabled": false, + "display_name": "Anteater 9", + "group": "Mercenary", + "order": 720 + }, + "stalker9": { + "enabled": false, + "display_name": "Stalker 9", + "group": "Mercenary", + "order": 730 + }, + "catapult_4": { + "enabled": true, + "display_name": "Catapult 4", + "group": "Silver Troops", + "order": 740 + } +} diff --git a/troop_catalog_gui.py b/troop_catalog_gui.py new file mode 100755 index 0000000..7e01053 --- /dev/null +++ b/troop_catalog_gui.py @@ -0,0 +1,1508 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import json +import re +import tkinter as tk +from tkinter import messagebox +from tkinter import ttk + + +# ============================================================ +# PATHS +# ============================================================ + +BASE_DIR = Path( + __file__ +).resolve().parent + +CATALOG_PATH = ( + BASE_DIR + / "troop_catalog.json" +) + +DESKTOP_UNITS_DIR = ( + BASE_DIR + / "samples_desktop" + / "units" +) + +STANDARD_UNITS_DIR = ( + BASE_DIR + / "samples" + / "units" +) + + +# ============================================================ +# DEFAULT GROUP SUGGESTIONS +# ============================================================ + +DEFAULT_GROUPS = [ + "Mercenary", + "Monster", + "Silver Troops", + "Other", +] + + +# ============================================================ +# HELPERS +# ============================================================ + +def pretty_name(name): + """ + Convert sample filename names into a reasonable + initial display name. + + Examples: + + heavy-knight7 + -> Heavy Knight 7 + + fire-phoenix2 + -> Fire Phoenix 2 + """ + + text = name.replace( + "-", + " ", + ) + + text = text.replace( + "_", + " ", + ) + + text = re.sub( + r"([A-Za-z])(\d+)$", + r"\1 \2", + text, + ) + + return text.title() + + +def save_json( + path, + data, +): + path.write_text( + json.dumps( + data, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + +def load_json( + path, + default=None, +): + if not path.exists(): + if default is None: + raise FileNotFoundError( + path + ) + + return default + + with path.open( + "r", + encoding="utf-8", + ) as f: + return json.load( + f + ) + + +# ============================================================ +# TROOP DISCOVERY +# ============================================================ + +def discover_troops(): + """ + Discover available troop templates. + + samples_desktop takes precedence over samples. + """ + + troops = {} + + if STANDARD_UNITS_DIR.exists(): + for path in ( + STANDARD_UNITS_DIR.glob( + "*.png" + ) + ): + troops[ + path.stem + ] = path + + if DESKTOP_UNITS_DIR.exists(): + for path in ( + DESKTOP_UNITS_DIR.glob( + "*.png" + ) + ): + troops[ + path.stem + ] = path + + return dict( + sorted( + troops.items(), + key=lambda item: ( + pretty_name( + item[0] + ).lower() + ), + ) + ) + + +# ============================================================ +# CATALOG INITIALIZATION +# ============================================================ + +def load_catalog( + discovered, +): + """ + Load the existing catalog and automatically add + newly discovered troop samples. + + Existing user settings are never overwritten. + """ + + catalog = load_json( + CATALOG_PATH, + default={}, + ) + + if not isinstance( + catalog, + dict, + ): + raise RuntimeError( + "troop_catalog.json must contain " + "a JSON object." + ) + + existing_orders = [] + + for entry in catalog.values(): + if not isinstance( + entry, + dict, + ): + continue + + try: + existing_orders.append( + int( + entry.get( + "order", + 0, + ) + ) + ) + except ( + TypeError, + ValueError, + ): + pass + + next_order = ( + max( + existing_orders, + default=0, + ) + + 10 + ) + + changed = False + + for troop_name in discovered: + + if troop_name not in catalog: + catalog[ + troop_name + ] = { + "enabled": True, + "display_name": pretty_name( + troop_name + ), + "group": "Other", + "order": next_order, + } + + next_order += 10 + changed = True + + continue + + entry = catalog[ + troop_name + ] + + if not isinstance( + entry, + dict, + ): + entry = {} + + catalog[ + troop_name + ] = entry + + changed = True + + if "enabled" not in entry: + entry[ + "enabled" + ] = True + changed = True + + if "display_name" not in entry: + entry[ + "display_name" + ] = pretty_name( + troop_name + ) + changed = True + + if "group" not in entry: + entry[ + "group" + ] = "Other" + changed = True + + if "order" not in entry: + entry[ + "order" + ] = next_order + + next_order += 10 + changed = True + + if changed: + save_json( + CATALOG_PATH, + catalog, + ) + + return catalog + + +# ============================================================ +# SCROLLABLE FRAME +# ============================================================ + +class ScrollableFrame( + ttk.Frame +): + def __init__( + self, + parent, + ): + super().__init__( + parent + ) + + self.canvas = tk.Canvas( + self, + highlightthickness=0, + ) + + self.scrollbar = ( + ttk.Scrollbar( + self, + orient="vertical", + command=( + self.canvas.yview + ), + ) + ) + + self.inner = ttk.Frame( + self.canvas + ) + + self.window = ( + self.canvas.create_window( + ( + 0, + 0, + ), + window=self.inner, + anchor="nw", + ) + ) + + self.canvas.configure( + yscrollcommand=( + self.scrollbar.set + ) + ) + + self.canvas.grid( + row=0, + column=0, + sticky="nsew", + ) + + self.scrollbar.grid( + row=0, + column=1, + sticky="ns", + ) + + self.rowconfigure( + 0, + weight=1, + ) + + self.columnconfigure( + 0, + weight=1, + ) + + self.inner.bind( + "", + self._inner_changed, + ) + + self.canvas.bind( + "", + self._canvas_changed, + ) + + self.canvas.bind_all( + "", + self._mouse_wheel, + ) + + self.canvas.bind_all( + "", + self._wheel_up, + ) + + self.canvas.bind_all( + "", + self._wheel_down, + ) + + def _inner_changed( + self, + event, + ): + self.canvas.configure( + scrollregion=( + self.canvas.bbox( + "all" + ) + ) + ) + + def _canvas_changed( + self, + event, + ): + self.canvas.itemconfigure( + self.window, + width=event.width, + ) + + def _mouse_wheel( + self, + event, + ): + delta = int( + -event.delta / 120 + ) + + if delta: + self.canvas.yview_scroll( + delta, + "units", + ) + + def _wheel_up( + self, + event, + ): + self.canvas.yview_scroll( + -3, + "units", + ) + + def _wheel_down( + self, + event, + ): + self.canvas.yview_scroll( + 3, + "units", + ) + + +# ============================================================ +# TROOP ROW +# ============================================================ + +class TroopRow: + def __init__( + self, + editor, + parent, + row, + troop_name, + image_path, + entry, + ): + self.editor = editor + + self.troop_name = ( + troop_name + ) + + self.image_path = ( + image_path + ) + + self.enabled_var = ( + tk.BooleanVar( + value=bool( + entry.get( + "enabled", + True, + ) + ) + ) + ) + + self.display_name_var = ( + tk.StringVar( + value=str( + entry.get( + "display_name", + pretty_name( + troop_name + ), + ) + ) + ) + ) + + self.group_var = ( + tk.StringVar( + value=str( + entry.get( + "group", + "Other", + ) + ) + ) + ) + + self.order_var = ( + tk.StringVar( + value=str( + entry.get( + "order", + 0, + ) + ) + ) + ) + + # ---------------------------------------------------- + # ENABLED + # ---------------------------------------------------- + + enabled = ttk.Checkbutton( + parent, + variable=( + self.enabled_var + ), + command=( + editor.mark_dirty + ), + ) + + enabled.grid( + row=row, + column=0, + padx=8, + pady=5, + ) + + # ---------------------------------------------------- + # IMAGE + # ---------------------------------------------------- + + self.image = None + + try: + image = tk.PhotoImage( + file=str( + image_path + ) + ) + + max_dimension = max( + image.width(), + image.height(), + ) + + if max_dimension > 70: + factor = max( + 1, + int( + max_dimension + / 60 + ), + ) + + image = image.subsample( + factor, + factor, + ) + + self.image = image + + image_widget = ( + ttk.Label( + parent, + image=image, + ) + ) + + except tk.TclError: + image_widget = ttk.Label( + parent, + text="[image]", + ) + + image_widget.grid( + row=row, + column=1, + padx=8, + pady=5, + ) + + # ---------------------------------------------------- + # INTERNAL SAMPLE NAME + # ---------------------------------------------------- + + ttk.Label( + parent, + text=troop_name, + ).grid( + row=row, + column=2, + sticky="w", + padx=8, + pady=5, + ) + + # ---------------------------------------------------- + # DISPLAY NAME + # ---------------------------------------------------- + + display_entry = ttk.Entry( + parent, + textvariable=( + self.display_name_var + ), + width=24, + ) + + display_entry.grid( + row=row, + column=3, + sticky="ew", + padx=8, + pady=5, + ) + + # ---------------------------------------------------- + # GROUP + # ---------------------------------------------------- + + group_combo = ttk.Combobox( + parent, + textvariable=( + self.group_var + ), + values=( + editor.group_choices + ), + state="normal", + width=20, + ) + + group_combo.grid( + row=row, + column=4, + sticky="ew", + padx=8, + pady=5, + ) + + # IMPORTANT: + # + # Do not allow the mouse wheel to cycle through Group + # values. The user must explicitly click/type/select a + # group. Wheel events over this widget scroll the troop + # catalog instead. + group_combo.bind( + "", + editor.combobox_mousewheel, + ) + + group_combo.bind( + "", + editor.combobox_mousewheel, + ) + + group_combo.bind( + "", + editor.combobox_mousewheel, + ) + + # ---------------------------------------------------- + # ORDER + # ---------------------------------------------------- + + order_entry = ttk.Entry( + parent, + textvariable=( + self.order_var + ), + width=8, + ) + + order_entry.grid( + row=row, + column=5, + padx=8, + pady=5, + ) + + # ---------------------------------------------------- + # CHANGE TRACKING + # ---------------------------------------------------- + + for variable in [ + self.display_name_var, + self.group_var, + self.order_var, + ]: + variable.trace_add( + "write", + editor.variable_changed, + ) + + def get_data( + self, + ): + display_name = ( + self.display_name_var.get().strip() + ) + + if not display_name: + display_name = pretty_name( + self.troop_name + ) + + group = ( + self.group_var.get().strip() + ) + + if not group: + group = "Other" + + raw_order = ( + self.order_var.get().strip() + ) + + try: + order = int( + raw_order + ) + + except ValueError: + raise ValueError( + f"{display_name}: " + f"'{raw_order}' is not a valid order number." + ) + + return { + "enabled": bool( + self.enabled_var.get() + ), + "display_name": ( + display_name + ), + "group": group, + "order": order, + } + + +# ============================================================ +# EDITOR +# ============================================================ + +class TroopCatalogEditor: + def __init__( + self, + root, + ): + self.root = root + + self.root.title( + "TBA Troop Catalog Editor" + ) + + self.root.geometry( + "1100x850" + ) + + self.root.minsize( + 850, + 600, + ) + + self.discovered = ( + discover_troops() + ) + + if not self.discovered: + raise RuntimeError( + "No troop samples found." + ) + + self.catalog = load_catalog( + self.discovered + ) + + self.rows = {} + + self.dirty = False + self.loading = False + + self.group_choices = ( + self.get_group_choices() + ) + + self.build_ui() + + self.root.protocol( + "WM_DELETE_WINDOW", + self.close_window, + ) + + # ======================================================== + # GROUPS + # ======================================================== + + def get_group_choices( + self, + ): + groups = set( + DEFAULT_GROUPS + ) + + for entry in ( + self.catalog.values() + ): + if not isinstance( + entry, + dict, + ): + continue + + group = str( + entry.get( + "group", + "", + ) + ).strip() + + if group: + groups.add( + group + ) + + return sorted( + groups, + key=str.lower, + ) + + # ======================================================== + # UI + # ======================================================== + + def build_ui( + self, + ): + main = ttk.Frame( + self.root, + padding=12, + ) + + main.pack( + fill="both", + expand=True, + ) + + main.columnconfigure( + 0, + weight=1, + ) + + main.rowconfigure( + 1, + weight=1, + ) + + # ---------------------------------------------------- + # TITLE + # ---------------------------------------------------- + + title_frame = ttk.Frame( + main + ) + + title_frame.grid( + row=0, + column=0, + sticky="ew", + pady=( + 0, + 10, + ), + ) + + title_frame.columnconfigure( + 0, + weight=1, + ) + + ttk.Label( + title_frame, + text="Troop Catalog", + font=( + "", + 16, + "bold", + ), + ).grid( + row=0, + column=0, + sticky="w", + ) + + ttk.Label( + title_frame, + text=( + f"{len(self.discovered)} " + "troop samples found" + ), + ).grid( + row=0, + column=1, + sticky="e", + ) + + # ---------------------------------------------------- + # CATALOG TABLE + # ---------------------------------------------------- + + box = ttk.LabelFrame( + main, + text="Troops", + padding=6, + ) + + box.grid( + row=1, + column=0, + sticky="nsew", + ) + + box.rowconfigure( + 0, + weight=1, + ) + + box.columnconfigure( + 0, + weight=1, + ) + + self.scroll = ( + ScrollableFrame( + box + ) + ) + + self.scroll.grid( + row=0, + column=0, + sticky="nsew", + ) + + parent = self.scroll.inner + + parent.columnconfigure( + 3, + weight=1, + ) + + parent.columnconfigure( + 4, + weight=1, + ) + + headers = [ + "Show", + "Image", + "Sample", + "Display Name", + "Group", + "Order", + ] + + for column, label in enumerate( + headers + ): + ttk.Label( + parent, + text=label, + font=( + "", + 10, + "bold", + ), + ).grid( + row=0, + column=column, + sticky="w", + padx=8, + pady=8, + ) + + ttk.Separator( + parent, + orient="horizontal", + ).grid( + row=1, + column=0, + columnspan=6, + sticky="ew", + ) + + row_number = 2 + + ordered_names = sorted( + self.discovered.keys(), + key=lambda troop_name: ( + int( + self.catalog.get( + troop_name, + {}, + ).get( + "order", + 999999, + ) + ), + str( + self.catalog.get( + troop_name, + {}, + ).get( + "display_name", + pretty_name( + troop_name + ), + ) + ).lower(), + ), + ) + + for troop_name in ordered_names: + + entry = self.catalog.get( + troop_name, + {}, + ) + + troop_row = TroopRow( + self, + parent, + row_number, + troop_name, + self.discovered[ + troop_name + ], + entry, + ) + + self.rows[ + troop_name + ] = troop_row + + ttk.Separator( + parent, + orient="horizontal", + ).grid( + row=row_number + 1, + column=0, + columnspan=6, + sticky="ew", + ) + + row_number += 2 + + # ---------------------------------------------------- + # BOTTOM CONTROLS + # ---------------------------------------------------- + + bottom = ttk.Frame( + main + ) + + bottom.grid( + row=2, + column=0, + sticky="ew", + pady=( + 12, + 0, + ), + ) + + bottom.columnconfigure( + 1, + weight=1, + ) + + utility = ttk.Frame( + bottom + ) + + utility.grid( + row=0, + column=0, + sticky="w", + ) + + ttk.Button( + utility, + text="Enable All", + command=self.enable_all, + ).grid( + row=0, + column=0, + padx=( + 0, + 6, + ), + ) + + ttk.Button( + utility, + text="Disable All", + command=self.disable_all, + ).grid( + row=0, + column=1, + padx=( + 0, + 6, + ), + ) + + ttk.Button( + utility, + text="Normalize Order", + command=self.normalize_order, + ).grid( + row=0, + column=2, + ) + + self.status_var = ( + tk.StringVar( + value="Ready" + ) + ) + + ttk.Label( + bottom, + textvariable=( + self.status_var + ), + ).grid( + row=0, + column=1, + sticky="w", + padx=20, + ) + + actions = ttk.Frame( + bottom + ) + + actions.grid( + row=0, + column=2, + sticky="e", + ) + + ttk.Button( + actions, + text="Cancel", + command=self.cancel_changes, + ).grid( + row=0, + column=0, + padx=( + 0, + 8, + ), + ) + + ttk.Button( + actions, + text="Save", + command=self.save, + ).grid( + row=0, + column=1, + ) + + def combobox_mousewheel( + self, + event, + ): + """ + Mouse-wheel events over editable comboboxes must NEVER + change their selected value. + + Instead, use the wheel only to scroll the troop catalog. + + Returning "break" prevents Tk/ttk's default Combobox + wheel behavior from cycling through the available groups. + """ + + # Linux/X11 wheel events. + if getattr( + event, + "num", + None, + ) == 4: + self.scroll.canvas.yview_scroll( + -3, + "units", + ) + + return "break" + + if getattr( + event, + "num", + None, + ) == 5: + self.scroll.canvas.yview_scroll( + 3, + "units", + ) + + return "break" + + # MouseWheel-style events. + delta = getattr( + event, + "delta", + 0, + ) + + if delta: + direction = ( + -3 + if delta > 0 + else 3 + ) + + self.scroll.canvas.yview_scroll( + direction, + "units", + ) + + return "break" + + + # ======================================================== + # DIRTY STATE + # ======================================================== + + def variable_changed( + self, + *args, + ): + self.mark_dirty() + + def mark_dirty( + self, + ): + if self.loading: + return + + self.dirty = True + + self.status_var.set( + "Unsaved changes" + ) + + # ======================================================== + # BULK OPERATIONS + # ======================================================== + + def enable_all( + self, + ): + for row in self.rows.values(): + row.enabled_var.set( + True + ) + + self.mark_dirty() + + def disable_all( + self, + ): + for row in self.rows.values(): + row.enabled_var.set( + False + ) + + self.mark_dirty() + + def normalize_order( + self, + ): + """ + Preserve the current visual ordering but renumber + it 10, 20, 30... + """ + + rows = list( + self.rows.values() + ) + + def row_order( + row, + ): + try: + return int( + row.order_var.get() + ) + + except ValueError: + return 999999 + + rows.sort( + key=lambda row: ( + row_order( + row + ), + row.display_name_var.get().lower(), + ) + ) + + value = 10 + + for row in rows: + row.order_var.set( + str( + value + ) + ) + + value += 10 + + self.mark_dirty() + + self.status_var.set( + "Order normalized; save to apply" + ) + + # ======================================================== + # SAVE + # ======================================================== + + def collect_catalog( + self, + ): + catalog = {} + + errors = [] + + for ( + troop_name, + row, + ) in self.rows.items(): + + try: + data = row.get_data() + + except ValueError as exc: + errors.append( + str(exc) + ) + continue + + catalog[ + troop_name + ] = data + + if errors: + messagebox.showerror( + "Invalid Catalog", + "\n".join( + errors + ), + ) + + return None + + return catalog + + def save( + self, + ): + catalog = ( + self.collect_catalog() + ) + + if catalog is None: + return False + + save_json( + CATALOG_PATH, + catalog, + ) + + self.catalog = catalog + + self.dirty = False + + self.status_var.set( + f"Saved {CATALOG_PATH.name}" + ) + + messagebox.showinfo( + "Troop Catalog", + ( + "Troop catalog saved successfully." + ), + ) + + return True + + # ======================================================== + # CANCEL / RELOAD + # ======================================================== + + def cancel_changes( + self, + ): + if not self.dirty: + return + + answer = ( + messagebox.askyesno( + "Discard Changes", + ( + "Discard all unsaved " + "catalog changes?" + ), + ) + ) + + if not answer: + return + + self.root.destroy() + + root = tk.Tk() + + TroopCatalogEditor( + root + ) + + root.mainloop() + + # ======================================================== + # CLOSE + # ======================================================== + + def close_window( + self, + ): + if self.dirty: + answer = ( + messagebox.askyesnocancel( + "Unsaved Changes", + ( + "Save troop catalog " + "before closing?" + ), + ) + ) + + if answer is None: + return + + if answer: + if not self.save(): + return + + self.root.destroy() + + +# ============================================================ +# MAIN +# ============================================================ + +def main(): + root = tk.Tk() + + try: + TroopCatalogEditor( + root + ) + + except Exception as exc: + root.withdraw() + + messagebox.showerror( + "Troop Catalog Editor", + str(exc), + ) + + root.destroy() + + raise + + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/troops.py b/troops.py index ed7b74e..6a542c9 100755 --- a/troops.py +++ b/troops.py @@ -1,45 +1,285 @@ #!/usr/bin/env python3 +from pathlib import Path + from automate import Automator +from scroll_control import production_scroll + +import argparse import json +import os import time + +# COSMIC / XWayland session does not provide ~/.Xauthority. +# This is required before importing pyautogui/python-xlib. +os.environ.setdefault( + "XAUTHORITY", + "/dev/null", +) + import pyautogui -import argparse -parser = argparse.ArgumentParser() -parser.add_argument("troops", help = "troop selection JSON file") -args = parser.parse_args() - -troops_file = args.troops -with open(troops_file) as f: - troops = json.load(f) - -a = Automator(scale = 0.5) -timeout = 8 -start = None -entered = dict() -done = False -if not a.await_samples("battle", 2): - exit(1) -while not done: - if start is None: - start = time.time() - done = True - for name in troops: - if name not in entered: - if a.find("units/" + name): - a.click(100, 7) - time.sleep(0.02) - a.write(troops[name]) - time.sleep(0.02) +pyautogui.PAUSE = 0 + + +def order_troops_by_catalog( + troops, + catalog_path="troop_catalog.json", +): + """ + Return the requested troop stack in the authoritative + troop-catalog order. + + The catalog's `order` value governs: + - Troop Catalog GUI + - Citadel Stack GUI + - Battle troop search/entry order + + Troops missing from the catalog are placed at the end. + """ + + path = Path( + catalog_path + ) + + if path.exists(): + with path.open( + "r", + encoding="utf-8", + ) as f: + catalog = json.load( + f + ) + else: + catalog = {} + + def sort_key( + troop_name, + ): + entry = catalog.get( + troop_name, + {}, + ) + + try: + order = int( + entry.get( + "order", + 999999, + ) + ) + except ( + TypeError, + ValueError, + ): + order = 999999 + + display_name = str( + entry.get( + "display_name", + troop_name, + ) + ).lower() + + return ( + order, + display_name, + troop_name, + ) + + return { + troop_name: troops[ + troop_name + ] + for troop_name in sorted( + troops.keys(), + key=sort_key, + ) + } + + +def load_troops( + a, + troops, + timeout=None, +): + """ + Load configured troop quantities into the current Battle screen. + + troops: + dict mapping troop sample name -> quantity + + Example: + { + "deadshot7": 50000, + "heavy-knight7": 75000, + } + + Returns True on success, False on timeout/failure. + """ + + troops = order_troops_by_catalog( + troops + ) + + a.debug( + "Troop search order:" + ) + + for troop_name in troops: + a.debug( + f" {troop_name}" + ) + + if timeout is None: + timeout = float( + a.timing[ + "troop_load_timeout" + ] + ) + + if not a.await_samples( + "battle", + 2, + ): + print( + "FAIL: Battle screen not detected." + ) + return False + + start = None + entered = {} + done = False + + while not done: + + if start is None: + start = time.time() + + done = True + + for name, amount in troops.items(): + + if name in entered: + continue + + if a.find( + "units/" + name + ): + a.debug( + f"Troop found: " + f"{name} -> {amount}" + ) + + # Quantity field offset was measured against + # the reference UI. Scale that relative offset + # with the rendered game UI. + a.click( + 100 * a.sample_scale, + 15 * a.sample_scale, + ) + + time.sleep( + 0.02 + ) + + a.write( + amount + ) + + time.sleep( + 0.02 + ) + entered[name] = True start = time.time() + else: done = False - if not done: - if time.time() > start + timeout: - exit(1) - time.sleep(0.1) - pyautogui.scroll(-4) - time.sleep(0.1) + + if done: + break + + if ( + time.time() + > start + timeout + ): + missing = [ + name + for name in troops + if name not in entered + ] + + print( + "FAIL: troop loading timed out." + ) + + print( + "Missing troops:", + missing, + ) + + return False + + + # ---------------------------------------------------- + # SCROLL TROOP LIST + # ---------------------------------------------------- + + a.debug( + "Scrolling troop list..." + ) + + production_scroll( + a, + amount=-4, + anchor=a.get_battle_scroll_anchor(), + ) + a.update() + + print( + "Troop stack loaded successfully." + ) + + return True + + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument( + "troops", + help="troop selection JSON file", + ) + + args = parser.parse_args() + + with open( + args.troops, + encoding="utf-8", + ) as f: + troops = json.load( + f + ) + + a = Automator( + scale=0.5 + ) + + try: + success = load_troops( + a, + troops, + ) + + raise SystemExit( + 0 if success else 1 + ) + + finally: + a.input.close() + + +if __name__ == "__main__": + main() diff --git a/ui_registry.py b/ui_registry.py new file mode 100644 index 0000000..826c224 --- /dev/null +++ b/ui_registry.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 + +""" +UI location/state metadata. + +There are two independent questions for a UI item: + +1. How stable is its LOCATION? +2. How stable is its STATE? + +location_behavior: + geometry + Derived directly from Total Battle window geometry. + + static + Visually located once and cached until window geometry + changes. + + anchored + State/content changes, but the item stays in approximately + the same location while its owning context is open. + + dynamic + Location itself can change and must be rediscovered. + +state_behavior: + static + Once located, no fresh classification is normally needed. + + dynamic + A fresh screenshot/classification is required when the + state matters. +""" + + +# ============================================================ +# GEOMETRY-DERIVED ITEMS +# ============================================================ + +GEOMETRY_ITEMS = { + "game_center": { + "location_behavior": "geometry", + "state_behavior": "static", + }, + + "game_top_center": { + "location_behavior": "geometry", + "state_behavior": "static", + }, + + "game_safe_location": { + "location_behavior": "geometry", + "state_behavior": "static", + }, +} + + +# ============================================================ +# STATIC CONTROLS +# +# These may appear on more than one high-level game screen. +# ============================================================ + +STATIC_ITEMS = { + "watchtower": { + "sample": "watchtower", + "screens": { + "city", + "world", + }, + "region": "bottom_center", + "location_behavior": "static", + "state_behavior": "static", + }, + + "army": { + "sample": "army", + "screens": { + "city", + "world", + }, + "region": "bottom_center", + "location_behavior": "static", + "state_behavior": "static", + }, + + "clan": { + "sample": "clan", + "screens": { + "city", + "world", + }, + "region": "bottom_center", + "location_behavior": "static", + "state_behavior": "static", + }, + + "zoom_out": { + "sample": "zoom_out", + "screens": { + "city", + "world", + }, + + # Zoom Out always lives very near the actual game + # window's bottom-right corner. A fixed 200x200 search + # is much smaller and more reliable than a large + # ratio-based quadrant. + "fixed_region": { + "anchor": "bottom_right", + "width": 200, + "height": 200, + }, + + "location_behavior": "static", + "state_behavior": "static", + }, + + "map": { + "sample": "map", + "screens": { + "city", + }, + "region": "bottom_center", + "location_behavior": "static", + "state_behavior": "static", + }, + + "city": { + "sample": "city", + "screens": { + "world", + }, + "region": "bottom_center", + "location_behavior": "static", + "state_behavior": "static", + }, + + "exit_shop": { + "sample": "exit_shop", + "screens": { + "shop", + }, + "region": None, + "location_behavior": "static", + "state_behavior": "static", + }, +} + + +# ============================================================ +# ANCHORED-DYNAMIC ITEMS +# +# Location is predictable within a particular UI context, but +# state/content must still be freshly classified. +# ============================================================ + +# ============================================================ +# VISUAL SCALE GROUPS +# ============================================================ +# +# Some Total Battle UI families may render at different +# apparent template scales. +# +# Members of a scale group share a learned visual/template +# scale. Samples not assigned to a group continue using the +# existing global Automator sample_scale behavior. +# +# IMPORTANT: +# These are TEMPLATE scales, not desktop/input coordinate scale. +# ============================================================ + +SCALE_GROUPS = { + "watchtower": { + "candidate_scales": ( + 0.75, + 1.00, + 1.25, + 4.0 / 3.0, + 1.50, + 1.75, + 2.00, + 2.25, + ), + "probe_samples": ( + "crypts_selected", + "crypts_unselected", + "monsters_selected", + "monsters_unselected", + ), + "min_good_matches": 2, + "probe_threshold": 0.05, + }, +} + + +SAMPLE_SCALE_GROUPS = { + "crypts_selected": "watchtower", + "crypts_unselected": "watchtower", + "monsters_selected": "watchtower", + "monsters_unselected": "watchtower", + + # GO buttons inside the Watchtower result list. + "go_watchtower": "watchtower", + + # -------------------------------------------------------- + # POST-GO CRYPT PANEL + # -------------------------------------------------------- + # + # Measured on the Windows standalone client: + # + # carter_selected: + # scale 1.333333 -> score 0.003834 + # + # carter_on_march: + # scale 1.333333 -> score 0.038080 + # + # rare_open: + # scale 1.333333 -> score 0.047233 + # + # These currently share the same visual scale as the + # Watchtower UI. Explore is intentionally NOT included yet + # because it was not visible during that measurement. + "carter_selected": "watchtower", + "carter_on_march": "watchtower", + "rare_open": "watchtower", + "explore": "watchtower", + + "common_selected": "watchtower", + "common_unselected": "watchtower", + "rare_selected": "watchtower", + "rare_unselected": "watchtower", + "epic_selected": "watchtower", + "epic_unselected": "watchtower", +} + + +def get_sample_scale_group(sample_name): + """ + Return the visual scale group for a sample. + + Samples not explicitly registered continue using the + Automator's existing global sample_scale behavior. + """ + + return SAMPLE_SCALE_GROUPS.get( + sample_name + ) + + +def get_scale_group_definition(group_name): + """ + Return configuration for one visual scale group. + """ + + return SCALE_GROUPS.get( + group_name + ) + + + +ANCHORED_ITEMS = { + "shop_loaded": { + "context": "shop", + "state_samples": { + "present": "shop_loaded", + }, + "discover_region": None, + "padding": 20, + "location_behavior": "anchored", + "state_behavior": "dynamic", + }, + + "crypts_tab": { + "context": "watchtower", + "scale_group": "watchtower", + "state_samples": { + "selected": "crypts_selected", + "unselected": "crypts_unselected", + }, + "discover_region": "middle_center", + "padding": 20, + "location_behavior": "anchored", + "state_behavior": "dynamic", + }, + + "monsters_tab": { + "context": "watchtower", + "scale_group": "watchtower", + "state_samples": { + "selected": "monsters_selected", + "unselected": "monsters_unselected", + }, + "discover_region": "middle_center", + "padding": 20, + "location_behavior": "anchored", + "state_behavior": "dynamic", + }, + + "common_filter": { + "context": "watchtower_crypts", + "scale_group": "watchtower", + "state_samples": { + "selected": "common_selected", + "unselected": "common_unselected", + }, + "discover_region": "middle_center", + "padding": 20, + "location_behavior": "anchored", + "state_behavior": "dynamic", + }, + + "rare_filter": { + "context": "watchtower_crypts", + "scale_group": "watchtower", + "state_samples": { + "selected": "rare_selected", + "unselected": "rare_unselected", + }, + "discover_region": "middle_center", + "padding": 20, + "location_behavior": "anchored", + "state_behavior": "dynamic", + }, + + "epic_filter": { + "context": "watchtower_crypts", + "scale_group": "watchtower", + "state_samples": { + "selected": "epic_selected", + "unselected": "epic_unselected", + }, + "discover_region": "middle_center", + "padding": 20, + "location_behavior": "anchored", + "state_behavior": "dynamic", + }, + + "citadel_type": { + "context": "citadel_attack", + "state_samples": { + "cursed_20": + "cursed_citadel_20_select", + + "cursed_25": + "cursed_citadel_25_select", + + "elven_20": + "elven_citadel_20_select", + + "elven_25": + "elven_citadel_25_select", + + "elven_30": + "elven_citadel_30_select", + }, + "discover_region": "middle_center", + "padding": 20, + "location_behavior": "anchored", + "state_behavior": "dynamic", + }, +} + + +# ============================================================ +# FULL REGISTRY +# ============================================================ + +UI_ITEMS = {} + +UI_ITEMS.update( + GEOMETRY_ITEMS +) + +UI_ITEMS.update( + STATIC_ITEMS +) + +UI_ITEMS.update( + ANCHORED_ITEMS +) + + +def get_ui_definition( + name, +): + return UI_ITEMS.get( + name + ) + + +def static_items_for_screen( + screen, +): + """ + Return static item definitions that can appear on `screen`. + """ + + return { + name: definition + for ( + name, + definition, + ) in STATIC_ITEMS.items() + if screen + in definition.get( + "screens", + set(), + ) + } + + +def anchored_items_for_context( + context, +): + return { + name: definition + for ( + name, + definition, + ) in ANCHORED_ITEMS.items() + if definition.get( + "context" + ) == context + } diff --git a/watchtower.py b/watchtower.py new file mode 100644 index 0000000..f045891 --- /dev/null +++ b/watchtower.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 + +import time + + +WATCHTOWER_TABS = { + "crypts": { + "selected": "crypts_selected", + "unselected": "crypts_unselected", + }, + "monsters": { + "selected": "monsters_selected", + "unselected": "monsters_unselected", + }, +} + + +def _tab_info(tab): + try: + return WATCHTOWER_TABS[ + tab + ] + + except KeyError: + raise ValueError( + f"Unsupported Watchtower tab: " + f"{tab}" + ) + + +def locate_watchtower( + a, + screen=None, +): + """ + Return the cached Watchtower location. + + If Watchtower has not yet been learned, discover static UI + for the current City/World screen from one screenshot. + """ + + location = a.get_ui_location( + "watchtower" + ) + + if location is not None: + + a.loc = location + + a.debug( + f"Watchtower cached: " + f"{location}" + ) + + return location + + if screen is None: + screen = ( + a.detect_game_state() + ) + + if screen not in { + "city", + "world", + }: + print( + "FAIL: Watchtower requested " + f"while state is {screen!r}." + ) + + return None + + a.ensure_static_ui_for_screen( + screen + ) + + location = a.get_ui_location( + "watchtower" + ) + + if location is None: + print( + "FAIL: Watchtower not found." + ) + + return None + + a.loc = location + + a.debug( + f"Watchtower cached: " + f"{location}" + ) + + return location + + +def select_tab( + a, + tab, + force_check=False, +): + """ + Ensure the requested Watchtower tab is selected. + + Runtime optimization: + + If our own previous action established that this tab is + selected, trust that state unless force_check=True. + + When state is unknown, use the anchored selected/unselected + classifier. The first successful visual classification also + learns the fuzzy tab location for future use. + """ + + _tab_info( + tab + ) + + # -------------------------------------------------------- + # TRUST KNOWN RUNTIME STATE + # -------------------------------------------------------- + + if ( + not force_check + and a.context_state.get( + "watchtower_tab" + ) == tab + ): + a.debug( + f"{tab.title()} tab already known selected." + ) + + return True + + logical_name = { + "crypts": "crypts_tab", + "monsters": "monsters_tab", + }[ + tab + ] + + # -------------------------------------------------------- + # CLASSIFY CURRENT STATE + # -------------------------------------------------------- + + a.update() + + result = a.classify_anchored_ui_state( + logical_name, + update=False, + ) + + # If something transient obscured the tab, clear hover and + # retry once. + if ( + result is None + or result["state"] is None + ): + safe = a.get_ui_location( + "game_safe_location" + ) + + if safe is not None: + a.debug( + f"{tab.title()} tab state unclear; " + "clearing possible hover popup..." + ) + + a.loc = safe + a.move() + + time.sleep( + 0.35 + ) + + a.update() + + result = ( + a.classify_anchored_ui_state( + logical_name, + update=False, + ) + ) + + if ( + result is None + or result["state"] is None + ): + print( + f"FAIL: {tab.title()} tab " + "could not be identified." + ) + + return False + + state = result[ + "state" + ] + + a.debug( + f"{tab.title()} tab state: " + f"{state} " + f"score={result['score']:.6f} " + f"margin={result['margin']:.6f} " + f"anchored={result['anchored']}" + ) + + # -------------------------------------------------------- + # ALREADY SELECTED + # -------------------------------------------------------- + + if state == "selected": + a.context_state[ + "watchtower_tab" + ] = tab + + a.debug( + f"{tab.title()} tab already selected." + ) + + return True + + if state != "unselected": + return False + + # -------------------------------------------------------- + # SELECT TAB + # -------------------------------------------------------- + + a.loc = result[ + "location" + ] + + a.debug( + f"Selecting {tab.title()} tab: " + f"{a.loc}" + ) + + a.click() + + time.sleep( + a.timing[ + "crypt_tab_select_wait" + ] + ) + + # Our own click establishes the state. + a.context_state[ + "watchtower_tab" + ] = tab + + return True + + +def open_watchtower( + a, + tab, +): + """ + Open the World-map Watchtower and ensure `tab` is active. + + Example: + + open_watchtower( + a, + tab="crypts", + ) + + open_watchtower( + a, + tab="monsters", + ) + """ + + location = locate_watchtower( + a + ) + + if location is None: + return False + + # locate_watchtower() leaves a.loc on the Watchtower. + a.loc = location + a.click() + + # -------------------------------------------------------- + # ESTABLISH WATCHTOWER VISUAL SCALE + # -------------------------------------------------------- + # + # The game's main HUD and the Watchtower panel may render + # at different apparent template scales. + # + # Learn the Watchtower group's scale once after the panel + # is opened. All registered Watchtower samples will then + # automatically use that learned group scale through + # Automator.get_sample(). + # -------------------------------------------------------- + + if ( + a.get_sample_group_scale( + "watchtower" + ) + is None + ): + # ---------------------------------------------------- + # WATCHTOWER RENDER / SCALE DISCOVERY RETRIES + # ---------------------------------------------------- + # + # The panel can still be animating immediately after the + # Watchtower icon is clicked. A partially rendered frame + # may not contain both corroborating scale probes yet. + # + # Retry a few fresh screenshots before treating scale + # discovery as a real failure. + # ---------------------------------------------------- + + scale_result = None + + for scale_try in range( + 1, + 4, + ): + + time.sleep( + a.timing[ + "crypt_tab_select_wait" + ] + ) + + scale_result = ( + a.discover_sample_group_scale( + "watchtower", + update=True, + ) + ) + + if ( + scale_result is not None + and scale_result.get( + "scale" + ) is not None + ): + break + + a.debug( + f"Watchtower visual scale " + f"not established on render " + f"attempt {scale_try}/3." + ) + + if ( + scale_result is None + or scale_result.get( + "scale" + ) is None + ): + print( + "FAIL: Watchtower visual scale " + "could not be established after " + "render retries." + ) + + return False + + return select_tab( + a, + tab, + ) + + +def refresh_tab( + a, + tab, +): + """ + Refresh the requested Watchtower tab. + + Clicking the tab is simultaneously: + + - the normal empty-list refresh action + - a state-establishing action + + Therefore no selected/unselected classification is needed + before this click if its anchored location is already known. + """ + + _tab_info( + tab + ) + + logical_name = { + "crypts": "crypts_tab", + "monsters": "monsters_tab", + }[ + tab + ] + + # -------------------------------------------------------- + # USE KNOWN TAB ANCHOR IF AVAILABLE + # -------------------------------------------------------- + + location = ( + a.get_anchored_ui_location( + logical_name + ) + ) + + # If we have never learned this tab location, perform one + # proper state classification to establish it. + if location is None: + + if not select_tab( + a, + tab, + force_check=True, + ): + return False + + location = ( + a.get_anchored_ui_location( + logical_name + ) + ) + + if location is None: + print( + f"FAIL: no cached location for " + f"{tab.title()} tab." + ) + + return False + + # -------------------------------------------------------- + # REFRESH BY CLICKING TAB + # -------------------------------------------------------- + + a.loc = location + + a.debug( + f"Refreshing {tab.title()} tab: " + f"{location}" + ) + + a.click() + + # This click guarantees which tab is selected. + a.context_state[ + "watchtower_tab" + ] = tab + + time.sleep( + a.timing[ + "watchtower_refresh_wait" + ] + ) + + return True + + +def wait_for_go_buttons( + a, + tab, + target_name="targets", +): + """ + Wait until the current Watchtower tab contains at least one + GO button. + + Refresh the requested tab while none are available. + + Returns a list of GO locations. + """ + + while True: + go_buttons = ( + a.await_samples_multi( + "go_watchtower", + 3, + ) + ) + + if go_buttons: + a.debug( + f"GO buttons available: " + f"{len(go_buttons)}" + ) + + return go_buttons + + print( + f"No {target_name} currently " + f"available." + ) + + print( + f"Refreshing " + f"{tab.title()} list..." + ) + + if not refresh_tab( + a, + tab, + ): + return None + + +def choose_random_go(a): + """ + Choose one result from the most recent multi-match. + + Keeps the currently proven Crypt selection behavior. + """ + + if not a.random_multi(): + print( + "FAIL: could not choose GO." + ) + + return None + + a.debug( + f"Chosen GO: {a.loc}" + ) + + return a.loc + + +def click_go(a, location=None): + """ + Click a Watchtower GO button. + + If location is supplied, use it. Otherwise self.loc is + expected to already contain the chosen GO. + """ + + if location is not None: + a.loc = location + + if a.loc is None: + print( + "FAIL: no GO location selected." + ) + + return False + + a.click() + + return True + + +# ============================================================ +# WATCHTOWER FILTER STATE +# ============================================================ + +def ensure_filters( + a, + filter_names, + requested, + detect, + label="Watchtower", +): + """ + Make a set of Watchtower toggle filters exactly match the + requested state. + + Parameters + ---------- + filter_names: + Iterable containing every valid filter name, in the + desired processing/display order. + + requested: + Names that should be selected/ON. + + If requested is None, the existing filter state is left + untouched. + + An EMPTY set is valid and means turn every filter OFF. + + detect: + Callable taking Automator `a` and returning: + + { + name: { + "selected": bool, + "location": (x, y), + "selected_score": float, + "unselected_score": float, + } + } + + The detector owns the task/tab-specific visual details. + + label: + Human-readable description used for logging. + + This function owns the generic Watchtower behavior: + validate requested names + -> detect current state + -> click only mismatches + -> detect again + -> verify final state + """ + + # None specifically means "do not alter filters". + # + # An empty set is intentionally different: it means all + # known filters should be OFF. + if requested is None: + return True + + filter_names = tuple( + filter_names + ) + + valid = set( + filter_names + ) + + requested = set( + requested + ) + + invalid = ( + requested + - valid + ) + + if invalid: + print( + f"Invalid {label} filter(s): " + + ", ".join( + sorted( + invalid + ) + ) + ) + + return False + + # Move away from the controls so hover state does not alter + # their appearance before the classifier screenshot. + a.loc = ( + 1200, + 900, + ) + + a.move() + + time.sleep( + 0.1 + ) + + # ONE screenshot is taken by the caller-owned detector path. + a.update() + + states = detect( + a + ) + + print() + print( + f"Current {label} filters:" + ) + + for name in filter_names: + + state = states[ + name + ] + + print( + f" {name:12s}: " + f"{'ON' if state['selected'] else 'OFF'} " + f"(selected=" + f"{state['selected_score']:.6f}, " + f"unselected=" + f"{state['unselected_score']:.6f})" + ) + + # -------------------------------------------------------- + # CLICK ONLY MISMATCHES + # -------------------------------------------------------- + + changed = False + + for name in filter_names: + + want_selected = ( + name + in requested + ) + + state = states[ + name + ] + + if ( + state["selected"] + == want_selected + ): + continue + + print( + f"Changing {name}: " + f"{'ON' if state['selected'] else 'OFF'} " + f"-> " + f"{'ON' if want_selected else 'OFF'}" + ) + + a.loc = state[ + "location" + ] + + a.click() + + changed = True + + # Preserve the currently proven settling delay. + time.sleep( + 0.15 + ) + + if not changed: + print( + f"{label} filters already correct." + ) + + return True + + # -------------------------------------------------------- + # VERIFY FROM ONE FRESH SCREENSHOT + # -------------------------------------------------------- + + # Again move the pointer away from the filter controls. + a.loc = ( + 1200, + 900, + ) + + a.move() + + time.sleep( + 0.15 + ) + + a.update() + + verified = detect( + a + ) + + print() + print( + f"Verified {label} filters:" + ) + + success = True + + for name in filter_names: + + actual = verified[ + name + ][ + "selected" + ] + + expected = ( + name + in requested + ) + + print( + f" {name:12s}: " + f"{'ON' if actual else 'OFF'}" + ) + + if actual != expected: + success = False + + if not success: + print( + f"FAIL: {label} filter verification " + f"did not match requested state." + ) + + return False + + print( + f"{label} filters configured." + ) + + return True diff --git a/world_map.py b/world_map.py new file mode 100644 index 0000000..e6e1945 --- /dev/null +++ b/world_map.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + + +def ensure_world(a): + """ + Ensure Total Battle is on the World map. + """ + + return a.ensure_game_state( + "world" + ) + + +def get_center_target(a): + """ + Return the actual cached center of the Total Battle window. + + The previous +12 px adjustment was tied to the older + monitor-center calculation. Now that actual game-window + geometry is known, use the window center directly. + """ + + center = a.get_ui_location( + "game_world_target_center" + ) + + # Compatibility fallback for callers/Automator instances + # that predate the dedicated world-map target location. + if center is None: + center = a.get_ui_location( + "game_center" + ) + + if center is None: + raise RuntimeError( + "Game world target center has not been cached." + ) + + return center + + +def click_center_target(a): + """ + Click the target centered by a Watchtower GO action. + """ + + location = get_center_target( + a + ) + + a.loc = location + + a.debug( + f"Clicking centered target: " + f"{location}" + ) + + a.click() + + return location