From c4e8b76ab633be468d157a9f5a0c93a96992bbd3 Mon Sep 17 00:00:00 2001 From: Nick Bottone Date: Fri, 9 Oct 2026 17:02:30 -0400 Subject: [PATCH] fix: use searchable game autocomplete to prevent startup crashes --- Makefile | 5 +- README.md | 29 ++++++++ cogs/gamehangout.py | 17 +++-- cogs/server.py | 16 ++++- config.py | 7 +- game_selection.py | 35 ++++++++++ tests/__init__.py | 0 tests/support.py | 28 ++++++++ tests/test_game_selection.py | 126 +++++++++++++++++++++++++++++++++++ 9 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 game_selection.py create mode 100644 tests/__init__.py create mode 100644 tests/support.py create mode 100644 tests/test_game_selection.py diff --git a/Makefile b/Makefile index f639e4d..c2f9390 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PYTHON ?= python3.10 PIPTOOLS := $(PYTHON) -m piptools -.PHONY: deps lock check-lock run +.PHONY: deps lock check-lock run test deps: $(PYTHON) -m pip install --upgrade pip setuptools wheel @@ -21,3 +21,6 @@ check-lock: run: $(PYTHON) main.py + +test: + $(PYTHON) -m unittest discover -s tests -v diff --git a/README.md b/README.md index d879f65..462d93f 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,32 @@ A Discord bot written in Discord.py that runs ranked competitive matches for xRC - Refresh the lock file with `make lock`. - Verify the lock file is up to date with `make check-lock`. - The default Makefile Python is `python3.10`. Override it if needed, for example `make lock PYTHON=python3.11`. + +## Game Selection + +`/launchserver` and `/hangout_create` search the full game catalog as you type. +The dropdown shows up to 25 suggestions at a time; type part of a game name +(for example, `bio` for Biobuzz) to find games outside the initial suggestions. +You can also submit a full game name without selecting a suggestion. Names +are case-insensitive, and valid game IDs are accepted as well. + +## Testing + +After installing the project dependencies, run the automated test suite: + +```sh +make test +``` + +Override the Python interpreter if needed, for example `make test PYTHON=python3.11`. +Or run test discovery directly: + +```sh +python3.10 -m unittest discover -s tests -v +``` + +Tests live in `tests/` and use Python's built-in `unittest` framework. Name new +test modules `test_*.py` so discovery includes them. Tests use dummy configuration +and mock external effects, so no `.env` file, credentials, or live Discord +connection is needed. Live Discord API acceptance is checked separately by +deploying and synchronizing the bot's commands. diff --git a/cogs/gamehangout.py b/cogs/gamehangout.py index 84863be..2e87973 100644 --- a/cogs/gamehangout.py +++ b/cogs/gamehangout.py @@ -7,6 +7,7 @@ from datetime import datetime import logging from config import * +from game_selection import game_autocomplete, resolve_game_id logger = logging.getLogger('discord') @@ -983,8 +984,8 @@ def __init__(self, bot): self.active_hangouts = {} # staff_user_id: HangoutSession @app_commands.command(description="Create a casual game hangout session (Staff Only)") - @app_commands.describe(game="The game to play in the hangout") - @app_commands.choices(game=server_games_choices) + @app_commands.describe(game="Search for a game or enter its full name") + @app_commands.autocomplete(game=game_autocomplete) async def hangout_create(self, interaction: discord.Interaction, game: str): # Check if user is a Member and has staff permissions if not isinstance(interaction.user, discord.Member): @@ -994,6 +995,14 @@ async def hangout_create(self, interaction: discord.Interaction, game: str): if not any(role_id in [role.id for role in interaction.user.roles] for role_id in [EVENT_STAFF_ID, TRIAL_STAFF_ID]): await interaction.response.send_message("Only staff members can create hangout sessions!", ephemeral=True) return + + game_id = resolve_game_id(game) + if game_id is None: + await interaction.response.send_message( + "Unknown game. Choose a search suggestion or enter a full game name.", + ephemeral=True, + ) + return # Check if staff already has an active hangout if interaction.user.id in self.active_hangouts: @@ -1009,7 +1018,7 @@ async def hangout_create(self, interaction: discord.Interaction, game: str): await interaction.response.defer() # Get game name from the choice value - game_name = next((name for name, value in server_games.items() if value == game), "Unknown Game") + game_name = next(name for name, value in server_games.items() if value == game_id) # Create hangout session session = HangoutSession(interaction.user, game_name, interaction.guild, self, interaction.channel) @@ -1403,4 +1412,4 @@ async def setup(bot: commands.Bot) -> None: await bot.add_cog( cog, guilds=[guild] - ) \ No newline at end of file + ) diff --git a/cogs/server.py b/cogs/server.py index cb45949..3baa1c1 100644 --- a/cogs/server.py +++ b/cogs/server.py @@ -17,7 +17,6 @@ from config import ( server_restart_modes, - server_games_choices, PORTS, server_game_settings, default_game_players, @@ -25,6 +24,7 @@ server_games, QUEUE_STATUS_CHANNEL_ID, ) +from game_selection import game_autocomplete, resolve_game_id logger = logging.getLogger('discord') @@ -561,8 +561,9 @@ async def server_watch_task(self, port: int, message: discord.Message): break await asyncio.sleep(5) # Update every 5 seconds - @app_commands.choices(game=server_games_choices) + @app_commands.autocomplete(game=game_autocomplete) @app_commands.command(description="Launches a new instance of xRC Sim server", name="launchserver") + @app_commands.describe(game="Search for a game or enter its full name") @app_commands.checks.has_any_role("Event Staff") async def launch_server(self, interaction: discord.Interaction, game: str, comment: str, password: str = "", admin: str = "Admin", @@ -591,11 +592,20 @@ async def launch_server(self, interaction: discord.Interaction, """ logger.info(f"{interaction.user.name} called /launchserver") + game_id = resolve_game_id(game) + if game_id is None: + await interaction.response.send_message( + "Unknown game. Choose a search suggestion or enter a full game name.", + ephemeral=True, + ) + return + game = game_id + result, port = self.start_server_process(game, comment, password, admin, restart_mode, frame_rate, update_time, tournament_mode, start_when_ready, register, spectators, min_players, restart_all, timeout) if not silent and port != -1: - game_type = server_games.get(game, "Unknown") + game_type = next(name for name, game_id in server_games.items() if game_id == game) asyncio.create_task(self._create_watch_message(port, game_type)) await interaction.response.send_message(result) diff --git a/config.py b/config.py index 06eb1a6..a46be8c 100644 --- a/config.py +++ b/config.py @@ -1,7 +1,6 @@ import os import logging from dotenv import load_dotenv -from discord.app_commands import Choice logger = logging.getLogger('discord') load_dotenv() @@ -100,13 +99,9 @@ "Decode": "21", "Rebuilt": "22", "Override": "23", - "Biobuzz": "23", + "Biobuzz": "24", } -server_games_choices = [ - Choice(name=game, value=server_games[game]) for game in server_games.keys() -] - PORTS = [11115, 11116, 11117, 11118, 11119, 11120] server_game_settings = { diff --git a/game_selection.py b/game_selection.py new file mode 100644 index 0000000..e74f90d --- /dev/null +++ b/game_selection.py @@ -0,0 +1,35 @@ +"""Shared game search and validation for server and hangout commands.""" + +from typing import List, Optional + +import discord +from discord import app_commands + +from config import server_games + + +async def game_autocomplete( + interaction: discord.Interaction, current: str +) -> List[app_commands.Choice[str]]: + query = current.strip().casefold() + choices = [ + app_commands.Choice(name=name, value=game_id) + for name, game_id in server_games.items() + if query in name.casefold() + ] + # Keep exact and prefix matches visible even for broad searches. + choices.sort(key=lambda choice: ( + choice.name.casefold() != query, + not choice.name.casefold().startswith(query), + )) + return choices[:25] + + +def resolve_game_id(value: str) -> Optional[str]: + """Accept a selected ID or a complete game name typed without selecting.""" + value = value.strip() + return next( + (game_id for name, game_id in server_games.items() + if value == game_id or value.casefold() == name.casefold()), + None, + ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..c96e9ed --- /dev/null +++ b/tests/support.py @@ -0,0 +1,28 @@ +"""Dummy configuration shared by tests.""" + +from contextlib import contextmanager +from unittest.mock import patch + + +TEST_ENV = { + "DISCORD_BOT_TOKEN": "test-token", + "DISCORD_APPLICATION_ID": "1", + "SRC_API_TOKEN": "test-token", + "GUILD_ID": "1", + "QUEUE_STATUS_CHANNEL_ID": "2", + "QUEUE_CHANNEL_ID": "3", + "RULES_CHANNEL_ID": "4", + "CATEGORY_ID": "5", + "EVENT_STAFF_ID": "6", + "TRIAL_STAFF_ID": "7", + "LOBBY_VC_ID": "8", + "BOTS_ROLE_ID": "9", + "RANKED_ADMIN_USERNAME": "test-admin", +} + + +@contextmanager +def test_configuration(): + # Avoid reading .env or requiring deployment credentials. + with patch.dict("os.environ", TEST_ENV, clear=True), patch("dotenv.load_dotenv"): + yield diff --git a/tests/test_game_selection.py b/tests/test_game_selection.py new file mode 100644 index 0000000..f108800 --- /dev/null +++ b/tests/test_game_selection.py @@ -0,0 +1,126 @@ +"""Regression checks using dummy configuration and no Discord connection.""" + +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +import discord +from tests.support import test_configuration + + +with test_configuration(): + from config import EVENT_STAFF_ID, server_games + from game_selection import game_autocomplete, resolve_game_id + from cogs.server import ServerActions + from cogs.gamehangout import GameHangout + + +def make_interaction(): + user = Mock(spec=discord.Member) + user.id = 100 + user.roles = [SimpleNamespace(id=EVENT_STAFF_ID)] + return SimpleNamespace( + user=user, + guild=Mock(), + channel=SimpleNamespace(id=200), + response=SimpleNamespace(send_message=AsyncMock(), defer=AsyncMock()), + followup=SimpleNamespace(send=AsyncMock()), + original_response=AsyncMock(), + ) + + +class GameSelectionTests(unittest.IsolatedAsyncioTestCase): + async def test_game_beyond_initial_choices_is_searchable(self): + catalog = {f"Game {i}": str(i) for i in range(100)} + catalog["Searchable Game"] = "100" + with patch.dict(server_games, catalog, clear=True): + initial = await game_autocomplete(None, "") + self.assertNotIn("Searchable Game", [choice.name for choice in initial]) + choices = await game_autocomplete(None, " SEARCHABLE ") + self.assertEqual([(c.name, c.value) for c in choices], [("Searchable Game", "100")]) + + async def test_substring_search(self): + choices = await game_autocomplete(None, "buzz") + self.assertEqual([choice.name for choice in choices], ["Biobuzz"]) + + async def test_unknown_search_has_no_suggestions(self): + self.assertEqual(await game_autocomplete(None, "not a game"), []) + + async def test_large_catalog_keeps_exact_match_visible(self): + catalog = {f"Game {i}": str(i) for i in range(100)} + catalog.update({f"Game 9 variant {i}": f"variant-{i}" for i in range(30)}) + # Insert the exact match last so limiting before ranking would hide it. + catalog["Game 9"] = catalog.pop("Game 9") + with patch.dict(server_games, catalog, clear=True): + choices = await game_autocomplete(None, "Game 9") + self.assertEqual(choices[0].name, "Game 9") + self.assertIn("Game 99", [choice.name for choice in choices]) + + def test_every_game_resolves_by_id_and_full_name(self): + for name, game_id in server_games.items(): + with self.subTest(game=name): + self.assertEqual(resolve_game_id(game_id), game_id) + self.assertEqual(resolve_game_id(f" {name.upper()} "), game_id) + + def test_partial_or_unknown_values_are_rejected(self): + for value in ("", "bio", "not a game", "999"): + with self.subTest(value=value): + self.assertIsNone(resolve_game_id(value)) + + def test_biobuzz_has_unique_incremented_id(self): + self.assertEqual(server_games["Override"], "23") + self.assertEqual(server_games["Biobuzz"], "24") + self.assertEqual(len(server_games), len(set(server_games.values()))) + + async def test_launch_rejects_unknown_game_before_starting_server(self): + cog = ServerActions.__new__(ServerActions) + cog.start_server_process = Mock() + interaction = make_interaction() + await ServerActions.launch_server.callback(cog, interaction, "bad game", "test") + cog.start_server_process.assert_not_called() + interaction.response.send_message.assert_awaited_once() + self.assertTrue(interaction.response.send_message.call_args.kwargs["ephemeral"]) + + async def test_launch_normalizes_full_name_and_selected_id(self): + for value in (" bIoBuZz ", "24"): + with self.subTest(value=value): + cog = ServerActions.__new__(ServerActions) + cog.start_server_process = Mock(return_value=("test result", -1)) + interaction = make_interaction() + await ServerActions.launch_server.callback(cog, interaction, value, "test") + self.assertEqual(cog.start_server_process.call_args.args[0], "24") + + async def test_hangout_rejects_unknown_game_before_creating_resources(self): + cog = GameHangout(Mock()) + interaction = make_interaction() + with patch("cogs.gamehangout.HangoutSession") as session_class: + await GameHangout.hangout_create.callback(cog, interaction, "bad game") + session_class.assert_not_called() + interaction.response.defer.assert_not_awaited() + interaction.response.send_message.assert_awaited_once() + self.assertTrue(interaction.response.send_message.call_args.kwargs["ephemeral"]) + + async def test_launched_server_watch_uses_selected_game_name(self): + cog = ServerActions.__new__(ServerActions) + cog.start_server_process = Mock(return_value=("server started", 11115)) + cog._create_watch_message = AsyncMock() + interaction = make_interaction() + await ServerActions.launch_server.callback(cog, interaction, "24", "test") + await asyncio.sleep(0) + cog._create_watch_message.assert_awaited_once_with(11115, "Biobuzz") + + async def test_hangout_normalizes_full_name_and_selected_id(self): + for value in (" bIoBuZz ", "24"): + with self.subTest(value=value): + cog = GameHangout(Mock()) + interaction = make_interaction() + with patch("cogs.gamehangout.HangoutSession") as session_class: + session_class.return_value.create_hangout_resources = AsyncMock(return_value=True) + await GameHangout.hangout_create.callback(cog, interaction, value) + self.assertEqual(session_class.call_args.args[1], "Biobuzz") + self.assertIn(interaction.user.id, cog.active_hangouts) + + +if __name__ == "__main__": + unittest.main()