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:
1 parent
a998614224
commit
86754715f5
6 files changed
+321
-30
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user