diff --git a/unique-universes/.docker/Dockerfile b/unique-universes/.docker/Dockerfile new file mode 100644 index 0000000..ebc9f17 --- /dev/null +++ b/unique-universes/.docker/Dockerfile @@ -0,0 +1,35 @@ +FROM python:3.12-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + DEBIAN_FRONTEND=noninteractive \ + PATH=/app/.venv/bin:$PATH + +RUN set -ex \ + && addgroup --gid 50000 python \ + && adduser --shell /bin/false --disabled-password --uid 50000 --gid 50000 --home /python python \ + && apt-get update \ + && apt-get upgrade -y \ + && apt-get clean \ + && apt-get autoremove \ + && rm -rf /var/lib/apt/lists/* + +USER python +WORKDIR /app + +FROM base AS build + +# Install project dependencies +RUN python -m pip install --no-cache --disable-pip-version-check --user poetry +ENV POETRY_VIRTUALENVS_IN_PROJECT=true \ + POETRY_VIRTUALENVS_CREATE=true + +COPY pyproject.toml poetry.lock ./ +RUN /python/.local/bin/poetry install --no-root --no-dev --sync --no-ansi --no-interaction + +FROM base AS runtime +COPY --from=build /app/.venv /app/.venv + +# Copy the source code in last to optimize rebuilding the image +COPY --chown=python:python . . + +ENTRYPOINT ["python", "-m", "src"] diff --git a/unique-universes/.github/workflows/docker.yml b/unique-universes/.github/workflows/docker.yml new file mode 100644 index 0000000..3503a92 --- /dev/null +++ b/unique-universes/.github/workflows/docker.yml @@ -0,0 +1,36 @@ +name: Build Docker Image + +on: + workflow_call: + workflow_dispatch: + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: ghcr.io/Snipy7374/code-jam-24-universes + tags: type=ref,event=branch + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-qemu-action@v2 + - uses: docker/setup-buildx-action@v2 + - name: Build container + uses: docker/build-push-action@v4 + with: + context: . + file: .docker/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/unique-universes/.github/workflows/lint.yaml b/unique-universes/.github/workflows/lint.yaml new file mode 100644 index 0000000..7f67e80 --- /dev/null +++ b/unique-universes/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/unique-universes/.gitignore b/unique-universes/.gitignore new file mode 100644 index 0000000..4b208ab --- /dev/null +++ b/unique-universes/.gitignore @@ -0,0 +1,35 @@ +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env +.history + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ + +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +# MacOS +.DS_Store + +# lock file +poetry.lock diff --git a/unique-universes/.pre-commit-config.yaml b/unique-universes/.pre-commit-config.yaml new file mode 100644 index 0000000..4bccb6f --- /dev/null +++ b/unique-universes/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + - id: ruff-format diff --git a/unique-universes/LICENSE.txt b/unique-universes/LICENSE.txt new file mode 100644 index 0000000..d8b6baf --- /dev/null +++ b/unique-universes/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Unique Universes + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/unique-universes/README.md b/unique-universes/README.md new file mode 100644 index 0000000..d149683 --- /dev/null +++ b/unique-universes/README.md @@ -0,0 +1,170 @@ +# Unique Universes CJ24's Application + +Our application is a shooting minigame, based on precise and calculated shots from your ship to the enemy ship. + +## How is the theme represented ? + +The minigame relies on the user’s ability to calculate a shot with the help of (a lot of) raw information provided to the user. + This is the interesting part, the user has to face an information overflow and extract the important data in order to play the game. + +## Availables commands + +- /shoot - Play the minigame. + +![The game](./readme_assets/minigame.png) + +The rules are simple. You are given an amount of ammunitions with which you can hit an enemy. The enemy position is proportional to your position, instead the health is capped at a given value and will decrease when you hit the enemy. +After every shot the enemy will change position, though if you have hitted the enemy you will have 1 ammunition given back and some HP regenerated. +If you miss the enemy nothing happens, you just lost one ammunition and the enemy change position. + +- /about - Get information about the application and the team. + +![About](./readme_assets/about.png) + +# Team members contribution + +- **@Snipy7374 (Team Leader)**: Worked on the bot's structure, the game design, the game logic (physics/maths & database communication) and the documentation. +- **@Mmesek**: Worked on the game design, the docker image, the game logic (physics/maths), the UI and the documentation. +- **@stroh13**: Worked on the about command and the game logic (physics/maths). +- **@Astroyo**: Worked on the game logic (database communication). +- **@EarthKiii**: Worked on the game design and the documentation. + +# Major Bugs notice + +So far everything was too good to be true. Indeed our project have one CRITIC bug (at least known at this time). This bug involve the `/shoot` command. I will provide an hotfix below, pls judges forgive us for this :pray: + +```diff +diff --git a/src/exts/minigames.py b/src/exts/minigames.py +index 23709c1..a183fcf 100644 +--- a/src/exts/minigames.py ++++ b/src/exts/minigames.py +@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING + import disnake + from disnake.ext import commands + from src.views.shoot import ShootMenu ++from src.database import PlayerNotFoundError + + if TYPE_CHECKING: + from src.bot import Universe +@@ -49,7 +50,10 @@ class Minigames(commands.Cog): + @commands.slash_command() # type: ignore[reportUnknownMemberType] + async def shoot(self, inter: disnake.GuildCommandInteraction) -> None: + """Run an info overloaded shoot minigame.""" +- player = await self.bot.database.fetch_player(inter.author.id) ++ try: ++ player = await self.bot.database.fetch_player(inter.author.id) ++ except PlayerNotFoundError: ++ player = await self.bot.database.create_player(inter.author.id) + view = ShootMenu(inter.author, player) + generate_random_stats(view.stats) + embed = disnake.Embed(title="Shoot minigame", description="\n".join(["." * 10] * 5)) +``` + +If you aren't a nerd (like me) and don't know what all that weird things at the top means i got you, you need to copy the green highlited changes and paste them at `src/exts/minigames.py` line 52 (replace the line, paste it on that line), don't forget the import too. With this the major bug should be solved. (I should have listened to Zig words about commits in the last day, sensei forgive me pls) + +If the code snippet above is weird looking blame GitHub, to avoid any (and i mean ANY) unfortunate event i have also added a [`diff.txt`](./diff.txt) (yeah click it) file at the root of the project. You can inspect it if necessary, the content is the same as the one provided above. + + +This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. + +You can run the bot either with Docker or manually. + +# Running the bot with Docker +## Building + +```sh +docker build -t unique-universes -f .docker/Dockerfile . +``` + +## Running +```sh +docker run --rm -it -e BOT_TOKEN=YOUR_TOKEN_HERE unique-universes +``` + +# Running the bot manually +## Installing the dependencies + +To install all the required dependencies to run the bot execute these commands: + +```shell +pip install poetry +poetry install +``` + +## Creating the .env file + +To be able to execute the bot locally you will need to create and grab the token of a discord bot. To create a bot head to the [discord developer portal]("https://discord.com/developers/applications/") and follow these [instructions to create a bot and copy its token]("https://discordpy.readthedocs.io/en/stable/discord.html"). + +After having copied the token you need to create a file called `.env` at the root of the project. Then type `BOT_TOKEN=` and paste the actual bot token after the equal sign. The file should look like this: + +```ini +BOT_TOKEN=ExampleOfBotTokenHere +``` + +## Run project + +```sh +python -m src +``` + +# For the developers + +# Setting Up the Dev Env + +If you are a team member make sure to read this section, otherwise you can skip this. + +> [!NOTE] +> The steps listed here needs to be done only the first time when setting up the project locally. + +## Required Python version + +This project requires python 3.12 to work. If you don't have it installed proceed to install it from the official python website. + +## Creating and activating a venv (virtual environment) + +The most preferable way to work on a project is to create a virtual envirnoment where to install the dependencies. This is extremely useful to avoid conflicts between different projects that install dependencies globally. + +To create a virtual environment run the following command in your terminal: + +```shell +python3 -m venv .venv +``` + +> [!NOTE] +> If you are on windows and have different python versions installed without a python version manager, you can run the following command to use python 3.12 +> `py -3.12 -m venv .venv` + +you can replace `.venv` in the commands with a path (e.g `example_folder/.venv`) if needed. If you provide only `.venv` python will create the environment in the same directory where you are running the command. + +After having created the venv (virtual environment) you need to activate it. +To enable the venv run the following commands depending on your platform: + +```shell +# Linux, Bash +$ source .venv/bin/activate +# Linux, Fish +$ source .venv/bin/activate.fish +# Linux, Csh +$ source .venv/bin/activate.csh +# Linux, PowerShell Core +$ .venv/bin/Activate.ps1 +# Windows, cmd.exe +> .venv\Scripts\activate.bat +# Windows, PowerShell +> .venv\Scripts\Activate.ps1 +``` + +To deactivate the venv type `deactivate` in the terminal. + +## Pre-commit + +Before commiting, make sure to run any pre-commit checks. +Install pre-commit if you haven't done yet: + +```sh +pre-commit install +``` + +## Final words + +Now you're ready to go, your local copy of the repository is ready to be ran. diff --git a/unique-universes/diff.txt b/unique-universes/diff.txt new file mode 100644 index 0000000..269d74a --- /dev/null +++ b/unique-universes/diff.txt @@ -0,0 +1,24 @@ +diff --git a/src/exts/minigames.py b/src/exts/minigames.py +index 23709c1..a183fcf 100644 +--- a/src/exts/minigames.py ++++ b/src/exts/minigames.py +@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING + import disnake + from disnake.ext import commands + from src.views.shoot import ShootMenu ++from src.database import PlayerNotFoundError + + if TYPE_CHECKING: + from src.bot import Universe +@@ -49,7 +50,10 @@ class Minigames(commands.Cog): + @commands.slash_command() # type: ignore[reportUnknownMemberType] + async def shoot(self, inter: disnake.GuildCommandInteraction) -> None: + """Run an info overloaded shoot minigame.""" +- player = await self.bot.database.fetch_player(inter.author.id) ++ try: ++ player = await self.bot.database.fetch_player(inter.author.id) ++ except PlayerNotFoundError: ++ player = await self.bot.database.create_player(inter.author.id) + view = ShootMenu(inter.author, player) + generate_random_stats(view.stats) + embed = disnake.Embed(title="Shoot minigame", description="\n".join(["." * 10] * 5)) diff --git a/unique-universes/example.env b/unique-universes/example.env new file mode 100644 index 0000000..26aa581 --- /dev/null +++ b/unique-universes/example.env @@ -0,0 +1 @@ +BOT_TOKEN=bot_token_here diff --git a/unique-universes/pyproject.toml b/unique-universes/pyproject.toml new file mode 100644 index 0000000..0c10011 --- /dev/null +++ b/unique-universes/pyproject.toml @@ -0,0 +1,70 @@ +[tool.poetry] +name = "CJ24 Unique Universes" +version = "0.1.0" +description = "Description" +authors = ["Snipy7374 ", "Mmesek", "EarthKiii", "stroh13", "Astroyo"] +license = "MIT" +package-mode = false + +[tool.poetry.dependencies] +python = "3.12.*" +disnake = "^2.9.2" +colorama = "^0.4.6" +aiosqlite = "0.20.0" +python-dotenv = "^1.0.1" + +[tool.poetry.dev-dependencies] +ruff = "~0.5.0" +pre-commit = "~3.7.1" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 120 +# Target Python 3.12. If you decide to use a different version of Python +# you will need to update this value. +target-version = "py312" +# Automatically fix auto-fixable issues. +fix = true +# The directory containing the source code. If you choose a different project layout +# you will need to update this value. +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Missing docstrings. + "D100", + "D101", + "D102", + "D103", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D213", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Builtins. + "A", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Annotations. + "ANN101", + "ANN102", +] diff --git a/unique-universes/readme_assets/about.png b/unique-universes/readme_assets/about.png new file mode 100644 index 0000000..8cf8464 Binary files /dev/null and b/unique-universes/readme_assets/about.png differ diff --git a/unique-universes/readme_assets/minigame.png b/unique-universes/readme_assets/minigame.png new file mode 100644 index 0000000..8398858 Binary files /dev/null and b/unique-universes/readme_assets/minigame.png differ diff --git a/unique-universes/requirements-dev.txt b/unique-universes/requirements-dev.txt new file mode 100644 index 0000000..d529f2e --- /dev/null +++ b/unique-universes/requirements-dev.txt @@ -0,0 +1,6 @@ +# This file contains all the development requirements for our linting toolchain. +# Don't forget to pin your dependencies! +# This list will have to be migrated if you wish to use another dependency manager. + +ruff~=0.5.0 +pre-commit~=3.7.1 diff --git a/unique-universes/src/__main__.py b/unique-universes/src/__main__.py new file mode 100644 index 0000000..d4ccdf5 --- /dev/null +++ b/unique-universes/src/__main__.py @@ -0,0 +1,55 @@ +import asyncio +import logging + +from src.bot import Universe +from src.database import setup_db + +_log = logging.getLogger(__name__) + + +async def start_bot(bot: Universe) -> None: + try: + await bot.start() + except KeyboardInterrupt: + pass + finally: + if not bot.is_closed(): + await bot.close() + + +async def close_db(bot: Universe) -> None: + _log.info("Closing DB connection") + await bot.database.db_connection.close() + _log.info("DB connection closed") + + +def cancel_tasks(loop: asyncio.AbstractEventLoop) -> None: + _log.info("Cancelling all the tasks") + tasks = {task for task in asyncio.all_tasks(loop) if not task.done()} + for task in tasks: + task.cancel() + loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) + _log.info("Tasks cancelled") + + +async def close_bot(bot: Universe) -> None: + _log.info("Closing the Bot") + await bot.close() + _log.info("Bot successfully closed") + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + db_conn = loop.run_until_complete(setup_db()) + bot = Universe(loop, db_conn) + + try: + loop.run_until_complete(start_bot(bot)) + except KeyboardInterrupt: + pass + finally: + loop.run_until_complete(close_db(bot)) + cancel_tasks(loop) + loop.run_until_complete(close_bot(bot)) + loop.close() diff --git a/unique-universes/src/bot.py b/unique-universes/src/bot.py new file mode 100644 index 0000000..1c32b23 --- /dev/null +++ b/unique-universes/src/bot.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, override + +import disnake +from disnake.ext import commands, tasks +from src.constants import EnvVars +from src.database import Database +from src.logger import setup_logging + +if TYPE_CHECKING: + from asyncio import AbstractEventLoop + + from aiosqlite import Connection + +__all__: tuple[str] = ("Universe",) + +_log = logging.getLogger(__name__) + + +class FetchTasks: + def __init__(self, bot: Universe) -> None: + self.bot = bot + + async def _fetch_shoot_cmd(self) -> None: + _log.info("Fetching shoot cmd") + cmds = await self.bot.fetch_global_commands() + for cmd in cmds: + if cmd.name != "shoot": + continue + if isinstance(cmd, disnake.APISlashCommand): + self.bot.shoot_cmd = cmd + + if self.bot.shoot_cmd is not None: + _log.info("Shoot command fetched") + + @tasks.loop(seconds=1, count=1) + async def fetch_cmd(self) -> None: + await self.bot.wait_until_ready() + await self._fetch_shoot_cmd() + + +class Universe(commands.InteractionBot): + def __init__(self, loop: AbstractEventLoop, db_connection: Connection) -> None: + super().__init__( + intents=disnake.Intents.none(), + loop=loop, + ) + self.database = Database(connection=db_connection) + self.shoot_cmd: disnake.APISlashCommand | None = None + self.task_cmd = FetchTasks(self) + + async def on_ready(self) -> None: + _log.info(f"Logged in as {self.user}") + + if not self.task_cmd.fetch_cmd.is_running(): + self.task_cmd.fetch_cmd.start() + + @override + async def start(self) -> None: # type: ignore[reportincomplatibleMethodOverride] + setup_logging() + _log.info("Loading extensions") + self.load_extensions("./src/exts") + _log.info("Extensions loading finished") + await super().start(EnvVars.BOT_TOKEN, reconnect=True) diff --git a/unique-universes/src/constants.py b/unique-universes/src/constants.py new file mode 100644 index 0000000..092d014 --- /dev/null +++ b/unique-universes/src/constants.py @@ -0,0 +1,20 @@ +import os + +import dotenv + +type LoggingLevel = int | str +dotenv.load_dotenv() + +__all__: tuple[str, ...] = ( + "EnvVars", + "Config", +) + + +class EnvVars: + BOT_TOKEN: str = os.getenv("BOT_TOKEN", "") + + +class Config: + DEBUG: bool = True + LOGGING_LEVEL: LoggingLevel = "INFO" diff --git a/unique-universes/src/database.py b/unique-universes/src/database.py new file mode 100644 index 0000000..4e2d4fc --- /dev/null +++ b/unique-universes/src/database.py @@ -0,0 +1,156 @@ +from pathlib import Path + +import aiosqlite + + +class PlayerData: + def __init__(self, data: tuple) -> None: + self._raw_data = data + self.user_id: int = data[0] + self.shots_fired: int = data[1] + self.hits: int = data[2] + self.misses: int = data[3] + self.wins: int = data[4] + self.loses: int = data[5] + + +class PlayerNotFoundError(LookupError): ... + + +class PlayerExistsError(NameError): ... + + +class UnknownValueError(TypeError): ... + + +async def setup_db() -> aiosqlite.Connection: + """Set up the database and get the connection to the database.""" + if not Path("build").is_dir(): + Path("./build").mkdir() + connection = await aiosqlite.connect("./build/database.db") + async with connection.cursor() as cursor: + await cursor.execute(""" + CREATE TABLE IF NOT EXISTS players_data ( + _id int PRIMARY KEY, + shots_fired int DEFAULT 0, + hits int DEFAULT 0, + misses int DEFAULT 0, + wins int DEFAULT 0, + loses int DEFAULT 0 + ) + """) + return connection + + +class Database: + def __init__(self, connection: aiosqlite.Connection) -> None: + self._db_connection: aiosqlite.Connection = connection + + @property + def db_connection(self) -> aiosqlite.Connection: + return self._db_connection + + async def execute(self, sql: str, *args: str | int) -> None: + """Execute an sql statement.""" + async with self.db_connection.cursor() as cursor: + await cursor.execute(sql, args) + await self.db_connection.commit() + + async def fetch(self, sql: str, *args: str | int) -> tuple: + """Execute a sql statement and fetch the first row.""" + async with self.db_connection.cursor() as cursor: + await cursor.execute(sql, args) + await self.db_connection.commit() + return await cursor.fetchone() + + async def fetchmany(self, sql: str, *args: str | int, rows: int) -> list[tuple]: + """Execute a sql statement and fetch the first x number of rows.""" + async with self.db_connection.cursor() as cursor: + await cursor.execute(sql, args) + await self.db_connection.commit() + return await cursor.fetchmany(rows) + + async def fetchall(self, sql: str, *args: str | int) -> list[tuple]: + """Execute a sql statement and fetch all rows.""" + async with self.db_connection.cursor() as cursor: + await cursor.execute(sql, args) + await self.db_connection.commit() + return await cursor.fetchall() + + async def create_player(self, user_id: int) -> PlayerData | None: + """Create a row for a player in the database using user id.""" + try: + async with self.db_connection.cursor() as cursor: + await cursor.execute("INSERT INTO players_data (_id) VALUES (?) RETURNING *", (user_id,)) + data = await cursor.fetchone() + except aiosqlite.IntegrityError as error: + error_message = "Player Already Exists" + raise PlayerExistsError(error_message) from error + await self.db_connection.commit() + + if data is None: + return None + return PlayerData(data) # type: ignore[reportArgumentType] + + async def fetch_player(self, user_id: int) -> PlayerData: + """Fetch the data for a player in the database using user id. + + raises PlayerNotFoundError + """ + data = await self.fetch("SELECT * FROM players_data WHERE _id=?", user_id) + if data is None: + error_message = "Create an entry for the player first" + raise PlayerNotFoundError(error_message) + return PlayerData(data) + + async def delete_player(self, user_id: int) -> None: + """Delete the player data from the database.""" + await self.fetch_player(user_id) + await self.execute("DELETE FROM players_data WHERE _id=?", user_id) + + async def increase(self, user_id: int, value_name: str) -> None: + """Increase a certain value in the players data.""" + data = await self.fetch_player(user_id) + try: + value_data = getattr(data, value_name) + except Exception as error: + error_message = ( + "What value are you trying to change? These are available: shots_fired, hits, misses, wins, loses" + ) + raise UnknownValueError(error_message) from error + await self.execute("UPDATE players_data SET ?=? WHERE _id=?", value_name, value_data + 1, user_id) + + async def update_stats( # noqa: PLR0913 + self, + user_id: int, + shots_fired: int, + hits: int, + misses: int, + wins: int, + loses: int, + ) -> None: + await self.execute( + """ + UPDATE players_data + SET shots_fired=?, hits=?, misses=?, wins=?, loses=? + WHERE _id=? + """, + shots_fired, + hits, + misses, + wins, + loses, + user_id, + ) + + async def decrease(self, user_id: int, value_name: str) -> None: + """Decrease a certain value in the players data.""" + data = await self.fetch_player(user_id) + try: + value_data = getattr(data, value_name) + except Exception as error: + error_message = ( + "What value are you trying to change? These are available: shots_fired, hits, misses, wins, loses" + ) + raise UnknownValueError(error_message) from error + await self.execute("UPDATE players_data SET ?=? WHERE _id=?", value_name, value_data - 1, user_id) diff --git a/unique-universes/src/exts/__init__.py b/unique-universes/src/exts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unique-universes/src/exts/info.py b/unique-universes/src/exts/info.py new file mode 100644 index 0000000..80e106c --- /dev/null +++ b/unique-universes/src/exts/info.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import disnake +from disnake.ext import commands + +if TYPE_CHECKING: + from src.bot import Universe + + +class InfoCommands(commands.Cog): + def __init__(self, bot: Universe) -> None: + self.bot = bot + + @commands.slash_command() + async def about(self, inter: disnake.GuildCommandInteraction) -> None: + """Provide information about the bot.""" + cmd = self.bot.shoot_cmd + embed = disnake.Embed( + title="About", + description=( + "This Discord bot was created by the " + "Unique Universes team for the Python Discord Code Jam 2024.\n\n" + "This bot's main feature is a 2D shooter minigame." + f"{'Invoke ' if cmd is not None else ''}" + ), + color=0x87CEEB, + ) + embed.add_field( + name="Team members", + value="\\_\\_snipy__\nastroyo\nEarthKii\nMmesek\nnostradamus", + inline=False, + ) + embed.set_footer(text="Made by the Unique Universes team") + await inter.send(embed=embed) + + +def setup(bot: Universe) -> None: + bot.add_cog(InfoCommands(bot)) diff --git a/unique-universes/src/exts/minigames.py b/unique-universes/src/exts/minigames.py new file mode 100644 index 0000000..23709c1 --- /dev/null +++ b/unique-universes/src/exts/minigames.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +import disnake +from disnake.ext import commands +from src.views.shoot import ShootMenu + +if TYPE_CHECKING: + from src.bot import Universe + from src.views.shoot import ShootStats + + +__all__: tuple[str, ...] = ( + "generate_random_stats", + "Minigames", +) + + +def _generate_fake_mac() -> str: + tokens = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + seq = random.choices(tokens, k=16) # noqa: S311 + return ":".join([f"{seq[i]}{seq[i+1]}" for i in range(0, len(seq), 2)]) + + +def generate_random_stats(stats: ShootStats, *, skip_health: bool = False) -> None: + for attr in stats.__dataclass_fields__: + if attr in ("g_acc", "radians_angle", "ammunition", "angle", "total_shots", "hits"): + continue + + if attr in ("enemy_position",): + x = int(stats.calculate_shot_range()) + setattr(stats, attr, random.randint(x, x * 2 if x != 0 else 10)) # noqa: S311 + continue + + if attr in ("enemy_health",) and skip_health: + setattr(stats, attr, random.randint(1, 20)) # noqa: S311 + continue + + setattr(stats, attr, int(random.random() * 100)) # noqa: S311 + + +class Minigames(commands.Cog): + def __init__(self, bot: Universe) -> None: + self.bot = bot + + # disnake typing skill issue + @commands.slash_command() # type: ignore[reportUnknownMemberType] + async def shoot(self, inter: disnake.GuildCommandInteraction) -> None: + """Run an info overloaded shoot minigame.""" + player = await self.bot.database.fetch_player(inter.author.id) + view = ShootMenu(inter.author, player) + generate_random_stats(view.stats) + embed = disnake.Embed(title="Shoot minigame", description="\n".join(["." * 10] * 5)) + embed.add_field("Planet acceleration", f"{round(view.stats.g_acc, 2)} m/s^2") + embed.add_field("Position", f"{view.stats.position}") + embed.add_field("Angle", f"{view.stats.angle}") + embed.add_field("Ammunition (Shots left)", f"{view.stats.ammunition}") + embed.add_field("Energy (Moves left)", f"{view.stats.energy}") + embed.add_field("Ship Speed", f"{view.stats.ship_speed}") + embed.add_field("Bullet Velocity", f"{view.stats.bullet_velocity}") + embed.add_field("Bullet Type", f"{view.stats.bullet_type}") + + embed.add_field("Obstacles in range", f"{view.stats.obstacles_in_range}") + embed.add_field("Outer Space Pression", "1.32 x 10^-11 Pa") + + embed.add_field("Enemy Position", f"{view.stats.enemy_position}") + embed.add_field("Enemy Health", f"{view.stats.enemy_health}") + embed.add_field("Enemy Energy", f"{view.stats.enemy_energy}") + + embed.add_field("Enemy has VIP Pass (+100 to Pay 2 Win)", f"{view.stats.enemy_has_vip_pass}") + embed.add_field("Enemy logins in a row", f"{view.stats.enemy_logins_in_a_row}") + + embed.add_field("Ship insured", "Only below 0-e2 of skin damage") + embed.add_field("Gun cleaned", f"{random.randint(1, 10_000)} days ago") # noqa: S311 + embed.add_field("Engines checked", f"{random.randint(1, 100)} years ago") # noqa: S311 + + embed.add_field("Serial Number", f"10-{str(inter.user.id).replace('4', 'A').replace('3', 'E')}-A") + # yeah you're basically fighting against yourself, enjoy it + embed.add_field("Enemy Serial Number", f"10-{str(inter.user.id*3).replace('4', 'A').replace('3', 'E')}-A") + embed.add_field("Manufacturer", "Legit Stuff™") + embed.add_field("MAC address", _generate_fake_mac()) + embed.add_field( + "Your stats", + ( + f"Wins: {player.wins}\n" + f"Losses: {player.loses}\n" + f"Total Shots: {player.shots_fired}\n" + f"Total Hits: {player.hits}\n" + f"Total Shots Missed: {player.misses}\n" + ), + ) + embed.set_footer(text=f"Total shots: {view.stats.total_shots}") + + await inter.send(embed=embed, view=view) + view.message = await inter.original_message() + + +def setup(bot: Universe) -> None: + bot.add_cog(Minigames(bot)) diff --git a/unique-universes/src/logger.py b/unique-universes/src/logger.py new file mode 100644 index 0000000..57679d6 --- /dev/null +++ b/unique-universes/src/logger.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING, ClassVar + +import colorama +from src.constants import Config + +if TYPE_CHECKING: + from src.constants import LoggingLevel + +__all__: tuple[str, ...] = ("setup_logging",) + + +def setup_logging() -> None: + logger = logging.getLogger("disnake") + logger.setLevel(Config.LOGGING_LEVEL) + handler = logging.StreamHandler(stream=sys.stdout) + handler.setFormatter(LogFormatter()) + logger.addHandler(handler) + + for k, v in logger.manager.loggerDict.items(): + if k.startswith(("src", "__main__")) and isinstance(v, logging.Logger): + v.setLevel(Config.LOGGING_LEVEL) + v.addHandler(handler) + + +class LogFormatter(logging.Formatter): + COLOR_MAP: ClassVar[dict[LoggingLevel, str]] = { + logging.DEBUG: colorama.Fore.MAGENTA, + logging.INFO: colorama.Fore.BLUE, + logging.WARNING: colorama.Fore.YELLOW, + logging.ERROR: colorama.Fore.RED, + logging.CRITICAL: colorama.Fore.BLACK, + } + + def __init__(self) -> None: + super().__init__("[%(asctime)s:%(levelname)s:%(name)s] | %(message)s") + + def format(self, record: logging.LogRecord) -> str: + record.levelname = f"{self.COLOR_MAP.get(record.levelno)}{record.levelname}{colorama.Fore.RESET}" + return super().format(record) diff --git a/unique-universes/src/views/__init__.py b/unique-universes/src/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unique-universes/src/views/shoot.py b/unique-universes/src/views/shoot.py new file mode 100644 index 0000000..16b3f98 --- /dev/null +++ b/unique-universes/src/views/shoot.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import dataclasses +import math +import random +from asyncio import sleep +from typing import TYPE_CHECKING, cast + +import disnake + +# weird import but it's to avoid circular imports +import src.exts.minigames as games +from src.bot import Universe +from src.database import PlayerExistsError + +if TYPE_CHECKING: + from src.database import PlayerData + + +# acceleration directed to the center of Earth +# measured in m/s^2 +EARTH_ACCELERATION = 9.81 +# same thing, just for the moon +MOON_ACCELERATION = 1.62 + + +class AngleModal(disnake.ui.Modal): + def __init__(self, view: ShootMenu) -> None: + self.view = view + + super().__init__( + title="Angle Input", + components=[ + disnake.ui.TextInput( + label="Angle degrees", + custom_id="angle_deg", + style=disnake.TextInputStyle.short, + placeholder="15", + value=str(self.view.stats.angle), + min_length=1, + max_length=3, + ), + ], + timeout=180, + ) + + async def on_timeout(self) -> None: + await self.view.on_timeout() + self.view.stop() + + async def callback(self, interaction: disnake.ModalInteraction) -> None: + angle = interaction.text_values["angle_deg"] + if not angle.isdigit(): + return await interaction.send("Invalid input! You can type only numbers (e.g 10, 45 ...)", ephemeral=True) + + self.view.stats.angle = int(angle) + await interaction.send(f"Your new angle is {angle}", ephemeral=True) + return await self.view.update_message() + + +@dataclasses.dataclass +class ShootStats: + position: int = 0 + angle: int = 15 + ammunition: int = 5 # shots left + energy: int = 10 # angles adjustments left + ship_speed: int = 0 + bullet_velocity: int = 0 + bullet_type: str = "?" + obstacles_in_range: int = 8 + enemy_position: int = 10 + enemy_health: int = 10 + enemy_energy: int = 5 + enemy_has_vip_pass: bool = False + enemy_logins_in_a_row: int = 3 + total_shots: int = 0 + hits: int = 0 + g_acc: float = dataclasses.field( + default_factory=lambda: random.uniform( # noqa: S311 + MOON_ACCELERATION, + EARTH_ACCELERATION, + ), + ) + + @property + def misses(self) -> int: + return self.total_shots - self.hits + + @property + def angle_as_radians(self) -> float: + return math.radians(self.angle) + + @property + def get_enemy_distance(self) -> int: + return abs(self.enemy_position - self.position) + + def calculate_shot_range(self) -> float: + return ( + 2 * self.bullet_velocity * math.cos(self.angle_as_radians) * math.sin(self.angle_as_radians) + ) / self.g_acc + + def calculate_max_possible_range(self) -> float: + return (self.bullet_velocity**2) / self.g_acc + + def calculate_flight_time(self) -> float: + return (2 * self.bullet_velocity * math.sin(self.angle_as_radians)) / self.g_acc + + def calculate_max_height(self) -> float: + return ((self.bullet_velocity**2) * (math.sin(self.angle_as_radians) ** 2)) / (2 * self.g_acc) + + @property + def enemy_hitted(self) -> bool: + return int(self.calculate_shot_range()) == self.enemy_position + + +class ShootMenu(disnake.ui.View): + message: disnake.InteractionMessage + + def __init__(self, author: disnake.Member, player: PlayerData) -> None: + super().__init__(timeout=None) + self.author = author + self.cached_player = player + self.stats = ShootStats() + + async def on_timeout(self) -> None: + # disnake typing skill issue + for item in self.children: # type: ignore[reportUnknownMemberType] + item.disabled = True # type: ignore[reportAttributeAccessIssue] + await self.message.edit( + embed=self.message.embeds[0].set_footer(text="View expired!!"), + view=self, + ) + + async def interaction_check(self, interaction: disnake.MessageInteraction) -> bool: + if interaction.author == self.author: + return True + + await interaction.send("This component is not for you!", ephemeral=True) + return False + + async def update_angle(self, inter: disnake.MessageInteraction) -> None: + await self.message.edit( + embed=self.message.embeds[0].set_field_at( + 1, + name="Angle", + value=f"{self.stats.angle}", + ), + ) + await inter.send(f"Your new angle is {self.stats.angle}", ephemeral=True) + + async def update_message(self) -> None: + # yk, black magic shit to not update the fields manually + for field in self.message.embeds[0]._fields: # type: ignore[reportPrivateUsage] + # fields that shouldn't be updated + if field["name"] in ( + "Outer Space Pression", + "Ship insured", + "Gun cleaned", + "Engines checked", + "Serial Number", + "Enemy Serial Number", + "Manufacturer", + "MAC address", + "Planet acceleration", + ): + continue + + field_name = field["name"] + # fields whose name is different from the stats class + # and as such needs a lil' transformation + if field_name == "Your stats": + field["value"] = ( + f"Wins: {self.cached_player.wins}\n" + f"Losses: {self.cached_player.loses}\n" + f"Total Shots: {self.cached_player.shots_fired + self.stats.total_shots}\n" + f"Total Hits: {self.cached_player.hits + self.stats.hits}\n" + f"Total Shots Missed: {self.cached_player.misses + self.stats.misses}" + ) + continue + + if field_name in ( + "Ammunition (Shots left)", + "Energy (Moves left)", + "Enemy has VIP Pass (+100 to Pay 2 Win)", + ): + field_name = field_name[: field_name.find("(") - 1] + field["value"] = getattr(self.stats, field_name.lower().replace(" ", "_")) + self.message.embeds[0].set_footer(text=f"Current Game Total Shots: {self.stats.total_shots}") + await self.message.edit(embed=self.message.embeds[0], view=self) + + @disnake.ui.button(style=disnake.ButtonStyle.gray, label="Angle", disabled=True) + async def angle_label(self, _: disnake.ui.Button[ShootMenu], __: disnake.MessageInteraction) -> None: + # this button serves as a label + return + + @disnake.ui.button(style=disnake.ButtonStyle.green, emoji="➕") # noqa: RUF001 + async def angle_plus(self, _: disnake.ui.Button[ShootMenu], inter: disnake.MessageInteraction) -> None: + if self.stats.angle == 180: # noqa: PLR2004 + self.stats.angle = 1 + else: + self.stats.angle += 1 + await self.update_angle(inter) + + @disnake.ui.button(style=disnake.ButtonStyle.danger, emoji="➖") # noqa: RUF001 + async def angle_minus(self, _: disnake.ui.Button[ShootMenu], inter: disnake.MessageInteraction) -> None: + if self.stats.angle == 0: + self.stats.angle = 179 + else: + self.stats.angle -= 1 + await self.update_angle(inter) + + @disnake.ui.button(style=disnake.ButtonStyle.gray, label="Write angle") + async def angle_in(self, _: disnake.ui.Button[ShootMenu], inter: disnake.MessageInteraction) -> None: + await inter.response.send_modal(modal=AngleModal(self)) + + async def stop_game(self) -> None: + # we manually call the on timeout to disable the view + await self.on_timeout() + # cancel all scheduled timeout tasks and interaction listeners + # for this view + self.stop() + + @disnake.ui.button(style=disnake.ButtonStyle.danger, label="Shoot", row=1) + async def shoot_callback(self, _: disnake.ui.Button[ShootMenu], inter: disnake.MessageInteraction) -> None: + bot = cast(Universe, inter.bot) + + try: + player = await bot.database.create_player(inter.author.id) + except PlayerExistsError: + player = None + + if player is None: + player = await bot.database.fetch_player(inter.author.id) + + if self.stats.ammunition != 0: + self.stats.total_shots += 1 + self.stats.ammunition -= 1 + await inter.send( + ( + f"You are taking your shot at degree {int(self.stats.angle)} " + f"with a max height of {int(self.stats.calculate_max_height())} meters " + f"your shot will land at {int(self.stats.calculate_shot_range())} meters of distance " + f"flying for {round(self.stats.calculate_flight_time(), 2)} seconds." + ), + ephemeral=True, + ) + await sleep(0.5) + + if self.stats.enemy_hitted: + await inter.send( + ( + "You hitted the enemy!! Decreasing enemy health, regenerating " + "5 energy and giving 1 ammunition as reward!" + ), + ephemeral=True, + ) + self.stats.hits += 1 + self.stats.enemy_health -= 2 + self.stats.energy += 5 + self.stats.ammunition += 1 + + if self.stats.enemy_health <= 0: + self.stats.enemy_health = 0 + await self.stop_game() + await inter.send("You Won!!!!", ephemeral=True) + await bot.database.update_stats( + inter.author.id, + self.stats.total_shots, + self.stats.hits, + self.stats.misses, + player.wins + 1, + player.loses, + ) + else: + await inter.send( + "Unfortunately you didn't hit the enemy!! Try to change the shoot angle!", + ephemeral=True, + ) + # to make the game a little bit more enjoyable we change the stats every time the user shoot + games.generate_random_stats(self.stats, skip_health=True) + await self.update_message() + + if self.stats.ammunition == 0: + await self.stop_game() + await inter.send("Game Over :'(", ephemeral=True) + await bot.database.update_stats( + inter.author.id, + self.stats.total_shots, + self.stats.hits, + self.stats.misses, + player.wins, + player.loses + 1, + ) + else: + await inter.send("You can't shoot because you're out of ammunition.")