Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,3 +21,6 @@ check-lock:

run:
$(PYTHON) main.py

test:
$(PYTHON) -m unittest discover -s tests -v
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 13 additions & 4 deletions cogs/gamehangout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -1403,4 +1412,4 @@ async def setup(bot: commands.Bot) -> None:
await bot.add_cog(
cog,
guilds=[guild]
)
)
16 changes: 13 additions & 3 deletions cogs/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@

from config import (
server_restart_modes,
server_games_choices,
PORTS,
server_game_settings,
default_game_players,
GUILD_ID,
server_games,
QUEUE_STATUS_CHANNEL_ID,
)
from game_selection import game_autocomplete, resolve_game_id

logger = logging.getLogger('discord')

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 1 addition & 6 deletions config.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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 = {
Expand Down
35 changes: 35 additions & 0 deletions game_selection.py
Original file line number Diff line number Diff line change
@@ -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,
)
Empty file added tests/__init__.py
Empty file.
28 changes: 28 additions & 0 deletions tests/support.py
Original file line number Diff line number Diff line change
@@ -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
126 changes: 126 additions & 0 deletions tests/test_game_selection.py
Original file line number Diff line number Diff line change
@@ -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()
Loading