Controller configs

This commit is contained in:
Joey Yakimowich-Payne 2025-05-16 09:22:03 -06:00
commit c43cb5e0ce
6 changed files with 1275 additions and 307 deletions

127
README.md
View file

@ -383,6 +383,133 @@ python main.py --token-cache /path/to/your/cache.json
!token !token
``` ```
## Game Control Configuration (`data/config/game_controls.json`)
The `data/config/game_controls.json` file allows you to define custom game control schemes for different games. This enables the bot to adapt its input commands (keyboard or controller) based on the game currently being played.
### Structure
The JSON file has the following main structure:
```json
{
"default_game": "YourDefaultGameName",
"games": {
"YourDefaultGameName": {
// Configuration for YourDefaultGameName
},
"AnotherGameName": {
// Configuration for AnotherGameName
}
// ... more game configurations
}
}
```
- `"default_game"`: (String) Specifies the name of the game configuration that will be loaded by default when the bot starts. The command names from this game's configuration will be registered at startup.
- `"games"`: (Object) A dictionary where each key is a unique game name (e.g., "Apex Legends", "Minecraft", "MyCustomGame"). Each game name maps to an object containing its specific control configuration.
### Game Configuration Structure
Each game entry within the `"games"` object has the following structure:
```json
"GameName": {
"description": "A brief description of this game or control scheme.",
"keyboard": {
// Keyboard control mappings for GameName
},
"controller": {
// Controller control mappings for GameName
}
}
```
- `"description"`: (String) A human-readable description for this game configuration (e.g., "Apex Legends - Standard Controls", "Dark Souls - Fat Roll Setup"). This description is shown in `!gamehelp`.
- `"keyboard"`: (Object) Contains mappings for keyboard-based commands.
- `"controller"`: (Object) Contains mappings for virtual gamepad-based commands.
### Control Mapping Structure (Keyboard & Controller)
Inside both the `"keyboard"` and `"controller"` objects, you define individual control commands. The **keys** in these objects are the **actual command strings** users will type in Twitch chat (without the `!` prefix). For example, if you define `"w": {...}`, users will type `!w`.
Each command mapping has the following structure:
```json
"command_user_types": {
"action_type": "type_of_action_to_perform",
"params": {
// Parameters specific to the action_type
},
"description": "What this command does in this specific game (e.g., 'Move Forward', 'Jump', 'Primary Attack')."
}
```
- `"command_user_types"`: (String) The command string the user types (e.g., `"w"`, `"space"`, `"mouse_left"`, `"a_button"`, `"ls_up"`).
- `"action_type"`: (String) Defines the kind of input to simulate. This corresponds to methods available in `KeyboardController` or `VirtualController`.
- **Common Keyboard `action_type` values:**
- `"press_key"`: Simulates pressing a keyboard key.
- `params`: `{ "key": "key_name" }` (e.g., `"w"`, `"space"`, `"shift"`, `"ctrl"`, `"1"`)
- `"press_mouse_button"`: Simulates a mouse button press.
- `params`: `{ "button": "button_name" }` (e.g., `"left"`, `"right"`, `"middle"`)
- **Common Controller `action_type` values:**
- `"press_button"`: Simulates pressing a gamepad button.
- `params`: `{ "button_name": "name_of_button" }` (e.g., `"a"`, `"b"`, `"x"`, `"y"`, `"left_shoulder"`, `"right_shoulder"`, `"left_thumb"`, `"right_thumb"`, `"start"`, `"back"`)
- `"press_trigger"`: Simulates pressing a gamepad trigger.
- `params`: `{ "trigger_name": "left_or_right" }` (e.g., `"left"`, `"right"`)
- `"move_left_stick"` / `"move_right_stick"`: Simulates moving an analog stick.
- `params`: `{ "direction": "up/down/left/right" }` (e.g., `"up"`)
- *Note: For more precise stick control, the `VirtualController` might support direct x/y values in the future, which would require different params.*
- `"press_dpad"`: Simulates pressing a D-Pad direction.
- `params`: `{ "direction": "up/down/left/right" }`
- `"params"`: (Object) A dictionary of parameters required by the specified `action_type`. The keys and values within `params` depend directly on what the underlying controller methods (`KeyboardController` or `VirtualController` methods) expect.
- An optional `"duration"` (float, in seconds) can often be included in `params` to specify how long a key/button should be held. If omitted, a short default (e.g., 0.1 seconds) is typically used.
- `"description"`: (String) This is crucial. It explains what the command does in the context of *this specific game*. This description is shown to users via the `!gamehelp` command.
### Example Snippet
```json
{
"default_game": "ExampleFPS",
"games": {
"ExampleFPS": {
"description": "Standard FPS Controls",
"keyboard": {
"w": {"action_type": "press_key", "params": {"key": "w"}, "description": "Move Forward"},
"s": {"action_type": "press_key", "params": {"key": "s"}, "description": "Move Backward"},
"mouse_left": {"action_type": "press_mouse_button", "params": {"button": "left"}, "description": "Fire Weapon"}
},
"controller": {
"ls_up": {"action_type": "move_left_stick", "params": {"direction": "up"}, "description": "Move Forward (L-Stick)"},
"rt": {"action_type": "press_trigger", "params": {"trigger_name": "right"}, "description": "Fire Weapon (RT)"},
"a": {"action_type": "press_button", "params": {"button_name": "a"}, "description": "Jump (A Button)"}
}
},
"RacingSim": {
"description": "Basic Racing Controls",
"keyboard": {
"w": {"action_type": "press_key", "params": {"key": "w", "duration": 0.5}, "description": "Accelerate"},
"s": {"action_type": "press_key", "params": {"key": "s", "duration": 0.3}, "description": "Brake/Reverse"}
},
"controller": {
"rt": {"action_type": "press_trigger", "params": {"trigger_name": "right"}, "description": "Accelerate (RT)"},
"lt": {"action_type": "press_trigger", "params": {"trigger_name": "left"}, "description": "Brake/Reverse (LT)"}
}
}
}
}
```
### Activating a Game Configuration
- At startup, the commands defined in the `keyboard` and `controller` sections of the `default_game` are registered. Their descriptions (from the JSON) are used.
- An admin can change the active game configuration at any time using the `!setgame <GameName>` command (e.g., `!setgame RacingSim`).
- Once the game is switched:
- The `!gamehelp` command will display the controls for the newly activated game.
- When users type a command (e.g., `!w`), the bot will execute the action defined for `"w"` in the *currently active game's* configuration. If `"w"` is not defined for the active game, it will be treated as an unknown command for that game.
This system allows for a highly flexible and game-adaptive control scheme managed entirely through the `game_controls.json` file.
## License ## License
This project is open source and available under the MIT License. This project is open source and available under the MIT License.

View file

@ -295,6 +295,9 @@ def main() -> None:
print("Keyboard/mouse control will be simulated but not actually perform any actions.") print("Keyboard/mouse control will be simulated but not actually perform any actions.")
print("Run 'pip install pynput' to enable actual keyboard/mouse control.") print("Run 'pip install pynput' to enable actual keyboard/mouse control.")
# Only initialize the GameController when NOT using queue mode
# When in queue mode, the controller is initialized in the queue consumer process
if not args.use_queue:
game_controller: GameController = GameController( game_controller: GameController = GameController(
bot, bot,
mode=args.game_mode, mode=args.game_mode,
@ -311,6 +314,9 @@ def main() -> None:
print("Use !gamehelp to see available game commands") print("Use !gamehelp to see available game commands")
print("Check user stats with !gamestats [username]") print("Check user stats with !gamestats [username]")
else:
print("Game control will be handled by the queue consumer process")
print("Use !gamehelp to see available game commands")
except ImportError as e: except ImportError as e:
print(f"Failed to import game control features: {e}") print(f"Failed to import game control features: {e}")
@ -319,7 +325,6 @@ def main() -> None:
if args.input_type == 'controller': if args.input_type == 'controller':
print(" pip install vgamepad # For controller support") print(" pip install vgamepad # For controller support")
print("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxinput type: ", args.input_type)
# Start the bot # Start the bot
print(f"Starting bot for channel #{channel}") print(f"Starting bot for channel #{channel}")
print("Basic commands: !hello, !dice [sides], !echo [message], !8ball") print("Basic commands: !hello, !dice [sides], !echo [message], !8ball")

View file

@ -7,13 +7,14 @@ import json
import logging import logging
import requests import requests
import webbrowser import webbrowser
import traceback
from typing import Dict, Any, Optional, Tuple from typing import Dict, Any, Optional, Tuple
# Import the auth server functionality # Import the auth server functionality
from src.core.auth_server import start_oauth_flow from src.core.auth_server import start_oauth_flow
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TwitchAuth: class TwitchAuth:
@ -128,7 +129,7 @@ class TwitchAuth:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to refresh token: {e}") logger.error(f"Failed to refresh token: {e}\n{traceback.format_exc()}")
# Clear refresh token on failure to force a new auth flow # Clear refresh token on failure to force a new auth flow
self.refresh_token = None self.refresh_token = None
return False return False
@ -172,7 +173,7 @@ class TwitchAuth:
return True, "Authentication successful" return True, "Authentication successful"
except Exception as e: except Exception as e:
logger.error(f"Failed to exchange authorization code: {e}") logger.error(f"Failed to exchange authorization code: {e}\n{traceback.format_exc()}")
return False, f"Failed to exchange authorization code: {str(e)}" return False, f"Failed to exchange authorization code: {str(e)}"
def _setup_auth(self, manual_mode: bool = False) -> bool: def _setup_auth(self, manual_mode: bool = False) -> bool:
@ -258,7 +259,7 @@ class TwitchAuth:
print("=====================================\n") print("=====================================\n")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to validate token: {e}") logger.error(f"Failed to validate token: {e}\n{traceback.format_exc()}")
print("\n===== Authentication Status =====") print("\n===== Authentication Status =====")
print("Warning: Could not validate token") print("Warning: Could not validate token")
print("============================\n") print("============================\n")
@ -342,7 +343,7 @@ class TwitchAuth:
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e: except Exception as e:
logger.error(f"Token validation failed: {e}") logger.error(f"Token validation failed: {e}\n{traceback.format_exc()}")
raise ValueError(f"Token validation failed: {str(e)}") raise ValueError(f"Token validation failed: {str(e)}")
def update_manually(self, access_token: str, refresh_token: str = None, expires_in: int = 14400) -> None: def update_manually(self, access_token: str, refresh_token: str = None, expires_in: int = 14400) -> None:

View file

@ -6,6 +6,7 @@ import requests
import time import time
import json import json
import os import os
import traceback
from typing import Dict, Callable, List, Optional, Any, Tuple, Union, Match, Set from typing import Dict, Callable, List, Optional, Any, Tuple, Union, Match, Set
# Import the queue functionality # Import the queue functionality
@ -203,7 +204,7 @@ class TwitchBot:
return False return False
except Exception as e: except Exception as e:
logger.error(f"Failed to connect: {e}") logger.error(f"Failed to connect: {e}\n{traceback.format_exc()}")
return False return False
def register_command(self, command: str, callback: CommandCallback) -> None: def register_command(self, command: str, callback: CommandCallback) -> None:
@ -234,6 +235,10 @@ class TwitchBot:
if not message_data: if not message_data:
return return
# Ignore messages from the bot itself
if message_data['username'].lower() == self.username.lower():
return
content: str = message_data['content'].strip() content: str = message_data['content'].strip()
username: str = message_data['username'] username: str = message_data['username']
@ -262,9 +267,6 @@ class TwitchBot:
if not self.oauth_token: if not self.oauth_token:
self.oauth_token = self.get_oauth_token() self.oauth_token = self.get_oauth_token()
# Simply pass the command name and let the queue worker handle all processing
logger.info(f"Enqueueing command '{command}' for processing by queue worker")
enqueue_command( enqueue_command(
username, username,
command, command,
@ -280,7 +282,7 @@ class TwitchBot:
try: try:
self.commands[command](username, args, self) self.commands[command](username, args, self)
except Exception as e: except Exception as e:
logger.error(f"Error executing command {command}: {e}") logger.error(f"Error executing command {command}: {e}\n{traceback.format_exc()}")
def start(self, use_queue: bool = False) -> None: def start(self, use_queue: bool = False) -> None:
"""Start the bot and listen for messages""" """Start the bot and listen for messages"""
@ -291,10 +293,13 @@ class TwitchBot:
# Try to connect with up to 3 attempts, possibly with token refreshes # Try to connect with up to 3 attempts, possibly with token refreshes
max_attempts = 3 max_attempts = 3
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
logger.info(f"Connection attempt {attempt}/{max_attempts}") res = self.connect()
if self.connect():
if res:
break break
elif attempt < max_attempts:
logger.info(f"Connection attempt {attempt}/{max_attempts}")
if attempt < max_attempts:
# Wait a moment before retrying # Wait a moment before retrying
time.sleep(2) time.sleep(2)
logger.info("Retrying connection...") logger.info("Retrying connection...")
@ -370,3 +375,30 @@ class TwitchBot:
def get_queue_stats(self) -> Dict[str, Any]: def get_queue_stats(self) -> Dict[str, Any]:
"""Get current queue statistics""" """Get current queue statistics"""
return get_queue_stats() return get_queue_stats()
# Add a standalone version of is_admin for use by other modules
def is_admin(username: str, channel: str = None, admin_users: List[str] = None) -> bool:
"""
Check if a user has admin privileges
Args:
username: The username to check
channel: The channel name (channel owner is always an admin)
admin_users: Optional list of additional admin users
Returns:
bool: True if the user is an admin, False otherwise
"""
# Normalize usernames to lowercase
username = username.lower()
# Create admin list - channel owner is always an admin
admins = []
if channel:
admins.append(channel.lower())
# Add additional admins if provided
if admin_users:
admins.extend([user.lower() for user in admin_users])
return username in admins

View file

@ -1,8 +1,13 @@
import time import time
import logging
import traceback
import threading import threading
from typing import Optional, Union, Any, Literal, Dict, Tuple, List, Set from typing import Optional, Union, Any, Literal, Dict, Tuple, List, Set
import ctypes import ctypes
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
# Try to import vgamepad for Xbox controller emulation # Try to import vgamepad for Xbox controller emulation
try: try:
import vgamepad as vg import vgamepad as vg
@ -69,7 +74,7 @@ class VirtualController:
self.gamepad = vg.VX360Gamepad() self.gamepad = vg.VX360Gamepad()
print("Virtual Xbox 360 controller initialized successfully") print("Virtual Xbox 360 controller initialized successfully")
except Exception as e: except Exception as e:
print(f"Failed to initialize virtual gamepad: {e}") logger.error(f"Failed to initialize virtual gamepad: {e}\n{traceback.format_exc()}")
self.available = False self.available = False
# Initialize SDL2 for physical controller if available # Initialize SDL2 for physical controller if available

File diff suppressed because it is too large Load diff