400 lines
16 KiB
Python
400 lines
16 KiB
Python
import math, os, random, sys, unittest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "home", "minqlx-plugins"))
|
|
from lgbot import (PRESETS, KEYS, resolve, Strafer, wanted_range, steer, fix_z, pick_target, to_int, is_bot, is_lgbot, StuckGuard, approach, ACCEL)
|
|
|
|
|
|
def preset(n, **kw):
|
|
s = dict(PRESETS[n]); s.update(kw); return s
|
|
|
|
|
|
class Resolve(unittest.TestCase):
|
|
def test_preset_only(self):
|
|
self.assertEqual(resolve(3, {}), PRESETS[3])
|
|
|
|
def test_minus_one_and_none_keep_preset(self):
|
|
self.assertEqual(resolve(2, {k: -1 for k in KEYS}), PRESETS[2])
|
|
self.assertEqual(resolve(2, {"fake": None}), PRESETS[2])
|
|
|
|
def test_override_and_clamp(self):
|
|
s = resolve(1, {"fake": 50, "range": 5000, "speed": 10})
|
|
self.assertEqual((s["fake"], s["range"], s["speed"]), (50, 750, 50))
|
|
|
|
def test_bad_move_level(self):
|
|
self.assertEqual(resolve(9, {}), PRESETS[5])
|
|
self.assertEqual(resolve(0, {}), PRESETS[1])
|
|
self.assertEqual(resolve(None, {}), PRESETS[3])
|
|
|
|
def test_max_below_min(self):
|
|
s = resolve(3, {"strafe_min": 800, "strafe_max": 200})
|
|
self.assertEqual((s["strafe_min"], s["strafe_max"]), (800, 800))
|
|
|
|
|
|
class StraferTest(unittest.TestCase):
|
|
def changes(self, s, n=2000, seed=1):
|
|
st, now, out = Strafer(random.Random(seed)), 0, []
|
|
d = st.step(now, s)
|
|
while len(out) < n:
|
|
now += 5
|
|
nd = st.step(now, s)
|
|
if nd != d:
|
|
out.append((now, nd)); d = nd
|
|
return out
|
|
|
|
def test_holds_within_range_without_fakes(self):
|
|
s = preset(3, fake=0, strafe_min=250, strafe_max=600, lane=0)
|
|
ch = self.changes(s, 300)
|
|
gaps = [b[0] - a[0] for a, b in zip(ch, ch[1:])]
|
|
self.assertTrue(all(245 <= g <= 610 for g in gaps), (min(gaps), max(gaps)))
|
|
|
|
def test_fake_taps_are_short_and_return(self):
|
|
s = preset(5, fake=100, strafe_min=300, strafe_max=300, lane=0)
|
|
ch = self.changes(s, 200)
|
|
gaps = [b[0] - a[0] for a, b in zip(ch, ch[1:])]
|
|
# always faking: alternating short tap (60-120) and full hold (300), direction never really changes
|
|
short = [g for g in gaps if g <= 125]
|
|
self.assertTrue(len(short) >= 90)
|
|
self.assertTrue(all(60 <= g <= 125 for g in short))
|
|
|
|
def test_fake_fraction_matches_setting(self):
|
|
s = preset(3, fake=30, strafe_min=300, strafe_max=300, lane=0)
|
|
st, now, fakes, decisions = Strafer(random.Random(7)), 0, 0, 0
|
|
st.step(0, s)
|
|
while decisions < 2000:
|
|
now += 5
|
|
was = st.faking
|
|
before = st.until
|
|
st.step(now, s)
|
|
if st.until != before and not was:
|
|
decisions += 1; fakes += st.faking
|
|
self.assertAlmostEqual(fakes / decisions, 0.30, delta=0.04)
|
|
|
|
|
|
def test_lane_stops_circling(self):
|
|
# always faking = always drifting the same way; the lane must turn it back
|
|
s = preset(5, fake=100, strafe_min=300, strafe_max=300, lane=300, speed=100)
|
|
lane_ms = 300 / 320.0 * 1000
|
|
st, now, drift = Strafer(random.Random(3)), 0, 0
|
|
d = st.step(0, s)
|
|
for _ in range(20000):
|
|
now += 25
|
|
drift += d * 25
|
|
self.assertLessEqual(abs(drift), lane_ms + 50)
|
|
d = st.step(now, s)
|
|
|
|
|
|
def test_gap_does_not_count_as_drift(self):
|
|
# bot dead / out of range for 10 s: afterwards it must still strafe both ways (C1)
|
|
s = preset(5)
|
|
st, now = Strafer(random.Random(5)), 0
|
|
for _ in range(80):
|
|
now += 25; st.step(now, s)
|
|
now += 10000
|
|
dirs = []
|
|
for _ in range(400):
|
|
now += 25; dirs.append(st.step(now, s))
|
|
self.assertLess(max(dirs.count(1), dirs.count(-1)) / 400.0, 0.75)
|
|
|
|
def test_reset_clears_drift(self):
|
|
st = Strafer(random.Random(1))
|
|
st.drift, st.last = 5000, 123
|
|
st.reset()
|
|
self.assertEqual((st.drift, st.last), (0, None))
|
|
|
|
|
|
class Accel(unittest.TestCase):
|
|
def test_limits_change_per_frame(self):
|
|
v = approach((320, 0), (-320, 0), ACCEL * 0.025)
|
|
self.assertAlmostEqual(v[0], 320 - ACCEL * 0.025, places=6)
|
|
|
|
def test_reaches_target_when_close(self):
|
|
self.assertEqual(approach((300, 0), (320, 0), 80), (320, 0))
|
|
|
|
def test_reversal_takes_human_time(self):
|
|
v, frames = (320.0, 0.0), 0
|
|
while v[0] > -319:
|
|
v = approach(v, (-320, 0), ACCEL * 0.025); frames += 1
|
|
self.assertTrue(6 <= frames <= 10, frames) # ~150-250 ms, not one frame
|
|
|
|
|
|
class Range(unittest.TestCase):
|
|
def test_plain(self):
|
|
self.assertEqual(wanted_range(preset(1), 100, 100), 450)
|
|
|
|
def test_back_off_when_low(self):
|
|
self.assertEqual(wanted_range(preset(5), 50, 20), 700)
|
|
|
|
def test_push_when_ahead(self):
|
|
self.assertEqual(wanted_range(preset(5), 150, 100), 250)
|
|
|
|
def test_even_fight(self):
|
|
self.assertEqual(wanted_range(preset(5), 100, 90), 350)
|
|
|
|
def test_range_play_off(self):
|
|
self.assertEqual(wanted_range(preset(5, range_play=0), 50, 20), 350)
|
|
|
|
|
|
class Steer(unittest.TestCase):
|
|
def test_perpendicular_at_wanted_range(self):
|
|
vx, vy = steer((0, 0), (400, 0), 1, preset(3), 400)
|
|
self.assertAlmostEqual(vx, 0, places=3)
|
|
self.assertAlmostEqual(abs(vy), 320, places=3)
|
|
|
|
def test_direction_flips_side(self):
|
|
a = steer((0, 0), (400, 0), 1, preset(3), 400)
|
|
b = steer((0, 0), (400, 0), -1, preset(3), 400)
|
|
self.assertAlmostEqual(a[1], -b[1], places=3)
|
|
|
|
def test_speed_cap(self):
|
|
for d in (50, 400, 1200):
|
|
vx, vy = steer((0, 0), (d, 0), 1, preset(1), 450)
|
|
self.assertLessEqual(math.hypot(vx, vy), 320 * 0.8 + 1e-6)
|
|
|
|
def test_closes_in_when_far(self):
|
|
vx, _ = steer((0, 0), (1000, 0), 1, preset(3), 400)
|
|
self.assertGreater(vx, 100)
|
|
|
|
def test_backs_off_when_close(self):
|
|
vx, _ = steer((0, 0), (100, 0), 1, preset(3), 400)
|
|
self.assertLess(vx, -100)
|
|
|
|
def test_same_spot(self):
|
|
vx, vy = steer((5, 5), (5, 5), 1, preset(3), 400)
|
|
self.assertLessEqual(math.hypot(vx, vy), 320 + 1e-6)
|
|
|
|
|
|
class Misc(unittest.TestCase):
|
|
def test_fix_z(self):
|
|
self.assertEqual(fix_z(275), 0)
|
|
self.assertEqual(fix_z(-100), -100)
|
|
self.assertEqual(fix_z(40), 40) # slopes/stairs
|
|
self.assertEqual(fix_z(800), 800) # jump pads
|
|
|
|
def test_is_bot(self):
|
|
self.assertTrue(is_bot(90071996842377216)) # minqlx bot ids: base + client number
|
|
self.assertTrue(is_bot(90071996842377216 + 5))
|
|
self.assertTrue(is_bot(0))
|
|
self.assertFalse(is_bot(76561198000000000)) # real SteamID64
|
|
|
|
def test_is_lgbot_needs_bot_id(self):
|
|
self.assertTrue(is_lgbot("lgbot", 90071996842377216))
|
|
self.assertTrue(is_lgbot("LGBot", 90071996842377217))
|
|
self.assertFalse(is_lgbot("lgbot", 76561198000000000)) # a human calling themselves lgbot
|
|
self.assertFalse(is_lgbot("hunter", 90071996842377216))
|
|
|
|
def test_to_int(self):
|
|
self.assertEqual(to_int("5"), 5)
|
|
self.assertEqual(to_int(" -1 "), -1)
|
|
self.assertEqual(to_int("2.7"), 2)
|
|
self.assertIsNone(to_int("abc"))
|
|
self.assertIsNone(to_int(None))
|
|
|
|
def test_pick_target(self):
|
|
c = [((300, 0, 0), "near"), ((800, 0, 0), "far")]
|
|
self.assertEqual(pick_target((0, 0, 0), c), "near")
|
|
self.assertIsNone(pick_target((0, 0, 0), [((950, 0, 0), "x")])) # too far to start steering
|
|
self.assertIsNone(pick_target((0, 0, 0), []))
|
|
|
|
def test_pick_target_ignores_other_floors(self):
|
|
c = [((200, 0, 300), "above"), ((600, 0, 40), "same floor")]
|
|
self.assertEqual(pick_target((0, 0, 0), c), "same floor")
|
|
self.assertIsNone(pick_target((0, 0, 0), [((200, 0, -300), "below")]))
|
|
|
|
def test_keeps_current_target_a_bit_past_the_start_limit(self):
|
|
self.assertEqual(pick_target((0, 0, 0), [((950, 0, 0), "a")], current="a"), "a") # hysteresis
|
|
self.assertIsNone(pick_target((0, 0, 0), [((1050, 0, 0), "a")], current="a"))
|
|
|
|
def test_sticky_target(self):
|
|
c = [((500, 0, 0), "a"), ((420, 0, 0), "b")]
|
|
self.assertEqual(pick_target((0, 0, 0), c, current="a"), "a") # b only 80 closer: stay
|
|
c = [((500, 0, 0), "a"), ((300, 0, 0), "b")]
|
|
self.assertEqual(pick_target((0, 0, 0), c, current="a"), "b") # clearly closer: switch
|
|
self.assertEqual(pick_target((0, 0, 0), [((400, 0, 0), "b")], current="a"), "b") # a gone
|
|
|
|
|
|
class Stuck(unittest.TestCase):
|
|
def test_steers_while_moving(self):
|
|
g = StuckGuard()
|
|
self.assertTrue(all(g.update(t, 320, 300) for t in range(0, 3000, 25)))
|
|
|
|
def test_lets_go_when_blocked_then_retries(self):
|
|
g = StuckGuard()
|
|
results = [(t, g.update(t, 320, 50)) for t in range(0, 4000, 25)]
|
|
first_off = next(t for t, ok in results if not ok)
|
|
self.assertTrue(450 <= first_off <= 550)
|
|
back_on = next(t for t, ok in results if t > first_off and ok)
|
|
self.assertTrue(1900 <= back_on - first_off + 500 <= 2100) # off for 1.5 s
|
|
|
|
def test_backs_off_longer_when_it_keeps_failing(self):
|
|
g = StuckGuard()
|
|
offs, prev = [], True
|
|
for t in range(0, 20000, 25):
|
|
ok = g.update(t, 320, 50)
|
|
if prev and not ok:
|
|
offs.append(t)
|
|
prev = ok
|
|
gaps = [b - a for a, b in zip(offs, offs[1:])]
|
|
self.assertGreater(gaps[1], gaps[0]) # 1.5 s, 3 s, 6 s ... between retries
|
|
self.assertLessEqual(max(gaps), 6000 + 525)
|
|
|
|
def test_backoff_resets_after_clean_steering(self):
|
|
g = StuckGuard()
|
|
for t in range(0, 4000, 25):
|
|
g.update(t, 320, 50) # two releases: streak up
|
|
for t in range(4000, 12000, 25):
|
|
g.update(t, 320, 300) # clean for a while
|
|
res = [(t, g.update(t, 320, 50)) for t in range(12000, 16000, 25)]
|
|
off = next(t for t, ok in res if not ok)
|
|
back = next(t for t, ok in res if t > off and ok)
|
|
self.assertEqual(back - off, 1500) # back to a 1.5 s release
|
|
|
|
def test_short_bump_is_fine(self):
|
|
g = StuckGuard()
|
|
ok = [g.update(t, 320, 50 if 1000 <= t < 1300 else 300) for t in range(0, 3000, 25)]
|
|
self.assertTrue(all(ok))
|
|
|
|
|
|
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_human_named_lgbot_is_not_the_bot(self):
|
|
status = STATUS_WITH_BOT.replace("lgbot 0 bot ", "lgbot 0 1.2.3.4:5")
|
|
self.assertFalse(setup.bot_present(status))
|
|
self.assertTrue(setup.impostor(status))
|
|
self.assertFalse(setup.impostor(STATUS_WITH_BOT))
|
|
|
|
def test_cvar_value(self):
|
|
reply = 'zmq RCON command from x: lgbot_fake' + chr(10) + '"lgbot_fake" is:"60" default:"-1"'
|
|
self.assertEqual(setup.cvar_value(reply, "lgbot_fake"), "60")
|
|
self.assertIsNone(setup.cvar_value("(no output: ok, or server down)", "lgbot_fake"))
|
|
|
|
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 Main(unittest.TestCase):
|
|
def test_refused_run_keeps_saved_setup(self):
|
|
path = os.path.join(tempfile.mkdtemp(), "lgbot.json")
|
|
impostor_status = STATUS_WITH_BOT.replace("lgbot 0 bot ", "lgbot 0 1.2.3.4:5")
|
|
sent = []
|
|
def run(cmd):
|
|
sent.append(cmd)
|
|
return impostor_status if cmd == "status" else ""
|
|
old_state, old_server = setup.STATE, setup.server
|
|
setup.STATE, setup.server = path, (lambda: run)
|
|
try:
|
|
rc = setup.main(["lgbot"], feeder("1", "5", "n"))
|
|
finally:
|
|
setup.STATE, setup.server = old_state, old_server
|
|
self.assertNotEqual(rc, 0)
|
|
self.assertFalse(os.path.exists(path))
|
|
self.assertEqual(sent, ["status"]) # no kick, no set
|
|
|
|
|
|
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", "250"), lambda *_: None)
|
|
self.assertEqual(new, {"aim": 1, "move": 5,
|
|
"details": {"strafe_max": 300, "fake": 60, "range_play": 0, "speed": 90, "lane": 250}})
|
|
# 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()
|