from __future__ import annotations import argparse import json import re import sys import time from pathlib import Path from typing import Any from pynput.keyboard import Controller as KeyboardController, Key from pynput.mouse import Button, Controller as MouseController DEFAULT_COORDS_PATH = Path(__file__).resolve().parent / "coords.json" # Timing tuned for games that poll input each frame: move must settle before click, # and a short pause after mouse-up helps the last click flush before the process exits. MOVE_SETTLE_SEC = 0.02 CLICK_HOLD_SEC = 0.03 POST_CLICK_SEC = 0.03 DEFAULT_TEST_SLOTS = ["i1", "i2", "i3", "i4", "i5", "i6", "i7", "i8", "i9", "i1"] def get_coord(coord_map: dict[str, Any], coord_name: str) -> tuple[float, float]: """Pixel position for a named slot. tl/br are absolute pixels; others use normalized offsets.""" if coord_name in ("tl", "br"): v = coord_map[coord_name] return (float(v[0]), float(v[1])) offsets = coord_map[coord_name] tl = coord_map["tl"] width = coord_map["width"] height = coord_map["height"] x = tl[0] + (offsets[0] * width) y = tl[1] + (offsets[1] * height) return (x, y) def load_coords(path: Path) -> dict[str, Any]: data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) if "anvil" not in data or "recipe" not in data: raise ValueError("coords.json must contain 'anvil' and 'recipe' keys") return data def resolve_coord_map(payload: dict[str, Any], name: str) -> dict[str, Any]: """Return the anvil or recipe sub-map that contains this slot name.""" if name in ("width", "height"): raise ValueError(f"{name!r} is metadata, not a clickable slot.") for section in ("anvil", "recipe"): m = payload[section] if name not in m: continue val = m[name] if isinstance(val, (int, float)): raise ValueError(f"{name!r} in {section!r} is not a coordinate pair.") return m raise KeyError(f"Unknown slot {name!r} (not in anvil or recipe).") def parse_slots_file(content: str) -> list[str]: """Split slot names on commas, spaces, tabs, and newlines; empty tokens are dropped.""" return [t for t in re.split(r"[\s,]+", content.strip()) if t] def parse_slot_token(token: str) -> tuple[str, bool]: """Underscore prefix means hold Shift for that click (_i2 -> i2 with shift).""" if token.startswith("_"): return token[1:], True return token, False def run_sequence( payload: dict[str, Any], slots: list[str], delay_ms: float, move_settle_sec: float = MOVE_SETTLE_SEC, click_hold_sec: float = CLICK_HOLD_SEC, post_click_sec: float = POST_CLICK_SEC, ) -> None: mouse = MouseController() keyboard = KeyboardController() for i, token in enumerate(slots): name, use_shift = parse_slot_token(token) if not name: raise ValueError(f"Empty slot name in token {token!r}") coord_map = resolve_coord_map(payload, name) x, y = get_coord(coord_map, name) xi, yi = int(round(x)), int(round(y)) mouse.position = (xi, yi) time.sleep(move_settle_sec) if use_shift: keyboard.press(Key.shift) time.sleep(move_settle_sec) try: mouse.press(Button.left) time.sleep(click_hold_sec) mouse.release(Button.left) finally: if use_shift: keyboard.release(Key.shift) time.sleep(post_click_sec) if i + 1 < len(slots) and delay_ms > 0: time.sleep(delay_ms / 1000.0) def main() -> None: parser = argparse.ArgumentParser( description="Move the mouse to slots from coords.json and left-click.", ) parser.add_argument( "-s", "--start-delay", type=int, default=700, metavar="MS", help="Milliseconds to wait before starting the sequence (default: 1000).", ) parser.add_argument( "-d", "--delay", type=int, default=200, metavar="MS", help="Milliseconds to wait after each click before the next (default: 200).", ) parser.add_argument( "--move-settle-ms", type=float, default=MOVE_SETTLE_SEC * 1000, metavar="MS", help=( "Milliseconds to wait after moving the cursor before mouse down " f"(default: {MOVE_SETTLE_SEC * 1000:.0f}). Helps clicks register on the new position." ), ) parser.add_argument( "--post-click-ms", type=float, default=POST_CLICK_SEC * 1000, metavar="MS", help=( "Milliseconds to wait after each mouse up (including the last) " f"(default: {POST_CLICK_SEC * 1000:.0f}). Helps the final click flush before exit." ), ) parser.add_argument( "--click-hold-ms", type=float, default=CLICK_HOLD_SEC * 1000, metavar="MS", help=f"Milliseconds to hold the mouse button down (default: {CLICK_HOLD_SEC * 1000:.0f}).", ) parser.add_argument( "-c", "--coords-file", type=Path, default=None, help=f"Path to coords.json (default: {DEFAULT_COORDS_PATH}).", ) parser.add_argument( "-f", "--file", type=Path, default=None, metavar="PATH", help=( "Read slot names from this file (commas, spaces, and newlines separate tokens). " "When set, positional SLOT arguments are ignored." ), ) parser.add_argument( "slots", nargs="*", metavar="SLOT", help=( "Slot names from coords.json, e.g. M D P G i9 h6. Use _NAME for shift-click. " "Ignored when --file is set." ), ) args = parser.parse_args() path = args.coords_file.expanduser().resolve() if args.coords_file else DEFAULT_COORDS_PATH if not path.is_file(): print(f"Missing coords file: {path}", file=sys.stderr) sys.exit(1) if args.file is not None: slots_path = args.file.expanduser().resolve() if not slots_path.is_file(): print(f"Missing slots file: {slots_path}", file=sys.stderr) sys.exit(1) slots = parse_slots_file(slots_path.read_text(encoding="utf-8")) if not slots: print("Slots file is empty or contains no slot names.", file=sys.stderr) sys.exit(1) else: slots = args.slots if args.slots else DEFAULT_TEST_SLOTS time.sleep(args.start_delay / 1000) try: payload = load_coords(path) run_sequence( payload, slots, args.delay, move_settle_sec=args.move_settle_ms / 1000.0, click_hold_sec=args.click_hold_ms / 1000.0, post_click_sec=args.post_click_ms / 1000.0, ) except (json.JSONDecodeError, ValueError, KeyError) as e: print(e, file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()