Files
public-python/tfc-forge-macro/main.py
2026-04-02 17:51:44 -04:00

196 lines
5.9 KiB
Python

from __future__ import annotations
import argparse
import json
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_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(
"slots",
nargs="*",
metavar="SLOT",
help="Slot names from coords.json, e.g. M D P G i9 h6. Use _NAME for shift-click.",
)
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)
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()