lgbot: interactive setup script
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
062ea6468a
commit
fda3a795bb
3 files changed
+282
No files matched your search
@@ -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 "$@"
|
||||||
@@ -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))
|
||||||
@@ -137,5 +137,116 @@ class Misc(unittest.TestCase):
|
|||||||
self.assertIsNone(pick_target((0, 0), []))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user