lgbot: review fixes: drift reset after pauses, player-like acceleration, sticky targets with hysteresis, wall backoff; plugin glue tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-15 15:16:53 +03:00
1 parent a998614224
commit 86754715f5
6 files changed
+320 -29

No files matched your search

+2 -2
View File
@@ -56,11 +56,11 @@ Movement levels (the details override one part, `-1` = use the level's value):
| 4 | 180–450 | 35% | 380 | yes | 100% | 350 |
| 5 | 120–350 | 50% | 350 | yes | 100% | 300 |
Range is the distance it tries to keep (LG reaches 768). Lane is how far it may drift sideways before it turns back, so it does ADAD in front of you instead of circling (0 = no limit). With push in / back off off, it always holds its range.
Range is the distance it tries to keep (LG reaches 768). Lane is roughly how far it may drift sideways before it turns back, so it does ADAD in front of you instead of circling (0 = no limit; while it's also closing in or backing off, the real drift can be up to ~30% more). With push in / back off off, it always holds its range.
Underneath it's plain console settings, if you'd rather: `./rcon addbot lgbot <aim>`, `./rcon set lgbot_move <1-5>`, and `lgbot_strafe_min`, `lgbot_strafe_max`, `lgbot_fake`, `lgbot_range`, `lgbot_range_play`, `lgbot_speed`, `lgbot_lane`. The plugin rereads them every second. `set lgbot_debug 1` writes one line a second about its movement to `home/minqlx.log`.
The bot fights the nearest human. Its own movement only takes over when you're within LG reach (900 units) on about the same floor; farther away, the normal bot AI walks it over to you. If a wall blocks its strafing for half a second, it lets the AI walk around for 1.5 s. One lgbot at a time.
The bot fights the nearest human and sticks with them unless someone else is clearly closer. Its own movement takes over when you're within 900 units (a bit beyond LG reach) on about the same floor, and keeps going until you're past 1000. Farther away, the normal bot AI walks it over to you. If a wall blocks its strafing for half a second, it lets the AI walk around for 1.5 s, then longer (up to 6 s) if it keeps getting stuck. It speeds up and turns like a player (a full left-right reversal takes about 0.2 s), not instantly. One lgbot at a time.
## Admins and bans: `home/baseq3/access.txt`
+1 -1
View File
@@ -23,7 +23,7 @@ In warmup the client draws "The match will begin when more players join" / "Pres
Quake 3 bots take their personality from a character file. `home/baseq3/botfiles/bots/lgbot_c.c` (registered by `scripts/lgbot.bot`) sets aim accuracy, aim skill, reaction time and view speed per skill level, sets jumping, crouching and rocket jumping to 0, and weights the LG above everything else. QL reads skill blocks 1, 4 and 5 and interpolates 2 and 3 between 1 and 4, which is why the file has only three. The server loads these loose files from `fs_homepath` alongside the ones in `pak00.pk3`, so no pk3 is needed (bots run on the server only, so clients don't need the files).
The stock bot AI moves like a bot: it runs at you, hops around and goes for items. So the movement is overridden. The `lgbot` minqlx plugin sets the bot's horizontal velocity on every server frame: sideways to its target in the current strafe direction, plus an in/out part that holds the wanted distance (capped at 70% of the speed so it still strafes while closing in). The strafe direction comes from a small pattern generator: hold one side for a random time between the min and max, then switch. Sometimes it fakes instead: a 60–120 ms tap the other way, then a new hold on the same side. Strafing sideways to someone moves you around them, and fakes keep you going the same way, so the bot would slowly circle you. The pattern therefore tracks its net sideways drift and turns back once it is `lane` units off centre. Ground friction removes 15–25% of the set speed before the move happens, so on the ground it sets 1.2× (measured at sv_fps 40: the bot then moves at about the set speed, never faster than a player can). An upward speed of 200–320 (a jump; QL jumps at ~275) is cancelled. Falling, slopes and jump pads are left alone. The AI still aims and shoots, so aim and movement don't interfere. The steering is straight-line, with no pathfinding. So it only runs when the target is within 900 units horizontally and 200 vertically. Farther away, the AI's own navigation walks the bot over. If the speed it makes along the steered direction stays under 60% for half a second (a wall, a ledge), steering pauses for 1.5 s so the AI can get around it. Without this, on thunderstruck it pushed against a wall toward a target on another level for 12 s. Measured on a test server, the plugin costs no extra CPU you could measure.
The stock bot AI moves like a bot: it runs at you, hops around and goes for items. So the movement is overridden. The `lgbot` minqlx plugin sets the bot's horizontal velocity on every server frame: sideways to its target in the current strafe direction, plus an in/out part that holds the wanted distance (capped at 70% of the speed so it still strafes while closing in). The strafe direction comes from a small pattern generator: hold one side for a random time between the min and max, then switch. Sometimes it fakes instead: a 60–120 ms tap the other way, then a new hold on the same side. Strafing sideways to someone moves you around them, and fakes keep you going the same way, so the bot would slowly circle you. The pattern therefore tracks its net sideways drift and turns back once it is `lane` units off centre. Ground friction removes 15–25% of the set speed before the move happens, so on the ground it sets 1.2× (measured at sv_fps 40: the bot then moves at about the set speed. On ramps no boost is applied, so it's ~15% slower there). The velocity also changes at most 3200 u/s per second (QL's ground acceleration of 10 × 320). So a left-right reversal takes ~0.2 s like a player's, and a fake is a short wobble, not an instant flip. Its top speed and its turning are both what a player can do. An upward speed of 200–320 (a jump; QL jumps at ~275) is cancelled. Falling, slopes and jump pads are left alone. The AI still aims and shoots, so aim and movement don't interfere. The steering is straight-line, with no pathfinding. So it only starts when the target is within 900 units horizontally and 200 vertically, and keeps the same target out to 1000 / 250 so it doesn't flip modes at the edge. It switches targets only to someone 150 units closer. Farther away, the AI's own navigation walks the bot over. If the speed it makes along the steered direction stays under 60% for half a second (a wall, a ledge), steering pauses for 1.5 s so the AI can get around it, doubling to at most 6 s while that keeps happening. Whenever it stops steering (dead, nobody near, paused), the strafe pattern starts fresh. Otherwise the pause would count as sideways drift and the bot would strafe one way only after every respawn (found in review). Without this, on thunderstruck it pushed against a wall toward a target on another level for 12 s. Measured on a test server, the plugin costs no extra CPU you could measure.
minqlx gives bots Steam IDs from 90071996842377216 up. The plugin uses that to pick human targets only.
+71 -21
View File
@@ -14,6 +14,9 @@ RUN_SPEED = 320 # QL ground run speed, units/s
# ground we set this much more to end up at about the wanted speed.
# In the air there is no friction, so no boost (vertical speed 0 = on the ground, near enough).
FRICTION_COMP = 1.2
# How fast the bot's velocity may change, units/s per second: QL ground acceleration (10) x run
# speed, so a full left-right reversal takes ~200 ms like a player, not one frame.
ACCEL = 3200
KEYS = ["strafe_min", "strafe_max", "fake", "range", "range_play", "speed", "lane"]
LIMITS = {"strafe_min": (50, 2000), "strafe_max": (50, 2000), "fake": (0, 100),
@@ -27,10 +30,15 @@ PRESETS = {
}
PUSH_RANGE, BACKOFF_RANGE = 250, 700
BACKOFF_HEALTH, PUSH_LEAD = 60, 30
# Steering is straight-line (no pathfinding), so only steer when the target is within LG reach and
# on about the same floor; otherwise the bot's own navigation walks it over.
# Steering is straight-line (no pathfinding), so only start steering when the target is at most a
# bit beyond LG reach (768) and on about the same floor; otherwise the bot's own navigation walks it
# over. Once steering, keep the target up to the STOP limits so it doesn't flip at the edge, and
# only switch to another player who is clearly closer.
MAX_TARGET_DIST = 900
MAX_TARGET_DZ = 200
STOP_TARGET_DIST = 1000
STOP_TARGET_DZ = 250
SWITCH_MARGIN = 150
BOT_STEAM_ID = 90071996842377216 # minqlx gives bots this + their client number
@@ -81,9 +89,14 @@ class Strafer:
self.drift = 0 # signed ms of strafing, + = direction 1
self.last = None
def reset(self):
"""Not steering (dead, no target, walking): drift so far means nothing for the next fight."""
self.drift = 0
self.last = None
def step(self, now_ms, s):
if self.last is not None:
self.drift += self.direction * (now_ms - self.last)
self.drift += self.direction * min(now_ms - self.last, 100) # a pause is not drift
self.last = now_ms
if s["lane"]:
lane_ms = s["lane"] * 1000.0 / (RUN_SPEED * s["speed"] / 100.0)
@@ -132,40 +145,65 @@ def fix_z(vz):
return 0.0 if 200 <= vz <= 320 else vz
def pick_target(bot_pos, candidates, max_dist=MAX_TARGET_DIST, max_dz=MAX_TARGET_DZ):
"""Nearest candidate (pos = x, y, z) within max_dist horizontally and max_dz vertically."""
best, best_d = None, max_dist
for pos, obj in candidates:
if abs(pos[2] - bot_pos[2]) > max_dz:
continue
def approach(cur, want, max_delta):
"""Move velocity `cur` toward `want` by at most `max_delta` (acceleration limit)."""
dx, dy = want[0] - cur[0], want[1] - cur[1]
d = math.hypot(dx, dy)
if d <= max_delta:
return (want[0], want[1])
return (cur[0] + dx / d * max_delta, cur[1] + dy / d * max_delta)
def pick_target(bot_pos, candidates, current=None):
"""Key of the player to fight, from candidates [((x, y, z), key)]. A new target must be within
MAX_TARGET_DIST / MAX_TARGET_DZ; the current one is kept up to the STOP limits unless another
is SWITCH_MARGIN closer. None = nobody (the bot AI walks)."""
near, cur_d = [], None
for pos, key in candidates:
dz = abs(pos[2] - bot_pos[2])
d = math.hypot(pos[0] - bot_pos[0], pos[1] - bot_pos[1])
if d <= best_d:
best, best_d = obj, d
return best
if key == current and d <= STOP_TARGET_DIST and dz <= STOP_TARGET_DZ:
cur_d = d
if d <= MAX_TARGET_DIST and dz <= MAX_TARGET_DZ:
near.append((d, key))
best = min(near, key=lambda t: t[0]) if near else None
if cur_d is not None and (best is None or best[0] > cur_d - SWITCH_MARGIN):
return current
return best[1] if best else None
class StuckGuard:
"""Lets go of the bot when steering keeps getting blocked (a wall, a ledge): if the speed it
makes along the commanded direction stays under `ratio` of the command for `after_ms`, steering
is off for `release_ms` so the bot AI can walk around the obstacle."""
is off for `release_ms` so the bot AI can walk around the obstacle. If it keeps failing, each
release is twice as long (up to `max_release_ms`); `clean_ms` of good steering resets that."""
def __init__(self, ratio=0.6, after_ms=500, release_ms=1500):
self.ratio, self.after_ms, self.release_ms = ratio, after_ms, release_ms
def __init__(self, ratio=0.6, after_ms=500, release_ms=1500, max_release_ms=6000, clean_ms=3000):
self.ratio, self.after_ms = ratio, after_ms
self.release_ms, self.max_release_ms, self.clean_ms = release_ms, max_release_ms, clean_ms
self.slow_since = None
self.off_until = 0
self.streak = 0
self.clean_since = None
def update(self, now_ms, cmd_speed, along_speed):
if now_ms < self.off_until:
return False
if cmd_speed > 1 and along_speed < cmd_speed * self.ratio:
self.clean_since = None
if self.slow_since is None:
self.slow_since = now_ms
elif now_ms - self.slow_since >= self.after_ms:
self.slow_since = None
self.off_until = now_ms + self.release_ms
self.off_until = now_ms + min(self.max_release_ms, self.release_ms * 2 ** self.streak)
self.streak += 1
return False
else:
self.slow_since = None
if self.clean_since is None:
self.clean_since = now_ms
elif now_ms - self.clean_since >= self.clean_ms:
self.streak = 0
return True
@@ -188,7 +226,8 @@ if minqlx:
self.debug = self.target_bots = False
self.failed = False
self.guard = StuckGuard()
self.last_cmd = None # last command (for the stuck guard)
self.last_cmd = None # last command (for the stuck guard and the acceleration limit)
self.target_id, self.last_tick = None, None
self.dbg_cmd, self.slow_frames, self.stuck = None, 0, 0 # debug stats
self.add_hook("frame", self.handle_frame)
@@ -210,7 +249,10 @@ if minqlx:
return self.settings
def idle(self, now, why):
self.last_cmd = None # not steering this frame: nothing for the stuck guard to judge
# not steering this frame: nothing for the stuck guard to judge, and the strafe pattern
# starts fresh next time (otherwise the pause counts as sideways drift)
self.last_cmd = self.dbg_cmd = self.target_id = None
self.strafer.reset()
if self.debug and now - self.log_at >= 1.0:
self.log_at = now
self.logger.info("lgbot idle: %s", why)
@@ -231,11 +273,15 @@ if minqlx:
if is_bot(p.steam_id) and not self.target_bots:
continue
pos = p.position()
cands.append(((pos.x, pos.y, pos.z), p))
cands.append(((pos.x, pos.y, pos.z), p.id))
bpos = bot.position()
target = pick_target((bpos.x, bpos.y, bpos.z), cands)
if target is None: # nobody to duel close by: the bot AI walks over
tid = pick_target((bpos.x, bpos.y, bpos.z), cands, self.target_id)
if tid is None: # nobody to duel close by: the bot AI walks over
return self.idle(now, "no target within %d on this floor (%d candidates)" % (MAX_TARGET_DIST, len(cands)))
if tid != self.target_id:
self.strafer.reset() # drift was measured around the previous target
self.target_id = tid
target = next(p for p in players if p.id == tid)
vel = bot.velocity()
cmd_speed, along = 0.0, 0.0
if self.last_cmd:
@@ -248,6 +294,10 @@ if minqlx:
direction = self.strafer.step(int(now * 1000), s)
want = wanted_range(s, bot.health, target.health)
vx, vy = steer((bpos.x, bpos.y), (tpos.x, tpos.y), direction, s, want)
dt = min(0.1, now - self.last_tick) if self.last_tick else 0.025
self.last_tick = now
start = self.last_cmd if self.last_cmd else (vel.x, vel.y)
vx, vy = approach(start, (vx, vy), ACCEL * dt) # turn like a player, not in one frame
comp = FRICTION_COMP if abs(vel.z) < 1 else 1.0
bot.velocity(x=vx * comp, y=vy * comp, z=fix_z(vel.z))
self.last_cmd = (vx, vy)
+8 -3
View File
@@ -147,11 +147,14 @@ def refuse_impostor(status_text):
return False
REFUSED = 3
def apply(st, old_aim):
run = server()
status = run("status")
if refuse_impostor(status):
return 1
return REFUSED
bot_in = bot_present(status)
for cmd in build_commands(st, readd=(not bot_in or st["aim"] != old_aim), bot_in=bot_in):
run(cmd)
@@ -162,7 +165,7 @@ def apply(st, old_aim):
def main(argv, input_fn=input):
arg = argv[1] if len(argv) > 1 else ""
st = load_state()
st = load_state(STATE)
if arg == "off":
run = server()
if refuse_impostor(run("status")):
@@ -187,8 +190,10 @@ def main(argv, input_fn=input):
except (EOFError, KeyboardInterrupt):
print("\ncancelled, nothing changed")
return 1
rc = apply(new, old_aim=st["aim"])
if rc != REFUSED: # saved even if the bot didn't show up (server restarting): --last retries
save_state(STATE, new)
return apply(new, old_aim=st["aim"])
return rc
if __name__ == "__main__":
+89 -2
View File
@@ -1,7 +1,7 @@
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)
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):
@@ -83,6 +83,40 @@ class StraferTest(unittest.TestCase):
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)
@@ -158,7 +192,7 @@ class Misc(unittest.TestCase):
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), [((1000, 0, 0), "x")])) # beyond LG reach: AI walks there
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):
@@ -166,6 +200,17 @@ class Misc(unittest.TestCase):
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):
@@ -180,6 +225,29 @@ class Stuck(unittest.TestCase):
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)]
@@ -267,6 +335,25 @@ class Parsing(unittest.TestCase):
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)
+149
View File
@@ -0,0 +1,149 @@
"""The lgbot plugin's frame logic (tick) against a fake minqlx: deaths, gaps, targets, acceleration."""
import importlib.util, logging, math, os, sys, types, unittest
PLUGIN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "home", "minqlx-plugins", "lgbot.py")
BOT_ID = 90071996842377216
HUMAN_ID = 76561198000000000
class V:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
class FakePlayer:
def __init__(self, cid, name, steam_id, pos):
self.id, self.clean_name, self.steam_id = cid, name, steam_id
self.team, self.is_alive, self.health = "free", True, 100
self.pos, self.vel = list(pos), [0.0, 0.0, 0.0]
self.sets = []
def position(self):
return V(*self.pos)
def velocity(self, **kw):
if kw:
self.vel = [kw.get("x", self.vel[0]), kw.get("y", self.vel[1]), kw.get("z", self.vel[2])]
self.sets.append(tuple(self.vel))
return True
return V(*self.vel)
class World:
"""Stands in for the server: players, cvars, a clock; moves players by their velocity."""
def __init__(self):
self.players, self.cvars, self.now = [], {}, 1000.0
def frame(self, plugin, dt=0.025):
self.now += dt
plugin.handle_frame()
for p in self.players:
p.pos[0] += p.vel[0] * 0.85 * dt # friction eats ~15%, like the measured server
p.pos[1] += p.vel[1] * 0.85 * dt
p.vel[0] *= 0.85
p.vel[1] *= 0.85
def load(world):
fake = types.ModuleType("minqlx")
class Plugin:
def __init__(self):
self.logger = logging.getLogger("lgbot-test")
def set_cvar_once(self, name, value):
world.cvars.setdefault(name, value)
def get_cvar(self, name):
return world.cvars.get(name)
def add_hook(self, event, fn):
pass
def players(self):
return list(world.players)
def log_exception(plugin=None):
raise # surface plugin errors in the test
fake.Plugin, fake.log_exception = Plugin, log_exception
sys.modules["minqlx"] = fake
try:
spec = importlib.util.spec_from_file_location("lgbot_under_test", PLUGIN)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
finally:
del sys.modules["minqlx"]
mod.time = types.SimpleNamespace(time=lambda: world.now)
plugin = mod.lgbot()
plugin.strafer.rng.seed(11) if hasattr(plugin.strafer.rng, "seed") else None
return mod, plugin
def sideways_sign(bot, human):
# sign of the bot's velocity across the bot->human line
dx, dy = human.pos[0] - bot.pos[0], human.pos[1] - bot.pos[1]
return 1 if (-dy * bot.vel[0] + dx * bot.vel[1]) > 0 else -1
class Tick(unittest.TestCase):
def setUp(self):
self.w = World()
self.bot = FakePlayer(0, "lgbot", BOT_ID, (0, 0, 0))
self.human = FakePlayer(1, "player", HUMAN_ID, (450, 0, 0))
self.w.players = [self.bot, self.human]
self.w.cvars["lgbot_move"] = "5"
self.mod, self.plugin = load(self.w)
def run_frames(self, n):
for _ in range(n):
self.w.frame(self.plugin)
def test_steers_when_human_in_range(self):
self.run_frames(40)
self.assertTrue(self.bot.sets)
def test_human_named_lgbot_is_left_alone(self):
self.bot.steam_id = HUMAN_ID
self.run_frames(40)
self.assertEqual(self.bot.sets, [])
def test_strafes_both_ways_after_respawn(self):
self.run_frames(80)
self.bot.is_alive = False
self.run_frames(400) # 10 s dead
self.bot.is_alive = True
self.bot.pos = [0, 0, 0]
signs = []
for _ in range(400):
self.w.frame(self.plugin)
signs.append(sideways_sign(self.bot, self.human))
self.assertLess(max(signs.count(1), signs.count(-1)) / 400.0, 0.75)
def test_strafes_both_ways_after_walking_in(self):
self.human.pos = [3000, 0, 0] # far: AI walks
self.run_frames(400)
self.human.pos = [450, 0, 0]
signs = []
for _ in range(400):
self.w.frame(self.plugin)
signs.append(sideways_sign(self.bot, self.human))
self.assertLess(max(signs.count(1), signs.count(-1)) / 400.0, 0.75)
def test_velocity_changes_like_a_player_not_instantly(self):
self.run_frames(400)
sets = self.bot.sets
comp = self.mod.FRICTION_COMP
biggest = max(math.hypot(b[0] - a[0] * 0.85, b[1] - a[1] * 0.85) for a, b in zip(sets, sets[1:]))
# per frame: at most the acceleration step (plus friction compensation), never a 640 u/s flip
self.assertLess(biggest, self.mod.ACCEL * 0.025 * comp + 0.15 * 320 * comp + 1)
def test_no_target_means_no_steering(self):
self.human.team = "spectator"
self.run_frames(40)
self.assertEqual(self.bot.sets, [])
if __name__ == "__main__":
unittest.main()