From 4bdc5b90db892b508597a7b5f7d4383defa9b5ce Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 00:18:39 +0200 Subject: [PATCH] fix(input): an unbound skill key is a no-op, not a thread-killing ValueError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from the previous commit. Blanking vigor/holy_shield/cleansing stopped the stray F4 presses, but exposed that paladin.cast_buffs sends the holy_shield key with no check: keyboard.send(self._skill_hotkeys["holy_shield"]) # -> ValueError: Unknown key: which killed the bot thread mid-run: Uncaught exception in thread Thread-8 (start) ... File "src/char/paladin/paladin.py", line 33, in cast_buffs ValueError: Unknown key: There are 90+ `keyboard.send(self._skill_hotkeys[x])` call sites across the paladin classes and almost none check first, so the guard belongs at the boundary: an empty or None key logs at debug and returns. A genuinely unknown key still raises — the guard is for unbound optional skills, not for typos. cast_buffs additionally returns early when holy_shield is unbound: send() now tolerates the empty key, but the right-click after it would still fire and cast whatever sits on the right slot instead. Co-Authored-By: Claude Opus 5 --- src/char/paladin/paladin.py | 5 +++++ src/input_layer/__init__.py | 11 +++++++++++ test/test_stealth_config.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/char/paladin/paladin.py b/src/char/paladin/paladin.py index 5d352e0..bd4e1a3 100644 --- a/src/char/paladin/paladin.py +++ b/src/char/paladin/paladin.py @@ -30,6 +30,11 @@ class Paladin(IChar): self._action_frame = 6 def cast_buffs(self, casting_delay: float): + # Skip entirely when Holy Shield is unbound. keyboard.send() tolerates + # the empty key now, but the right-click after it would still fire and + # cast whatever happens to be on the right slot instead. + if not self._skill_hotkeys.get("holy_shield"): + return keyboard.send(self._skill_hotkeys["holy_shield"]) wait(0.04, 0.1) mouse.click(button="right") diff --git a/src/input_layer/__init__.py b/src/input_layer/__init__.py index 15685c3..bb6ccfc 100644 --- a/src/input_layer/__init__.py +++ b/src/input_layer/__init__.py @@ -12,6 +12,7 @@ On non-Windows (Docker/Linux), uses bridge_input to talk to a Windows host via TCP. Set BOTTY_BRIDGE_HOST / BOTTY_BRIDGE_PORT env vars. """ import re as _re +from logger import Logger import os as _os import threading as _threading @@ -192,9 +193,19 @@ class _Keyboard: """ Send a key press event with stealth timing. + An EMPTY key is a no-op, not an error. An unbound optional skill is a + legitimate configuration — a FoHdin with no Holy Shield, no Vigor, no + Cleansing — and there are 90+ `keyboard.send(self._skill_hotkeys[x])` + call sites across the paladin classes alone, almost none of which check + first. Raising here kills the whole bot thread for a skill the + character was never meant to cast. Guard once, at the boundary. + Supports combo keys like 'shift + a', 'ctrl + alt + del'. Supports do_release=False (hold key) and do_press=False (release-only). """ + if key is None or str(key).strip() == "": + Logger.debug("keyboard.send: empty key (unbound skill) — skipping") + return self._stealth_before() # Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del') diff --git a/test/test_stealth_config.py b/test/test_stealth_config.py index 6a4e272..97fb021 100644 --- a/test/test_stealth_config.py +++ b/test/test_stealth_config.py @@ -191,3 +191,40 @@ def test_session_bias_is_constant_and_bounded(): first = get_session_bias() assert 0.90 <= first <= 1.15 assert all(get_session_bias() == first for _ in range(20)) + + +# ─── Unbound optional skills must not kill the bot thread ──────────────────── + +def test_unbound_skill_key_is_a_noop_not_an_exception(): + """A FoHdin has no Holy Shield, Vigor or Cleansing. + + paladin.cast_buffs does keyboard.send(self._skill_hotkeys["holy_shield"]) + with no check, and there are 90+ such unguarded sends across the paladin + classes. Raising on an empty key killed the whole bot thread mid-run for a + skill the character was never meant to cast, so the guard belongs at the + boundary rather than at every call site. + """ + from input_layer import keyboard + + keyboard.send("") + keyboard.send(None) + + +def test_genuinely_unknown_key_still_raises(): + """The empty-key guard must not swallow real typos.""" + import pytest as _pytest + from input_layer import keyboard + + with _pytest.raises(ValueError): + keyboard.send("notakey") + + +def test_cast_buffs_skips_when_holy_shield_unbound(): + """Not just the send — the right-click after it would cast whatever is on + the right slot instead of the intended buff.""" + import inspect + from char.paladin.paladin import Paladin + + src = inspect.getsource(Paladin.cast_buffs) + assert 'if not self._skill_hotkeys.get("holy_shield")' in src + assert src.index("return") < src.index('mouse.click')