Files
public-python/main.py
2026-04-04 16:45:10 -04:00

226 lines
6.9 KiB
Python

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()