From fda3a795bb8524760152692cf359a6d48e78e515 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Thu, 15 Oct 2026 14:28:00 +0300 Subject: [PATCH] lgbot: interactive setup script Co-Authored-By: Claude Opus 5.5 --- lgbot | 7 ++ tools/lgbot_setup.py | 164 +++++++++++++++++++++++++++++++++++++++++++ tools/test_lgbot.py | 111 +++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100755 lgbot create mode 100644 tools/lgbot_setup.py diff --git a/lgbot b/lgbot new file mode 100755 index 0000000..b4aaa69 --- /dev/null +++ b/lgbot @@ -0,0 +1,7 @@ +#!/bin/sh +# LG training bot setup: ./lgbot asks for aim/movement and sets the bot up; ./lgbot --last, show, off. +# Runs inside the container like ./rcon, so the RCON password never leaves this machine. +D=$(cd "$(dirname "$0")" && pwd) +NAME=$(sed -n 's/^QL_NAME=//p' "$D/.env" 2>/dev/null | tr -d '\r') +if [ -t 0 ]; then T=-it; else T=-i; fi +exec docker exec $T "${NAME:-ql-lg}" python3 /ql/tools/lgbot_setup.py "$@" diff --git a/tools/lgbot_setup.py b/tools/lgbot_setup.py new file mode 100644 index 0000000..c7ac1f0 --- /dev/null +++ b/tools/lgbot_setup.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""LG training bot setup. Runs inside the container (through ./lgbot), like ./rcon. + ./lgbot questions: aim 1-5, movement 1-5, optional movement details; Enter keeps [value] + ./lgbot --last put the last setup back (e.g. after a server restart) + ./lgbot show saved setup and whether the bot is in + ./lgbot off remove the bot +Aim is the bot's skill level (addbot lgbot N). Movement is the lgbot_* cvars the lgbot plugin reads. +The last setup is saved in home/lgbot.json. +""" +import json, os, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "home", "minqlx-plugins")) +from lgbot import PRESETS, LIMITS, KEYS # noqa: E402 (the plugin's pattern settings) +from qlq import parse_rcon_status # noqa: E402 + +STATE = os.path.join(HERE, "..", "home", "lgbot.json") +LABELS = {"strafe_min": "Strafe hold min ms", "strafe_max": "Strafe hold max ms", + "fake": "Fake chance %", "range": "Preferred range (LG reach 768)", + "range_play": "Range play: push in when ahead, back off when low y/n", + "speed": "Strafe speed % of run speed"} + + +def default_state(): + return {"aim": 3, "move": 3, "details": {}} + + +def load_state(path=STATE): + st = default_state() + try: + with open(path) as f: + raw = json.load(f) + except (OSError, ValueError): + return st + if not isinstance(raw, dict): + return st + for k in ("aim", "move"): + if isinstance(raw.get(k), int) and 1 <= raw[k] <= 5: + st[k] = raw[k] + details = raw.get("details") + if isinstance(details, dict): + for k, v in details.items(): + if k in LIMITS and isinstance(v, int) and LIMITS[k][0] <= v <= LIMITS[k][1]: + st["details"][k] = v + return st + + +def save_state(path, st): + with open(path, "w") as f: + json.dump(st, f, indent=1) + + +def parse_int(text, lo, hi, default): + t = text.strip() + if not t: + return default + try: + v = int(t) + except ValueError: + return None + return v if lo <= v <= hi else None + + +def parse_yn(text, default): + t = text.strip().lower() + if not t: + return default + return {"y": True, "yes": True, "n": False, "no": False}.get(t) + + +def ask(prompt, parse, input_fn=input, print_fn=print): + while True: + v = parse(input_fn(prompt)) + if v is not None: + return v + print_fn(" not valid, try again") + + +def prompt_setup(st, input_fn=input, print_fn=print): + """Ask everything; Enter keeps the bracketed value. Ctrl-C/EOF propagate (caller cancels).""" + aim = ask("Aim level 1-5 [%d]: " % st["aim"], lambda t: parse_int(t, 1, 5, st["aim"]), input_fn, print_fn) + move = ask("Movement level 1-5 [%d]: " % st["move"], lambda t: parse_int(t, 1, 5, st["move"]), input_fn, print_fn) + had = bool(st["details"]) + custom = ask("Customize movement details? %s: " % ("(Y/n)" if had else "(y/N)"), + lambda t: parse_yn(t, had), input_fn, print_fn) + details = {} + if custom: + for k in KEYS: + cur = st["details"].get(k, PRESETS[move][k]) + lo, hi = LIMITS[k] + if k == "range_play": + v = int(ask(" %s [%s]: " % (LABELS[k], "y" if cur else "n"), + lambda t, c=cur: parse_yn(t, bool(c)), input_fn, print_fn)) + else: + v = ask(" %s %d-%d [%d]: " % (LABELS[k], lo, hi, cur), + lambda t, c=cur, lo=lo, hi=hi: parse_int(t, lo, hi, c), input_fn, print_fn) + if v != PRESETS[move][k]: + details[k] = v + return {"aim": aim, "move": move, "details": details} + + +def bot_present(status_text): + parsed = parse_rcon_status(status_text) + return bool(parsed) and any(p[2].lower() == "lgbot" for p in parsed[1]) + + +def build_commands(st, readd, bot_in): + cmds = ["set lgbot_move %d" % st["move"]] + cmds += ["set lgbot_%s %d" % (k, st["details"].get(k, -1)) for k in KEYS] + if readd: + if bot_in: + cmds.append("kick lgbot") + cmds.append("addbot lgbot %d" % st["aim"]) + return cmds + + +def describe(st): + d = ", ".join("%s %s" % (k, v) for k, v in sorted(st["details"].items())) + return "aim %d, movement %d%s" % (st["aim"], st["move"], " (custom: %s)" % d if d else "") + + +def server(): + import qlrcon # needs zmq + the RCON password: only available inside the container + s = qlrcon.connect() + return lambda cmd: qlrcon.run(s, cmd) + + +def apply(st, old_aim): + run = server() + bot_in = bot_present(run("status")) + for cmd in build_commands(st, readd=(not bot_in or st["aim"] != old_aim), bot_in=bot_in): + run(cmd) + ok = bot_present(run("status")) + print("lgbot %s: %s" % ("in" if ok else "NOT in (is the server up? map loading?)", describe(st))) + return 0 if ok else 1 + + +def main(argv, input_fn=input): + arg = argv[1] if len(argv) > 1 else "" + st = load_state() + if arg == "off": + server()("kick lgbot") + print("lgbot removed (setup kept, ./lgbot --last brings it back)") + return 0 + if arg == "show": + print("saved setup: " + describe(st)) + print("bot is " + ("in" if bot_present(server()("status")) else "not in")) + return 0 + if arg == "--last": + return apply(st, old_aim=None) # old_aim None: always re-add so the aim level is right + if arg: + print(__doc__.strip()) + return 2 + try: + new = prompt_setup(st, input_fn) + except (EOFError, KeyboardInterrupt): + print("\ncancelled, nothing changed") + return 1 + save_state(STATE, new) + return apply(new, old_aim=st["aim"]) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/test_lgbot.py b/tools/test_lgbot.py index a06f982..b685d51 100644 --- a/tools/test_lgbot.py +++ b/tools/test_lgbot.py @@ -137,5 +137,116 @@ class Misc(unittest.TestCase): self.assertIsNone(pick_target((0, 0), [])) +import json, tempfile +import lgbot_setup as setup + +STATUS_WITH_BOT = """map: thunderstruck +num score ping name lastmsg address qport rate steamid +--- ----- ---- --------------- ------- --------------------- ----- ----- ----------------- + 0 3 0 lgbot 0 bot 0 16384 0 + 1 7 48 player 0 100.64.1.2:27960 354 25000 76561198000000000 +""" + + +def feeder(*answers): + it = iter(answers) + def inp(prompt=""): + a = next(it) + if isinstance(a, BaseException): + raise a + return a + return inp + + +class State(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.path = os.path.join(self.dir, "lgbot.json") + + def test_missing_file_gives_defaults(self): + self.assertEqual(setup.load_state(self.path), {"aim": 3, "move": 3, "details": {}}) + + def test_corrupt_file_gives_defaults(self): + with open(self.path, "w") as f: + f.write("{not json") + self.assertEqual(setup.load_state(self.path), {"aim": 3, "move": 3, "details": {}}) + + def test_bad_values_are_dropped(self): + with open(self.path, "w") as f: + json.dump({"aim": 9, "move": "x", "details": {"fake": 50, "range": 99999, "bogus": 1}}, f) + self.assertEqual(setup.load_state(self.path), {"aim": 3, "move": 3, "details": {"fake": 50}}) + + def test_round_trip(self): + st = {"aim": 1, "move": 5, "details": {"fake": 60, "range_play": 0}} + setup.save_state(self.path, st) + self.assertEqual(setup.load_state(self.path), st) + + +class Parsing(unittest.TestCase): + def test_parse_int(self): + self.assertEqual(setup.parse_int("", 1, 5, 3), 3) + self.assertEqual(setup.parse_int(" 4 ", 1, 5, 3), 4) + self.assertIsNone(setup.parse_int("6", 1, 5, 3)) + self.assertIsNone(setup.parse_int("abc", 1, 5, 3)) + + def test_parse_yn(self): + self.assertTrue(setup.parse_yn("", True)) + self.assertTrue(setup.parse_yn("Y", False)) + self.assertFalse(setup.parse_yn("no", True)) + self.assertIsNone(setup.parse_yn("maybe", True)) + + def test_ask_reasks_until_valid(self): + said = [] + v = setup.ask("Aim: ", lambda t: setup.parse_int(t, 1, 5, 3), feeder("x", "7", "2"), said.append) + self.assertEqual(v, 2) + self.assertEqual(len(said), 2) + + def test_bot_present(self): + self.assertTrue(setup.bot_present(STATUS_WITH_BOT)) + self.assertFalse(setup.bot_present(STATUS_WITH_BOT.replace("lgbot ", "Hunter"))) + self.assertFalse(setup.bot_present("(no output)")) + + +class Commands(unittest.TestCase): + def test_no_details_resets_to_minus_one(self): + cmds = setup.build_commands({"aim": 2, "move": 4, "details": {}}, readd=False, bot_in=True) + self.assertEqual(cmds[0], "set lgbot_move 4") + self.assertIn("set lgbot_fake -1", cmds) + self.assertEqual(len(cmds), 1 + len(KEYS)) + + def test_details_and_readd(self): + cmds = setup.build_commands({"aim": 1, "move": 5, "details": {"fake": 50}}, readd=True, bot_in=True) + self.assertIn("set lgbot_fake 50", cmds) + self.assertEqual(cmds[-2:], ["kick lgbot", "addbot lgbot 1"]) + + def test_readd_without_bot_does_not_kick(self): + cmds = setup.build_commands({"aim": 4, "move": 3, "details": {}}, readd=True, bot_in=False) + self.assertNotIn("kick lgbot", cmds) + self.assertEqual(cmds[-1], "addbot lgbot 4") + + +class Prompts(unittest.TestCase): + def test_enter_keeps_everything(self): + old = {"aim": 2, "move": 4, "details": {"fake": 70}} + new = setup.prompt_setup(old, feeder("", "", "", "", "", "", "", "", ""), lambda *_: None) + self.assertEqual(new, old) + + def test_poor_aim_best_movement_custom(self): + new = setup.prompt_setup({"aim": 3, "move": 3, "details": {}}, + feeder("1", "5", "y", "", "300", "60", "", "n", "90"), lambda *_: None) + self.assertEqual(new, {"aim": 1, "move": 5, + "details": {"strafe_max": 300, "fake": 60, "range_play": 0, "speed": 90}}) + # only values that differ from the movement preset are stored as overrides + + def test_no_details_clears_them(self): + new = setup.prompt_setup({"aim": 3, "move": 3, "details": {"fake": 70}}, + feeder("", "", "n"), lambda *_: None) + self.assertEqual(new["details"], {}) + + def test_ctrl_c_propagates(self): + with self.assertRaises(KeyboardInterrupt): + setup.prompt_setup({"aim": 3, "move": 3, "details": {}}, feeder("1", KeyboardInterrupt()), lambda *_: None) + + if __name__ == "__main__": unittest.main()