From 6aa6f0af8a8aa725fce861dad09f22101b82c9c5 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 15 Jul 2024 23:15:51 +0200 Subject: [PATCH 01/31] Initial commit --- .github/workflows/lint.yaml | 35 +++++++ .gitignore | 31 ++++++ .pre-commit-config.yaml | 18 ++++ LICENSE.txt | 7 ++ README.md | 184 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 44 +++++++++ requirements-dev.txt | 6 ++ samples/Pipfile | 15 +++ samples/pyproject.toml | 19 ++++ 9 files changed, 359 insertions(+) create mode 100644 .github/workflows/lint.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 samples/Pipfile create mode 100644 samples/pyproject.toml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..7f67e80 --- /dev/null +++ b/.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/.gitignore b/.gitignore new file mode 100644 index 0000000..233eb87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ + +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +# MacOS +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4bccb6f --- /dev/null +++ b/.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/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5a04926 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Python Discord + +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/README.md b/README.md new file mode 100644 index 0000000..d50f7b7 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# Python Discord Code Jam Repository Template + +## A primer + +Hello code jam participants! We've put together this repository template for you to use in [our code jams](https://pythondiscord.com/events/) or even other Python events! + +This document contains the following information: + +1. [What does this template contain?](#what-does-this-template-contain) +2. [How do I use this template?](#how-do-i-use-this-template) +3. [How do I adapt this template to my project?](#how-do-i-adapt-this-template-to-my-project) + +> [!TIP] +> You can also look at [our style guide](https://pythondiscord.com/events/code-jams/code-style-guide/) to get more information about what we consider a maintainable code style. + +## What does this template contain? + +Here is a quick rundown of what each file in this repository contains: + +- [`LICENSE.txt`](LICENSE.txt): [The MIT License](https://opensource.org/licenses/MIT), an OSS approved license which grants rights to everyone to use and modify your project, and limits your liability. We highly recommend you to read the license. +- [`.gitignore`](.gitignore): A list of files and directories that will be ignored by Git. Most of them are auto-generated or contain data that you wouldn't want to share publicly. +- [`requirements-dev.txt`](requirements-dev.txt): Every PyPI package used for the project's development, to ensure a common development environment. More on that [below](#using-the-default-pip-setup). +- [`pyproject.toml`](pyproject.toml): Configuration and metadata for the project, as well as the linting tool Ruff. If you're interested, you can read more about `pyproject.toml` in the [Python Packaging documentation](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). +- [`.pre-commit-config.yaml`](.pre-commit-config.yaml): The configuration of the [pre-commit](https://pre-commit.com/) tool. +- [`.github/workflows/lint.yaml`](.github/workflows/lint.yaml): A [GitHub Actions](https://github.com/features/actions) workflow, a set of actions run by GitHub on their server after each push, to ensure the style requirements are met. + +Each of these files have comments for you to understand easily, and modify to fit your needs. + +### Ruff: general style rules + +Our first tool is Ruff. It will check your codebase and warn you about any non-conforming lines. +It is run with the command `ruff check` in the project root. + +Here is a sample output: + +```shell +$ ruff check +app.py:1:5: N802 Function name `helloWorld` should be lowercase +app.py:1:5: ANN201 Missing return type annotation for public function `helloWorld` +app.py:2:5: D400 First line should end with a period +app.py:2:5: D403 First word of the first line should be capitalized: `docstring` -> `Docstring` +app.py:3:15: W292 No newline at end of file +Found 5 errors. +``` + +Each line corresponds to an error. The first part is the file path, then the line number, and the column index. +Then comes the error code, a unique identifier of the error, and then a human-readable message. + +If, for any reason, you do not wish to comply with this specific error on a specific line, you can add `# noqa: CODE` at the end of the line. +For example: + +```python +def helloWorld(): # noqa: N802 + ... + +``` + +This will ignore the function naming issue and pass linting. + +> [!WARNING] +> We do not recommend ignoring errors unless you have a good reason to do so. + +### Ruff: formatting + +Ruff also comes with a formatter, which can be run with the command `ruff format`. +It follows the same code style enforced by [Black](https://black.readthedocs.io/en/stable/index.html), so there's no need to pick between them. + +### Pre-commit: run linting before committing + +The second tool doesn't check your code, but rather makes sure that you actually *do* check it. + +It makes use of a feature called [Git hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) which allow you to run a piece of code before running `git commit`. +The good thing about it is that it will cancel your commit if the lint doesn't pass. You won't have to wait for GitHub Actions to report issues and have a second fix commit. + +It is *installed* by running `pre-commit install` and can be run manually by calling only `pre-commit`. + +[Lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push) + +#### List of hooks + +- `check-toml`: Lints and corrects your TOML files. +- `check-yaml`: Lints and corrects your YAML files. +- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. +- `trailing-whitespace`: Removes whitespaces at the end of each line. +- `ruff`: Runs the Ruff linter. +- `ruff-format`: Runs the Ruff formatter. + +## How do I use this template? + +### Creating your team repository + +One person in the team, preferably the leader, will have to create the repository and add other members as collaborators. + +1. In the top right corner of your screen, where **Clone** usually is, you have a **Use this template** button to click. + ![use-this-template-button](https://docs.github.com/assets/images/help/repository/use-this-template-button.png) +2. Give the repository a name and a description. + ![create-repository-name](https://docs.github.com/assets/images/help/repository/create-repository-name.png) +3. Click **Create repository from template**. +4. Click **Settings** in your newly created repository. + ![repo-actions-settings](https://docs.github.com/assets/images/help/repository/repo-actions-settings.png) +5. In the "Access" section of the sidebar, click **Collaborators**. + ![collaborators-settings](https://github.com/python-discord/code-jam-template/assets/63936253/c150110e-d1b5-4e4d-93e0-0a2cf1de352b) +6. Click **Add people**. +7. Insert the names of each of your teammates, and invite them. Once they have accepted the invitation in their email, they will have write access to the repository. + +You are now ready to go! Sit down, relax, and wait for the kickstart! + +> [!IMPORTANT] +> Don't forget to swap "Python Discord" in the [`LICENSE.txt`](LICENSE.txt) file for the name of each of your team members or the name of your team *after* the start of the code jam. + +### Using the default pip setup + +Our default setup includes a bare requirements file to be used with a [virtual environment](https://docs.python.org/3/library/venv.html). +We recommend this if you have never used any other dependency manager, although if you have, feel free to switch to it. More on that [below](#how-do-i-adapt-this-template-to-my-project). + +#### Creating the environment + +Create a virtual environment in the folder `.venv`. + +```shell +python -m venv .venv +``` + +#### Entering the environment + +It will change based on your operating system and shell. + +```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 +``` + +#### Installing the dependencies + +Once the environment is created and activated, use this command to install the development dependencies. + +```shell +pip install -r requirements-dev.txt +``` + +#### Exiting the environment + +Interestingly enough, it is the same for every platform. + +```shell +deactivate +``` + +Once the environment is activated, all the commands listed previously should work. + +> [!IMPORTANT] +> We highly recommend that you run `pre-commit install` as soon as possible. + +## How do I adapt this template to my project? + +If you wish to use Pipenv or Poetry, you will have to move the dependencies in [`requirements-dev.txt`](requirements-dev.txt) to the development dependencies of your tool. + +We've included a porting of [`requirements-dev.txt`](requirements-dev.txt) to both [Poetry](samples/pyproject.toml) and [Pipenv](samples/Pipfile) in the [`samples` folder](samples). +If you use the Poetry setup, make sure to change the project name, description, and authors at the top of the file. +Also note that the Poetry [`pyproject.toml`](samples/pyproject.toml) file does not include the Ruff configuration, so if you simply replace the file then the Ruff configuration will be lost. + +When installing new dependencies, don't forget to [pin](https://pip.pypa.io/en/stable/topics/repeatable-installs/#pinning-the-package-versions) them by adding a version tag at the end. +For example, if I wish to install [Click](https://click.palletsprojects.com/en/8.1.x/), a quick look at [PyPI](https://pypi.org/project/click/) tells me that `8.1.7` is the latest version. +I will then add `click~=8.1`, without the last number, to my requirements file or dependency manager. + +> [!IMPORTANT] +> A code jam project is left unmaintained after the end of the event. If the dependencies aren't pinned, the project will break after any major change in an API. + +## Final words + +> [!IMPORTANT] +> Don't forget to replace this README with an actual description of your project! Images are also welcome! + +We hope this template will be helpful. Good luck in the jam! diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0880be9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[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 = 119 +# 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", + "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/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..d529f2e --- /dev/null +++ b/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/samples/Pipfile b/samples/Pipfile new file mode 100644 index 0000000..27673c0 --- /dev/null +++ b/samples/Pipfile @@ -0,0 +1,15 @@ +# Sample Pipfile. + +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +ruff = "~=0.5.0" +pre-commit = "~=3.7.1" + +[requires] +python_version = "3.12" diff --git a/samples/pyproject.toml b/samples/pyproject.toml new file mode 100644 index 0000000..835045d --- /dev/null +++ b/samples/pyproject.toml @@ -0,0 +1,19 @@ +# Sample poetry configuration. + +[tool.poetry] +name = "Name" +version = "0.1.0" +description = "Description" +authors = ["Author 1 "] +license = "MIT" + +[tool.poetry.dependencies] +python = "3.12.*" + +[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" From c3d3d91ce2a27cacba2f1e8c35263b56a7275f9d Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 15 Jul 2024 23:42:26 +0200 Subject: [PATCH 02/31] update README, LICENSE and pyproject --- LICENSE.txt | 2 +- README.md | 176 ++++++++++--------------------------------------- pyproject.toml | 20 +++++- 3 files changed, 56 insertions(+), 142 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 5a04926..d8b6baf 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright 2021 Python Discord +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: diff --git a/README.md b/README.md index d50f7b7..3615d4b 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,36 @@ -# Python Discord Code Jam Repository Template +# Unique Universes CJ24 team entry -## A primer +This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. -Hello code jam participants! We've put together this repository template for you to use in [our code jams](https://pythondiscord.com/events/) or even other Python events! +# Setting Up the Dev Env -This document contains the following information: +If you are a team member make sure to read this section, otherwise you can skip this. -1. [What does this template contain?](#what-does-this-template-contain) -2. [How do I use this template?](#how-do-i-use-this-template) -3. [How do I adapt this template to my project?](#how-do-i-adapt-this-template-to-my-project) +> [!NOTE] +> The steps listed here needs to be done only the first time when setting up the project locally. -> [!TIP] -> You can also look at [our style guide](https://pythondiscord.com/events/code-jams/code-style-guide/) to get more information about what we consider a maintainable code style. +## Required Python version -## What does this template contain? +This project requires python 3.12 to work. If you don't have it installed proceed to install it from the official python website. -Here is a quick rundown of what each file in this repository contains: +## Creating and activating a venv (virtual environment) -- [`LICENSE.txt`](LICENSE.txt): [The MIT License](https://opensource.org/licenses/MIT), an OSS approved license which grants rights to everyone to use and modify your project, and limits your liability. We highly recommend you to read the license. -- [`.gitignore`](.gitignore): A list of files and directories that will be ignored by Git. Most of them are auto-generated or contain data that you wouldn't want to share publicly. -- [`requirements-dev.txt`](requirements-dev.txt): Every PyPI package used for the project's development, to ensure a common development environment. More on that [below](#using-the-default-pip-setup). -- [`pyproject.toml`](pyproject.toml): Configuration and metadata for the project, as well as the linting tool Ruff. If you're interested, you can read more about `pyproject.toml` in the [Python Packaging documentation](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). -- [`.pre-commit-config.yaml`](.pre-commit-config.yaml): The configuration of the [pre-commit](https://pre-commit.com/) tool. -- [`.github/workflows/lint.yaml`](.github/workflows/lint.yaml): A [GitHub Actions](https://github.com/features/actions) workflow, a set of actions run by GitHub on their server after each push, to ensure the style requirements are met. +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. -Each of these files have comments for you to understand easily, and modify to fit your needs. - -### Ruff: general style rules - -Our first tool is Ruff. It will check your codebase and warn you about any non-conforming lines. -It is run with the command `ruff check` in the project root. - -Here is a sample output: +To create a virtual environment run the following command in your terminal: ```shell -$ ruff check -app.py:1:5: N802 Function name `helloWorld` should be lowercase -app.py:1:5: ANN201 Missing return type annotation for public function `helloWorld` -app.py:2:5: D400 First line should end with a period -app.py:2:5: D403 First word of the first line should be capitalized: `docstring` -> `Docstring` -app.py:3:15: W292 No newline at end of file -Found 5 errors. +python3 -m venv .venv ``` -Each line corresponds to an error. The first part is the file path, then the line number, and the column index. -Then comes the error code, a unique identifier of the error, and then a human-readable message. - -If, for any reason, you do not wish to comply with this specific error on a specific line, you can add `# noqa: CODE` at the end of the line. -For example: - -```python -def helloWorld(): # noqa: N802 - ... - -``` - -This will ignore the function naming issue and pass linting. - -> [!WARNING] -> We do not recommend ignoring errors unless you have a good reason to do so. - -### Ruff: formatting - -Ruff also comes with a formatter, which can be run with the command `ruff format`. -It follows the same code style enforced by [Black](https://black.readthedocs.io/en/stable/index.html), so there's no need to pick between them. - -### Pre-commit: run linting before committing - -The second tool doesn't check your code, but rather makes sure that you actually *do* check it. - -It makes use of a feature called [Git hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) which allow you to run a piece of code before running `git commit`. -The good thing about it is that it will cancel your commit if the lint doesn't pass. You won't have to wait for GitHub Actions to report issues and have a second fix commit. - -It is *installed* by running `pre-commit install` and can be run manually by calling only `pre-commit`. - -[Lint before you push!](https://soundcloud.com/lemonsaurusrex/lint-before-you-push) - -#### List of hooks - -- `check-toml`: Lints and corrects your TOML files. -- `check-yaml`: Lints and corrects your YAML files. -- `end-of-file-fixer`: Makes sure you always have an empty line at the end of your file. -- `trailing-whitespace`: Removes whitespaces at the end of each line. -- `ruff`: Runs the Ruff linter. -- `ruff-format`: Runs the Ruff formatter. - -## How do I use this template? - -### Creating your team repository - -One person in the team, preferably the leader, will have to create the repository and add other members as collaborators. - -1. In the top right corner of your screen, where **Clone** usually is, you have a **Use this template** button to click. - ![use-this-template-button](https://docs.github.com/assets/images/help/repository/use-this-template-button.png) -2. Give the repository a name and a description. - ![create-repository-name](https://docs.github.com/assets/images/help/repository/create-repository-name.png) -3. Click **Create repository from template**. -4. Click **Settings** in your newly created repository. - ![repo-actions-settings](https://docs.github.com/assets/images/help/repository/repo-actions-settings.png) -5. In the "Access" section of the sidebar, click **Collaborators**. - ![collaborators-settings](https://github.com/python-discord/code-jam-template/assets/63936253/c150110e-d1b5-4e4d-93e0-0a2cf1de352b) -6. Click **Add people**. -7. Insert the names of each of your teammates, and invite them. Once they have accepted the invitation in their email, they will have write access to the repository. +> [!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 are now ready to go! Sit down, relax, and wait for the kickstart! +you can replace `.venv` in the commands with a path (e.g `exaple_folder/.venv`) if needed. If you provide only `.venv` python will create the environment in the same directory where you are running the command. -> [!IMPORTANT] -> Don't forget to swap "Python Discord" in the [`LICENSE.txt`](LICENSE.txt) file for the name of each of your team members or the name of your team *after* the start of the code jam. - -### Using the default pip setup - -Our default setup includes a bare requirements file to be used with a [virtual environment](https://docs.python.org/3/library/venv.html). -We recommend this if you have never used any other dependency manager, although if you have, feel free to switch to it. More on that [below](#how-do-i-adapt-this-template-to-my-project). - -#### Creating the environment - -Create a virtual environment in the folder `.venv`. - -```shell -python -m venv .venv -``` - -#### Entering the environment - -It will change based on your operating system and shell. +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 @@ -140,45 +47,34 @@ $ .venv/bin/Activate.ps1 > .venv\Scripts\Activate.ps1 ``` -#### Installing the dependencies +To deactivate the venv type `deactivate` in the terminal. -Once the environment is created and activated, use this command to install the development dependencies. +## Installing the dependencies + +To install all the required dependencies to run the bot execute these commands: ```shell -pip install -r requirements-dev.txt +pip install poetry ``` - -#### Exiting the environment - -Interestingly enough, it is the same for every platform. - +and then ```shell -deactivate +poetry install +``` +finally run +```shell +pre-commit install ``` -Once the environment is activated, all the commands listed previously should work. - -> [!IMPORTANT] -> We highly recommend that you run `pre-commit install` as soon as possible. - -## How do I adapt this template to my project? - -If you wish to use Pipenv or Poetry, you will have to move the dependencies in [`requirements-dev.txt`](requirements-dev.txt) to the development dependencies of your tool. +## Creating the .env file -We've included a porting of [`requirements-dev.txt`](requirements-dev.txt) to both [Poetry](samples/pyproject.toml) and [Pipenv](samples/Pipfile) in the [`samples` folder](samples). -If you use the Poetry setup, make sure to change the project name, description, and authors at the top of the file. -Also note that the Poetry [`pyproject.toml`](samples/pyproject.toml) file does not include the Ruff configuration, so if you simply replace the file then the Ruff configuration will be lost. +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"). -When installing new dependencies, don't forget to [pin](https://pip.pypa.io/en/stable/topics/repeatable-installs/#pinning-the-package-versions) them by adding a version tag at the end. -For example, if I wish to install [Click](https://click.palletsprojects.com/en/8.1.x/), a quick look at [PyPI](https://pypi.org/project/click/) tells me that `8.1.7` is the latest version. -I will then add `click~=8.1`, without the last number, to my requirements file or dependency manager. +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: -> [!IMPORTANT] -> A code jam project is left unmaintained after the end of the event. If the dependencies aren't pinned, the project will break after any major change in an API. +``` +BOT_TOKEN=ExampleOfBotTokenHere +``` ## Final words -> [!IMPORTANT] -> Don't forget to replace this README with an actual description of your project! Images are also welcome! - -We hope this template will be helpful. Good luck in the jam! +Now you're ready to go, your local copy of the repository is ready to be ran. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0880be9..d3488f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,26 @@ +[tool.poetry] +name = "CJ24 Unique Universes" +version = "0.1.0" +description = "Description" +authors = ["Snipy7374 "] +license = "MIT" + +[tool.poetry.dependencies] +python = "3.12.*" + +[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 = 119 +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" From dd2274e9dcd427baa54e10bb5b701f0b827674fa Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 15 Jul 2024 23:45:24 +0200 Subject: [PATCH 03/31] update gitignore to ignore lock file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 233eb87..117f58a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ build/ .vscode/ # MacOS .DS_Store + +# lock file +poetry.lock \ No newline at end of file From d4eb5bd11f33edfadfa0cec96e138843ad2d9143 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Mon, 15 Jul 2024 23:55:48 +0200 Subject: [PATCH 04/31] lint and update gitignore --- .gitignore | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 117f58a..c1a737a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ build/ .DS_Store # lock file -poetry.lock \ No newline at end of file +poetry.lock diff --git a/README.md b/README.md index 3615d4b..1b505d5 100644 --- a/README.md +++ b/README.md @@ -77,4 +77,4 @@ BOT_TOKEN=ExampleOfBotTokenHere ## Final words -Now you're ready to go, your local copy of the repository is ready to be ran. \ No newline at end of file +Now you're ready to go, your local copy of the repository is ready to be ran. From 10cbb1d4016033c4680f3f2d576d444915100bc7 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 20 Jul 2024 00:00:29 +0200 Subject: [PATCH 05/31] implement bot core structure --- src/__main__.py | 13 ++++++++++++ src/bot.py | 31 +++++++++++++++++++++++++++++ src/constants.py | 23 ++++++++++++++++++++++ src/exts/__init__.py | 0 src/logger.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 src/__main__.py create mode 100644 src/bot.py create mode 100644 src/constants.py create mode 100644 src/exts/__init__.py create mode 100644 src/logger.py diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..da5d832 --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,13 @@ +import asyncio + +from src.bot import Universe +from src.constants import EnvVars + + +async def main() -> None: + bot = Universe() + await bot.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..0b7e3d9 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import logging + +import disnake +from disnake.ext import commands + +from src.constants import EnvVars +from src.logger import setup_logging + +__all__: tuple[str] = ( + "Universe", +) + +_log = logging.getLogger(__name__) + + +class Universe(commands.InteractionBot): + def __init__(self) -> None: + super().__init__( + intents=disnake.Intents.none(), + ) + + + async def on_ready(self) -> None: + _log.info(f"Logged in as {self.user}") + + async def start(self) -> None: + setup_logging() + await super().start(EnvVars.BOT_TOKEN, reconnect=True) + diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..c3ace51 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,23 @@ + +from typing import TypeAlias + +import os + +import dotenv + +LoggingLevel: TypeAlias = int | str +dotenv.load_dotenv() # type: ignore + +__all__: tuple[str, ...] = ( + "EnvVars", + "Config", +) + + +class EnvVars: + BOT_TOKEN: str = os.getenv("BOT_TOKEN") or "" + + +class Config: + DEBUG: bool = True + LOGGING_LEVEL: LoggingLevel = "INFO" diff --git a/src/exts/__init__.py b/src/exts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..f755d0c --- /dev/null +++ b/src/logger.py @@ -0,0 +1,47 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +import sys +import logging + +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") and isinstance(v, logging.Logger): + v.setLevel(Config.LOGGING_LEVEL) + v.addHandler(handler) + + +class LogFormatter(logging.Formatter): + COLOR_MAP: 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) From 0d173b2a316723a69fa52d63d71c2ffe1dcf6d4e Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 20 Jul 2024 00:03:25 +0200 Subject: [PATCH 06/31] add depencendcie --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d3488f2..babd1c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,9 @@ license = "MIT" [tool.poetry.dependencies] python = "3.12.*" +disnake = "^2.9.2" +colorama = "^0.4.6" +python-dotenv = "^1.0.1" [tool.poetry.dev-dependencies] ruff = "~0.5.0" From 553264be9c0cc8d5a02d2bd11fe49218714347e0 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 20 Jul 2024 00:10:34 +0200 Subject: [PATCH 07/31] lint and reformat --- pyproject.toml | 3 +++ src/__main__.py | 1 - src/bot.py | 9 ++------- src/constants.py | 7 ++----- src/logger.py | 12 ++++-------- 5 files changed, 11 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index babd1c4..2b1811f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ select = ["ALL"] ignore = [ # Missing docstrings. "D100", + "D101", + "D102", + "D103", "D104", "D105", "D106", diff --git a/src/__main__.py b/src/__main__.py index da5d832..f48742a 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,7 +1,6 @@ import asyncio from src.bot import Universe -from src.constants import EnvVars async def main() -> None: diff --git a/src/bot.py b/src/bot.py index 0b7e3d9..34c83ea 100644 --- a/src/bot.py +++ b/src/bot.py @@ -4,13 +4,10 @@ import disnake from disnake.ext import commands - from src.constants import EnvVars from src.logger import setup_logging -__all__: tuple[str] = ( - "Universe", -) +__all__: tuple[str] = ("Universe",) _log = logging.getLogger(__name__) @@ -20,12 +17,10 @@ def __init__(self) -> None: super().__init__( intents=disnake.Intents.none(), ) - async def on_ready(self) -> None: _log.info(f"Logged in as {self.user}") - + async def start(self) -> None: setup_logging() await super().start(EnvVars.BOT_TOKEN, reconnect=True) - diff --git a/src/constants.py b/src/constants.py index c3ace51..40ca1e2 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,12 +1,9 @@ - -from typing import TypeAlias - import os import dotenv -LoggingLevel: TypeAlias = int | str -dotenv.load_dotenv() # type: ignore +type LoggingLevel = int | str +dotenv.load_dotenv() __all__: tuple[str, ...] = ( "EnvVars", diff --git a/src/logger.py b/src/logger.py index f755d0c..465f5fa 100644 --- a/src/logger.py +++ b/src/logger.py @@ -1,19 +1,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING -import sys 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", -) +__all__: tuple[str, ...] = ("setup_logging",) def setup_logging() -> None: @@ -22,7 +19,6 @@ def setup_logging() -> None: handler = logging.StreamHandler(stream=sys.stdout) handler.setFormatter(LogFormatter()) logger.addHandler(handler) - for k, v in logger.manager.loggerDict.items(): if k.startswith("src") and isinstance(v, logging.Logger): @@ -31,7 +27,7 @@ def setup_logging() -> None: class LogFormatter(logging.Formatter): - COLOR_MAP: dict[LoggingLevel, str] = { + COLOR_MAP: ClassVar[dict[LoggingLevel, str]] = { logging.DEBUG: colorama.Fore.MAGENTA, logging.INFO: colorama.Fore.BLUE, logging.WARNING: colorama.Fore.YELLOW, From 12e6d3a783de716218c43a19c268e2035006ea42 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 20 Jul 2024 00:22:08 +0200 Subject: [PATCH 08/31] typing tweaks --- src/bot.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bot.py b/src/bot.py index 34c83ea..212b1ff 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from typing import override import disnake from disnake.ext import commands @@ -21,6 +22,7 @@ def __init__(self) -> None: async def on_ready(self) -> None: _log.info(f"Logged in as {self.user}") - async def start(self) -> None: + @override + async def start(self) -> None: # type: ignore[reportincomplatibleMethodOverride] setup_logging() await super().start(EnvVars.BOT_TOKEN, reconnect=True) From fd61256d80a1fd7fd02e7f7be56bdb92557d477d Mon Sep 17 00:00:00 2001 From: Mmesek <13630781+Mmesek@users.noreply.github.com> Date: Sat, 20 Jul 2024 08:49:42 +0200 Subject: [PATCH 09/31] Add example .env file (#3) Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> --- example.env | 1 + 1 file changed, 1 insertion(+) create mode 100644 example.env diff --git a/example.env b/example.env new file mode 100644 index 0000000..26aa581 --- /dev/null +++ b/example.env @@ -0,0 +1 @@ +BOT_TOKEN=bot_token_here From 457f6661e74f88e08061e133b769a96e79720a16 Mon Sep 17 00:00:00 2001 From: Mmesek <13630781+Mmesek@users.noreply.github.com> Date: Sat, 20 Jul 2024 08:50:19 +0200 Subject: [PATCH 10/31] Change or switch to getenv's default (#2) Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> --- src/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants.py b/src/constants.py index 40ca1e2..092d014 100644 --- a/src/constants.py +++ b/src/constants.py @@ -12,7 +12,7 @@ class EnvVars: - BOT_TOKEN: str = os.getenv("BOT_TOKEN") or "" + BOT_TOKEN: str = os.getenv("BOT_TOKEN", "") class Config: From 81c9a0bf9967359baa5a599cacf5932798d15dc9 Mon Sep 17 00:00:00 2001 From: EarthKiii Date: Sat, 20 Jul 2024 20:41:35 +0200 Subject: [PATCH 11/31] Add Ad embed (part 1) --- pyproject.toml | 2 +- src/ad.py | 45 +++++++++++++++++++++++++++++++++++++++ src/exts/test/__init__.py | 0 src/exts/test/test_ad.py | 16 ++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/ad.py create mode 100644 src/exts/test/__init__.py create mode 100644 src/exts/test/test_ad.py diff --git a/pyproject.toml b/pyproject.toml index 2b1811f..709b0f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "CJ24 Unique Universes" +name = "src" version = "0.1.0" description = "Description" authors = ["Snipy7374 "] diff --git a/src/ad.py b/src/ad.py new file mode 100644 index 0000000..094fd55 --- /dev/null +++ b/src/ad.py @@ -0,0 +1,45 @@ +from random import choices +from string import ascii_letters + +import disnake +import requests + +type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None + + +class Ad(disnake.Embed): + """A Wikipedia "ad". + + Generates an embed of a random Wikipedia "ad" + The ad's subject is randomly chosen among the 676 the pages appearing first after searching for 2 + (random) ascii letters + This class is a subclass of `disnake.Embed`. + """ + + def __init__(self) -> None: + page_object: JSON = requests.get( + "https://en.wikipedia.org/w/rest.php/v1/search/title", + { + "limit": 2, + "q": choices(ascii_letters, k=2), # noqa: S311 + # ascii_letters, k=2 -> 26² = 676 possible ads at runtime + }, + timeout=5, + ).json()[1] # the second page_object is chosen to avoid the disambiguation pages + + self.key: str = page_object["key"] + title: str = page_object["title"] + url: str = f"https://en.wikipedia.org/wiki/{self.key}" + image_url: str = page_object["thumbnail"]["url"] + html: str = requests.get( + f"https://en.wikipedia.org/w/rest.php/v1/page/{self.key}/html", + timeout=5, + ).text + + super().__init__(title=title, url=url, description=html) + + self.set_image(url=image_url) + self.set_footer(text="Unique Universes 2024") + + def __repr__(self) -> str: + return f"" diff --git a/src/exts/test/__init__.py b/src/exts/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/exts/test/test_ad.py b/src/exts/test/test_ad.py new file mode 100644 index 0000000..ae32503 --- /dev/null +++ b/src/exts/test/test_ad.py @@ -0,0 +1,16 @@ +import disnake +from src.ad import Ad +from src.bot import Universe + + +class TestAd(disnake.Cog): + def __init__(self, bot: Universe) -> None: + self.bot = bot + + @disnake.slash_command(name="test ad", description="A command to test Ad") + async def test_ad(self, ctx: disnake.ApplicationCommandInteraction) -> None: + await ctx.response.send_message("Test command executed.", embed=Ad()) + + +async def setup(bot: Universe) -> None: + bot.add_cog(TestAd(bot)) From c1d39ce9d70d45f88ad180f8def5145216a95bd3 Mon Sep 17 00:00:00 2001 From: EarthKiii Date: Sat, 20 Jul 2024 21:32:45 +0200 Subject: [PATCH 12/31] Add Ad embed (part 2) (test) --- pyproject.toml | 1 + src/ad.py | 20 +++++++++++++++----- src/bot.py | 1 + src/exts/test/test_ad.py | 7 ++++--- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 709b0f4..4d59db5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ python = "3.12.*" disnake = "^2.9.2" colorama = "^0.4.6" python-dotenv = "^1.0.1" +html2text = "^2024.2.26" [tool.poetry.dev-dependencies] ruff = "~0.5.0" diff --git a/src/ad.py b/src/ad.py index 094fd55..5598214 100644 --- a/src/ad.py +++ b/src/ad.py @@ -1,8 +1,10 @@ +import re from random import choices from string import ascii_letters import disnake import requests +from html2text import HTML2Text type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None @@ -25,20 +27,28 @@ def __init__(self) -> None: # ascii_letters, k=2 -> 26² = 676 possible ads at runtime }, timeout=5, - ).json()[1] # the second page_object is chosen to avoid the disambiguation pages + ).json()["pages"][1] # the second page_object is chosen to avoid the disambiguation pages self.key: str = page_object["key"] title: str = page_object["title"] url: str = f"https://en.wikipedia.org/wiki/{self.key}" - image_url: str = page_object["thumbnail"]["url"] + image_url: str = f"https:{page_object["thumbnail"]["url"]}" html: str = requests.get( f"https://en.wikipedia.org/w/rest.php/v1/page/{self.key}/html", timeout=5, - ).text + ).text # 4096 characters is the maximum length of the description - super().__init__(title=title, url=url, description=html) + html: str = re.sub(r"", "", html) + # HTML2Text doesn't strip figures even if ignore_images is True - self.set_image(url=image_url) + html2text = HTML2Text(baseurl=url, bodywidth=0) + html2text.ignore_images = True + + description: str = html2text.handle(html)[:4096] + + super().__init__(title=title, url=url, description=description) + + self.set_thumbnail(url=image_url) self.set_footer(text="Unique Universes 2024") def __repr__(self) -> str: diff --git a/src/bot.py b/src/bot.py index 212b1ff..6ac11f8 100644 --- a/src/bot.py +++ b/src/bot.py @@ -25,4 +25,5 @@ async def on_ready(self) -> None: @override async def start(self) -> None: # type: ignore[reportincomplatibleMethodOverride] setup_logging() + self.load_extensions("./exts") await super().start(EnvVars.BOT_TOKEN, reconnect=True) diff --git a/src/exts/test/test_ad.py b/src/exts/test/test_ad.py index ae32503..ab4457d 100644 --- a/src/exts/test/test_ad.py +++ b/src/exts/test/test_ad.py @@ -1,16 +1,17 @@ import disnake +from disnake.ext import commands from src.ad import Ad from src.bot import Universe -class TestAd(disnake.Cog): +class TestAd(commands.Cog): def __init__(self, bot: Universe) -> None: self.bot = bot - @disnake.slash_command(name="test ad", description="A command to test Ad") + @commands.slash_command(name="test_ad", description="A command to test Ad") async def test_ad(self, ctx: disnake.ApplicationCommandInteraction) -> None: await ctx.response.send_message("Test command executed.", embed=Ad()) -async def setup(bot: Universe) -> None: +def setup(bot: Universe) -> None: bot.add_cog(TestAd(bot)) From 5178637d54ec3cc4cc68e039103f2012c2083e76 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sat, 20 Jul 2024 23:52:13 +0200 Subject: [PATCH 13/31] update authors and disable poetry package mode --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2b1811f..46b7fcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,9 @@ name = "CJ24 Unique Universes" version = "0.1.0" description = "Description" -authors = ["Snipy7374 "] +authors = ["Snipy7374 ", "Mmesek", "EarthKiii", "stroh13", "Astroyo"] license = "MIT" +package-mode = false [tool.poetry.dependencies] python = "3.12.*" From cf1343ef5be5c53dbb16996c03e6873131cfd27e Mon Sep 17 00:00:00 2001 From: Mmesek <13630781+Mmesek@users.noreply.github.com> Date: Sat, 20 Jul 2024 23:53:07 +0200 Subject: [PATCH 14/31] Add docker setup & instructions (#1) * Add docker setup & instructions Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> * Add docker build workflow Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> * Improve image size & build by leveraging multi-stage build Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> --------- Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> --- .docker/Dockerfile | 35 +++++++++++++++++++++++++++++++++++ .github/workflows/docker.yml | 36 ++++++++++++++++++++++++++++++++++++ README.md | 14 +++++++++++++- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 .docker/Dockerfile create mode 100644 .github/workflows/docker.yml diff --git a/.docker/Dockerfile b/.docker/Dockerfile new file mode 100644 index 0000000..ebc9f17 --- /dev/null +++ b/.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/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..3503a92 --- /dev/null +++ b/.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/README.md b/README.md index 1b505d5..ba91d43 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,18 @@ This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. +# 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 +``` + # Setting Up the Dev Env If you are a team member make sure to read this section, otherwise you can skip this. @@ -71,7 +83,7 @@ To be able to execute the bot locally you will need to create and grab the token 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 ``` From 37250339959d4ad90c7531b7df8037ed0ffecd82 Mon Sep 17 00:00:00 2001 From: Mmesek <13630781+Mmesek@users.noreply.github.com> Date: Sun, 21 Jul 2024 22:22:58 +0200 Subject: [PATCH 15/31] Repository Cleanup (#7) * Update README to include how to run project without docker Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> * Remove samples folder Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> * Reorganize installation steps Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> --------- Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> --- README.md | 53 ++++++++++++++++++++++++------------------ samples/Pipfile | 15 ------------ samples/pyproject.toml | 19 --------------- 3 files changed, 31 insertions(+), 56 deletions(-) delete mode 100644 samples/Pipfile delete mode 100644 samples/pyproject.toml diff --git a/README.md b/README.md index ba91d43..b300201 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. -# Docker +# Docker Usage ## Building ```sh @@ -14,6 +14,32 @@ docker build -t unique-universes -f .docker/Dockerfile . docker run --rm -it -e BOT_TOKEN=YOUR_TOKEN_HERE unique-universes ``` +# Manual installation +## 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 +``` + # Setting Up the Dev Env If you are a team member make sure to read this section, otherwise you can skip this. @@ -61,32 +87,15 @@ $ .venv/bin/Activate.ps1 To deactivate the venv type `deactivate` in the terminal. -## Installing the dependencies +## Pre-commit -To install all the required dependencies to run the bot execute these commands: +Before commiting, make sure to run any pre-commit checks. +Install pre-commit if you haven't done yet: -```shell -pip install poetry -``` -and then -```shell -poetry install -``` -finally run -```shell +```sh pre-commit 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 -``` - ## Final words Now you're ready to go, your local copy of the repository is ready to be ran. diff --git a/samples/Pipfile b/samples/Pipfile deleted file mode 100644 index 27673c0..0000000 --- a/samples/Pipfile +++ /dev/null @@ -1,15 +0,0 @@ -# Sample Pipfile. - -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] - -[dev-packages] -ruff = "~=0.5.0" -pre-commit = "~=3.7.1" - -[requires] -python_version = "3.12" diff --git a/samples/pyproject.toml b/samples/pyproject.toml deleted file mode 100644 index 835045d..0000000 --- a/samples/pyproject.toml +++ /dev/null @@ -1,19 +0,0 @@ -# Sample poetry configuration. - -[tool.poetry] -name = "Name" -version = "0.1.0" -description = "Description" -authors = ["Author 1 "] -license = "MIT" - -[tool.poetry.dependencies] -python = "3.12.*" - -[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" From f35d8fe17cd7367eda8c862f5e09adbdc32395b7 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sun, 21 Jul 2024 22:32:01 +0200 Subject: [PATCH 16/31] load exts at startup --- src/bot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bot.py b/src/bot.py index 212b1ff..796f8e9 100644 --- a/src/bot.py +++ b/src/bot.py @@ -25,4 +25,5 @@ async def on_ready(self) -> None: @override async def start(self) -> None: # type: ignore[reportincomplatibleMethodOverride] setup_logging() + self.load_extensions("./src/exts") await super().start(EnvVars.BOT_TOKEN, reconnect=True) From 16671951133c3be2a9a4444e5e3bde19d93c267c Mon Sep 17 00:00:00 2001 From: Jonas Charrier Date: Tue, 23 Jul 2024 18:37:29 +0200 Subject: [PATCH 17/31] Use a json of ads instead of random Wikipedia articles --- pyproject.toml | 1 - src/ad.py | 47 +++++++++++------------------------------------ src/ads.json | 5 +++++ 3 files changed, 16 insertions(+), 37 deletions(-) create mode 100644 src/ads.json diff --git a/pyproject.toml b/pyproject.toml index 4d59db5..709b0f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ python = "3.12.*" disnake = "^2.9.2" colorama = "^0.4.6" python-dotenv = "^1.0.1" -html2text = "^2024.2.26" [tool.poetry.dev-dependencies] ruff = "~0.5.0" diff --git a/src/ad.py b/src/ad.py index 5598214..36b70e2 100644 --- a/src/ad.py +++ b/src/ad.py @@ -1,10 +1,8 @@ -import re -from random import choices -from string import ascii_letters +import json +from pathlib import Path +from random import choice import disnake -import requests -from html2text import HTML2Text type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None @@ -19,37 +17,14 @@ class Ad(disnake.Embed): """ def __init__(self) -> None: - page_object: JSON = requests.get( - "https://en.wikipedia.org/w/rest.php/v1/search/title", - { - "limit": 2, - "q": choices(ascii_letters, k=2), # noqa: S311 - # ascii_letters, k=2 -> 26² = 676 possible ads at runtime - }, - timeout=5, - ).json()["pages"][1] # the second page_object is chosen to avoid the disambiguation pages - - self.key: str = page_object["key"] - title: str = page_object["title"] - url: str = f"https://en.wikipedia.org/wiki/{self.key}" - image_url: str = f"https:{page_object["thumbnail"]["url"]}" - html: str = requests.get( - f"https://en.wikipedia.org/w/rest.php/v1/page/{self.key}/html", - timeout=5, - ).text # 4096 characters is the maximum length of the description - - html: str = re.sub(r"", "", html) - # HTML2Text doesn't strip figures even if ignore_images is True - - html2text = HTML2Text(baseurl=url, bodywidth=0) - html2text.ignore_images = True - - description: str = html2text.handle(html)[:4096] - - super().__init__(title=title, url=url, description=description) - - self.set_thumbnail(url=image_url) + with Path("./ads.json").open() as file: + data: JSON = json.loads(file.read()) + data: JSON = choice(data) # noqa: S311 + self.title: str = data["title"] + super().__init__(title=self.title, description=data["description"]) + + self.set_thumbnail(url=data["thumbnail_url"]) self.set_footer(text="Unique Universes 2024") def __repr__(self) -> str: - return f"" + return f"" diff --git a/src/ads.json b/src/ads.json new file mode 100644 index 0000000..3a1f5d8 --- /dev/null +++ b/src/ads.json @@ -0,0 +1,5 @@ +[{ + "title": "Ad 1", + "thumbnail_url": "https://picsum.photos/200/300", + "description": "Description of Ad 1" +}] From 212eb5307dda3736d44676938efcc3f1b7f4f586 Mon Sep 17 00:00:00 2001 From: Jonas Charrier Date: Tue, 23 Jul 2024 18:50:06 +0200 Subject: [PATCH 18/31] Revert "Merge branch 'main' into main" This reverts commit 47c243ce5daa5f5f34ac807232d05b91c41daee2, reversing changes made to e1a8c370005973d21b54840ee5f57a251f76a91c. --- pyproject.toml | 2 +- src/ad.py | 30 ------------------------------ src/ads.json | 5 ----- src/exts/test/__init__.py | 0 src/exts/test/test_ad.py | 17 ----------------- 5 files changed, 1 insertion(+), 53 deletions(-) delete mode 100644 src/ad.py delete mode 100644 src/ads.json delete mode 100644 src/exts/test/__init__.py delete mode 100644 src/exts/test/test_ad.py diff --git a/pyproject.toml b/pyproject.toml index dbe7709..46b7fcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "src" +name = "CJ24 Unique Universes" version = "0.1.0" description = "Description" authors = ["Snipy7374 ", "Mmesek", "EarthKiii", "stroh13", "Astroyo"] diff --git a/src/ad.py b/src/ad.py deleted file mode 100644 index 36b70e2..0000000 --- a/src/ad.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -from pathlib import Path -from random import choice - -import disnake - -type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None - - -class Ad(disnake.Embed): - """A Wikipedia "ad". - - Generates an embed of a random Wikipedia "ad" - The ad's subject is randomly chosen among the 676 the pages appearing first after searching for 2 - (random) ascii letters - This class is a subclass of `disnake.Embed`. - """ - - def __init__(self) -> None: - with Path("./ads.json").open() as file: - data: JSON = json.loads(file.read()) - data: JSON = choice(data) # noqa: S311 - self.title: str = data["title"] - super().__init__(title=self.title, description=data["description"]) - - self.set_thumbnail(url=data["thumbnail_url"]) - self.set_footer(text="Unique Universes 2024") - - def __repr__(self) -> str: - return f"" diff --git a/src/ads.json b/src/ads.json deleted file mode 100644 index 3a1f5d8..0000000 --- a/src/ads.json +++ /dev/null @@ -1,5 +0,0 @@ -[{ - "title": "Ad 1", - "thumbnail_url": "https://picsum.photos/200/300", - "description": "Description of Ad 1" -}] diff --git a/src/exts/test/__init__.py b/src/exts/test/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/exts/test/test_ad.py b/src/exts/test/test_ad.py deleted file mode 100644 index ab4457d..0000000 --- a/src/exts/test/test_ad.py +++ /dev/null @@ -1,17 +0,0 @@ -import disnake -from disnake.ext import commands -from src.ad import Ad -from src.bot import Universe - - -class TestAd(commands.Cog): - def __init__(self, bot: Universe) -> None: - self.bot = bot - - @commands.slash_command(name="test_ad", description="A command to test Ad") - async def test_ad(self, ctx: disnake.ApplicationCommandInteraction) -> None: - await ctx.response.send_message("Test command executed.", embed=Ad()) - - -def setup(bot: Universe) -> None: - bot.add_cog(TestAd(bot)) From 9976632f695b63ea1ccf01d0715995885f259b27 Mon Sep 17 00:00:00 2001 From: Astroyo <139684641+Astroyo@users.noreply.github.com> Date: Sat, 27 Jul 2024 09:14:25 -0700 Subject: [PATCH 19/31] new: database (#9) * added database * safety * Update src/database.py * Update src/database.py * Update src/database.py * Update src/database.py * lint and format * added type hinting for player obj * changed where db connection is made * implement custom run flow for db closure --------- Co-authored-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> --- pyproject.toml | 1 + src/__main__.py | 51 +++++++++++++++++-- src/bot.py | 14 ++++- src/database.py | 133 ++++++++++++++++++++++++++++++++++++++++++++++++ src/logger.py | 2 +- 5 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 src/database.py diff --git a/pyproject.toml b/pyproject.toml index 46b7fcb..0c10011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ package-mode = false python = "3.12.*" disnake = "^2.9.2" colorama = "^0.4.6" +aiosqlite = "0.20.0" python-dotenv = "^1.0.1" [tool.poetry.dev-dependencies] diff --git a/src/__main__.py b/src/__main__.py index f48742a..d4ccdf5 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,12 +1,55 @@ import asyncio +import logging from src.bot import Universe +from src.database import setup_db +_log = logging.getLogger(__name__) -async def main() -> None: - bot = Universe() - await bot.start() + +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__": - asyncio.run(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/src/bot.py b/src/bot.py index 796f8e9..5ec7c11 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,23 +1,31 @@ from __future__ import annotations import logging -from typing import override +from typing import TYPE_CHECKING, override import disnake from disnake.ext import commands 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 Universe(commands.InteractionBot): - def __init__(self) -> None: + def __init__(self, loop: AbstractEventLoop, db_connection: Connection) -> None: super().__init__( intents=disnake.Intents.none(), + loop=loop, ) + self.database = Database(connection=db_connection) async def on_ready(self) -> None: _log.info(f"Logged in as {self.user}") @@ -25,5 +33,7 @@ async def on_ready(self) -> None: @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/src/database.py b/src/database.py new file mode 100644 index 0000000..00b0986 --- /dev/null +++ b/src/database.py @@ -0,0 +1,133 @@ +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 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/src/logger.py b/src/logger.py index 465f5fa..57679d6 100644 --- a/src/logger.py +++ b/src/logger.py @@ -21,7 +21,7 @@ def setup_logging() -> None: logger.addHandler(handler) for k, v in logger.manager.loggerDict.items(): - if k.startswith("src") and isinstance(v, logging.Logger): + if k.startswith(("src", "__main__")) and isinstance(v, logging.Logger): v.setLevel(Config.LOGGING_LEVEL) v.addHandler(handler) From 158311790a97a0a0d3cf189f3f18dd826e19e500 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sun, 28 Jul 2024 17:04:11 +0200 Subject: [PATCH 20/31] feat: shoot minigame (#13) * implement the base for shoot minigame * Add sample statisic fields Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> * Add aim buttons Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> * Add embed attributes to ShootMenu Added embed attributes, did ammunition check in shoot_callback * Add values to fields Add values to fields from ShootMenu * implement shoot minigame logic * Add Ad embed (part 1) * Add Ad embed (part 2) (test) * Use a json of ads instead of random Wikipedia articles * Revert "Merge branch 'main' into main" This reverts commit 47c243ce5daa5f5f34ac807232d05b91c41daee2, reversing changes made to e1a8c370005973d21b54840ee5f57a251f76a91c. * new: database (#9) * added database * safety * Update src/database.py * Update src/database.py * Update src/database.py * Update src/database.py * lint and format * added type hinting for player obj * changed where db connection is made * implement custom run flow for db closure --------- Co-authored-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> * finalize shoot minigame adding database stats --------- Signed-off-by: Mmesek <13630781+Mmesek@users.noreply.github.com> Co-authored-by: Mmesek <13630781+Mmesek@users.noreply.github.com> Co-authored-by: stroh13 Co-authored-by: EarthKiii Co-authored-by: Astroyo <139684641+Astroyo@users.noreply.github.com> --- src/database.py | 23 ++++ src/exts/minigames.py | 101 +++++++++++++++ src/views/__init__.py | 0 src/views/shoot.py | 295 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 src/exts/minigames.py create mode 100644 src/views/__init__.py create mode 100644 src/views/shoot.py diff --git a/src/database.py b/src/database.py index 00b0986..4e2d4fc 100644 --- a/src/database.py +++ b/src/database.py @@ -120,6 +120,29 @@ async def increase(self, user_id: int, value_name: str) -> None: 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) diff --git a/src/exts/minigames.py b/src/exts/minigames.py new file mode 100644 index 0000000..23709c1 --- /dev/null +++ b/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/src/views/__init__.py b/src/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/views/shoot.py b/src/views/shoot.py new file mode 100644 index 0000000..7570708 --- /dev/null +++ b/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.stats.total_shots}\n" + f"Total Hits: {self.stats.hits}\n" + f"Total Shots Missed: {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"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(1) + + 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.") From fc0ef9bae12ad9bd2cf79d818822afd0b9090b03 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sun, 28 Jul 2024 17:42:56 +0200 Subject: [PATCH 21/31] feat: implement an about command (#12) * Added about command with bot instantiat Instantiated the bot, and added bot.run() and implemented the /about command * Updated .gitignore Includes .history * Added about command with bot instantiat Instantiated the bot, and added bot.run() and implemented the /about command * Updated .gitignore Includes .history * fix about command * remove version field * Add Ad embed (part 1) * Add Ad embed (part 2) (test) * Use a json of ads instead of random Wikipedia articles * Revert "Merge branch 'main' into main" This reverts commit 47c243ce5daa5f5f34ac807232d05b91c41daee2, reversing changes made to e1a8c370005973d21b54840ee5f57a251f76a91c. * new: database (#9) * added database * safety * Update src/database.py * Update src/database.py * Update src/database.py * Update src/database.py * lint and format * added type hinting for player obj * changed where db connection is made * implement custom run flow for db closure --------- Co-authored-by: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> * Added about command with bot instantiat Instantiated the bot, and added bot.run() and implemented the /about command * fix about command * add shoot command mention in about command response --------- Co-authored-by: stroh13 Co-authored-by: EarthKiii Co-authored-by: Astroyo <139684641+Astroyo@users.noreply.github.com> --- .gitignore | 1 + src/bot.py | 29 ++++++++++++++++++++++++++++- src/exts/info.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/exts/info.py diff --git a/.gitignore b/.gitignore index c1a737a..4b208ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ venv .env env +.history # Unittest reports .coverage* diff --git a/src/bot.py b/src/bot.py index 5ec7c11..1c32b23 100644 --- a/src/bot.py +++ b/src/bot.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, override import disnake -from disnake.ext import commands +from disnake.ext import commands, tasks from src.constants import EnvVars from src.database import Database from src.logger import setup_logging @@ -19,6 +19,28 @@ _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__( @@ -26,10 +48,15 @@ def __init__(self, loop: AbstractEventLoop, db_connection: Connection) -> 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() diff --git a/src/exts/info.py b/src/exts/info.py new file mode 100644 index 0000000..641a0d0 --- /dev/null +++ b/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 None: + bot.add_cog(InfoCommands(bot)) From 04f96265edf985b9cd21bf58706cad8cea9eedc0 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sun, 28 Jul 2024 17:45:35 +0200 Subject: [PATCH 22/31] fix shoot command ping in about command --- src/exts/info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/exts/info.py b/src/exts/info.py index 641a0d0..80e106c 100644 --- a/src/exts/info.py +++ b/src/exts/info.py @@ -23,7 +23,7 @@ async def about(self, inter: disnake.GuildCommandInteraction) -> None: "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, ) From 4dcf0f47861820893d9636451cd0ee1ac5ed69d1 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Sun, 28 Jul 2024 17:52:24 +0200 Subject: [PATCH 23/31] fix shoot stats incorrect behaviour --- src/views/shoot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/shoot.py b/src/views/shoot.py index 7570708..16b3f98 100644 --- a/src/views/shoot.py +++ b/src/views/shoot.py @@ -172,9 +172,9 @@ async def update_message(self) -> None: field["value"] = ( f"Wins: {self.cached_player.wins}\n" f"Losses: {self.cached_player.loses}\n" - f"Total Shots: {self.stats.total_shots}\n" - f"Total Hits: {self.stats.hits}\n" - f"Total Shots Missed: {self.stats.misses}" + 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 @@ -185,7 +185,7 @@ async def update_message(self) -> None: ): 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"Total Shots: {self.stats.total_shots}") + 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) @@ -244,7 +244,7 @@ async def shoot_callback(self, _: disnake.ui.Button[ShootMenu], inter: disnake.M ), ephemeral=True, ) - await sleep(1) + await sleep(0.5) if self.stats.enemy_hitted: await inter.send( From ece06a064f58d27b294850181ef2207ed7983213 Mon Sep 17 00:00:00 2001 From: Jonas Charrier Date: Wed, 31 Jul 2024 18:17:16 +0200 Subject: [PATCH 24/31] Add documentation about the application --- README.md | 23 +++++++++++++++++++++-- readme_assets/about.png | Bin 0 -> 21658 bytes readme_assets/minigame.png | Bin 0 -> 64828 bytes 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 readme_assets/about.png create mode 100644 readme_assets/minigame.png diff --git a/README.md b/README.md index b300201..c619ec8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,23 @@ -# Unique Universes CJ24 team entry +# 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](https://media.githubusercontent.com/media/Snipy7374/code-jam-24-universes/main/readme_assets/minigame.png) + +- /about - Get information about the application and the team. + +![About](https://media.githubusercontent.com/media/Snipy7374/code-jam-24-universes/main/readme_assets/about.png) + +# For the developers This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. @@ -65,7 +84,7 @@ python3 -m venv .venv > 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 `exaple_folder/.venv`) if needed. If you provide only `.venv` python will create the environment in the same directory where you are running the command. +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: diff --git a/readme_assets/about.png b/readme_assets/about.png new file mode 100644 index 0000000000000000000000000000000000000000..8cf84648802b745ede98a6e9e2f4c0dfc3522f8f GIT binary patch literal 21658 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 Bh{yl{ literal 0 HcmV?d00001 diff --git a/readme_assets/minigame.png b/readme_assets/minigame.png new file mode 100644 index 0000000000000000000000000000000000000000..83988585079e6e12c79fbaa4abdbf595314f1372 GIT binary patch literal 64828 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 G`2QC}<@2)u literal 0 HcmV?d00001 From a2fd4649aeaf6823adcb4f9735d9c12f3d55f2a9 Mon Sep 17 00:00:00 2001 From: Jonas Charrier Date: Wed, 31 Jul 2024 18:18:57 +0200 Subject: [PATCH 25/31] Fix images paths --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c619ec8..54725e8 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ The minigame relies on the user’s ability to calculate a shot with the help of - /shoot - Play the minigame. -![The game](https://media.githubusercontent.com/media/Snipy7374/code-jam-24-universes/main/readme_assets/minigame.png) +![The game](./readme_assets/minigame.png) - /about - Get information about the application and the team. -![About](https://media.githubusercontent.com/media/Snipy7374/code-jam-24-universes/main/readme_assets/about.png) +![About](./readme_assets/about.png) # For the developers From d976fa141a2d3bca2e51de0f261a515a19f02c19 Mon Sep 17 00:00:00 2001 From: Jonas Charrier Date: Wed, 31 Jul 2024 18:35:45 +0200 Subject: [PATCH 26/31] Add contribution section to the readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 54725e8..43065d9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,14 @@ The minigame relies on the user’s ability to calculate a shot with the help of ![About](./readme_assets/about.png) +# Team members contribution + +- **@Snipy7374**: Worked on the bot's structure, the game design and the game logic (physics/maths & database communication). +- **@Mmesek**: Worked on the game design, the game logic (physics/maths) and the UI. +- **@stroh13**: Worked on the about command and the game logic (physics/maths). +- **@Astroyo**: Worked on the game logic (database communication). +- **@EarthKii**: Worked on the game design and the documentation. + # For the developers This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. From 3d05051133251c36f369a11b220f92372bcc6b15 Mon Sep 17 00:00:00 2001 From: Jonas Charrier Date: Wed, 31 Jul 2024 18:37:38 +0200 Subject: [PATCH 27/31] Add missing details for the contribution --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 43065d9..dc953e6 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ The minigame relies on the user’s ability to calculate a shot with the help of # Team members contribution -- **@Snipy7374**: Worked on the bot's structure, the game design and the game logic (physics/maths & database communication). -- **@Mmesek**: Worked on the game design, the game logic (physics/maths) and the UI. +- **@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). -- **@EarthKii**: Worked on the game design and the documentation. +- **@EarthKiii**: Worked on the game design and the documentation. # For the developers From e3cc0100e5f34a25b4fca99a7142d5f163770c20 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Wed, 31 Jul 2024 23:32:10 +0200 Subject: [PATCH 28/31] add info about critic bug --- README.md | 35 +++++++++++++++++++++++++++++++++++ diff.txt | 24 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 diff.txt diff --git a/README.md b/README.md index dc953e6..c9c0883 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,41 @@ The minigame relies on the user’s ability to calculate a shot with the help of - **@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. + # For the developers This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. diff --git a/diff.txt b/diff.txt new file mode 100644 index 0000000..269d74a --- /dev/null +++ b/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)) From 899fee81089933a3520f0d766ee7da916c70f0d9 Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Wed, 31 Jul 2024 23:40:20 +0200 Subject: [PATCH 29/31] add explanation for shoot game --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index c9c0883..08b051b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ The minigame relies on the user’s ability to calculate a shot with the help of ![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) From 1ade90c9cdc31838eec6a1baa2a0da87e85afb3b Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Wed, 31 Jul 2024 23:44:40 +0200 Subject: [PATCH 30/31] remove wrong section title --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 08b051b..1e55eda 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ If you aren't a nerd (like me) and don't know what all that weird things at the 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. -# For the developers This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. From 7959a82291d04cc526d8d0b1546f9bade4bd887a Mon Sep 17 00:00:00 2001 From: Snipy7374 <100313469+Snipy7374@users.noreply.github.com> Date: Wed, 31 Jul 2024 23:46:06 +0200 Subject: [PATCH 31/31] fix installation steps info --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1e55eda..d149683 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ If the code snippet above is weird looking blame GitHub, to avoid any (and i mea This repository is the entry of the unique universes team for the Python Discord Code Jam 2024. -# Docker Usage +You can run the bot either with Docker or manually. + +# Running the bot with Docker ## Building ```sh @@ -79,7 +81,7 @@ docker build -t unique-universes -f .docker/Dockerfile . docker run --rm -it -e BOT_TOKEN=YOUR_TOKEN_HERE unique-universes ``` -# Manual installation +# Running the bot manually ## Installing the dependencies To install all the required dependencies to run the bot execute these commands: @@ -105,6 +107,8 @@ BOT_TOKEN=ExampleOfBotTokenHere 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.